Merge "Create overriding mechanism for ThreePaneScaffold" into androidx-main
diff --git a/activity/integration-tests/baselineprofile/build.gradle b/activity/integration-tests/baselineprofile/build.gradle
index b340454..dd1f0be 100644
--- a/activity/integration-tests/baselineprofile/build.gradle
+++ b/activity/integration-tests/baselineprofile/build.gradle
@@ -35,7 +35,7 @@
         minSdkVersion 23
         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
     }
-    testOptions.managedDevices.devices {
+    testOptions.managedDevices.allDevices {
         pixel6Api31(ManagedVirtualDevice) {
             device = "Pixel 6"
             apiLevel = 31
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 1c56469..9f47f58 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
@@ -17,21 +17,26 @@
 package androidx.appsearch.localstorage;
 
 import static androidx.appsearch.app.AppSearchResult.RESULT_INVALID_ARGUMENT;
+import static androidx.appsearch.app.AppSearchResult.RESULT_OUT_OF_SPACE;
 import static androidx.appsearch.localstorage.util.PrefixUtil.addPrefixToDocument;
 import static androidx.appsearch.localstorage.util.PrefixUtil.createPrefix;
 import static androidx.appsearch.localstorage.util.PrefixUtil.removePrefixesFromDocument;
 import static androidx.appsearch.localstorage.visibilitystore.VisibilityStore.ANDROID_V_OVERLAY_DATABASE_NAME;
 import static androidx.appsearch.localstorage.visibilitystore.VisibilityStore.VISIBILITY_DATABASE_NAME;
 import static androidx.appsearch.localstorage.visibilitystore.VisibilityStore.VISIBILITY_PACKAGE_NAME;
+import static androidx.appsearch.testutil.AppSearchTestUtils.calculateDigest;
 import static androidx.appsearch.testutil.AppSearchTestUtils.createMockVisibilityChecker;
+import static androidx.appsearch.testutil.AppSearchTestUtils.generateRandomBytes;
 
 import static com.google.common.truth.Truth.assertThat;
 
 import static org.junit.Assert.assertThrows;
 
 import android.content.Context;
+import android.os.ParcelFileDescriptor;
 
 import androidx.annotation.NonNull;
+import androidx.appsearch.app.AppSearchBlobHandle;
 import androidx.appsearch.app.AppSearchResult;
 import androidx.appsearch.app.AppSearchSchema;
 import androidx.appsearch.app.GenericDocument;
@@ -95,6 +100,9 @@
 import org.junit.rules.TemporaryFolder;
 
 import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
@@ -117,18 +125,20 @@
     private final CallerAccess mSelfCallerAccess = new CallerAccess(mContext.getPackageName());
 
     private AppSearchImpl mAppSearchImpl;
+    private AppSearchConfig mUnlimitedConfig = new AppSearchConfigImpl(
+            new UnlimitedLimitConfig(),
+            new LocalStorageIcingOptionsConfig()
+    );
 
     @Before
     public void setUp() throws Exception {
         mAppSearchDir = mTemporaryFolder.newFolder();
         mAppSearchImpl = AppSearchImpl.create(
                 mAppSearchDir,
-                new AppSearchConfigImpl(
-                        new UnlimitedLimitConfig(),
-                        new LocalStorageIcingOptionsConfig()
-                ),
+                mUnlimitedConfig,
                 /*initStatsBuilder=*/ null,
                 /*visibilityChecker=*/ null,
+                /*revocableFileDescriptorStore=*/ null,
                 ALWAYS_OPTIMIZE);
     }
 
@@ -513,7 +523,10 @@
         mAppSearchImpl = AppSearchImpl.create(
                 mAppSearchDir, new AppSearchConfigImpl(new UnlimitedLimitConfig(),
                         new LocalStorageIcingOptionsConfig()),
-                initStatsBuilder, /*visibilityChecker=*/ null, ALWAYS_OPTIMIZE);
+                initStatsBuilder,
+                /*visibilityChecker=*/ null,
+                /*revocableFileDescriptorStore=*/ null,
+                ALWAYS_OPTIMIZE);
 
         // Check recovery state
         InitializeStats initStats = initStatsBuilder.build();
@@ -746,7 +759,8 @@
                         new LocalStorageIcingOptionsConfig()
                 ),
                 /*initStatsBuilder=*/ null,
-                mockVisibilityChecker, ALWAYS_OPTIMIZE
+                mockVisibilityChecker, /*revocableFileDescriptorStore=*/ null,
+                ALWAYS_OPTIMIZE
         );
 
         // Insert package1 schema
@@ -921,7 +935,8 @@
                         new LocalStorageIcingOptionsConfig()
                 ),
                 /*initStatsBuilder=*/ null,
-                mockVisibilityChecker, ALWAYS_OPTIMIZE
+                mockVisibilityChecker, /*revocableFileDescriptorStore=*/ null,
+                ALWAYS_OPTIMIZE
         );
 
         AppSearchSchema.StringPropertyConfig personField =
@@ -2238,7 +2253,168 @@
     }
 
     @Test
-    public void testClearPackageData() throws AppSearchException {
+    public void testWriteAndReadBlob() throws Exception {
+        mAppSearchImpl = AppSearchImpl.create(
+                mAppSearchDir,
+                new AppSearchConfigImpl(new UnlimitedLimitConfig(),
+                        new LocalStorageIcingOptionsConfig()),
+                /*initStatsBuilder=*/ null,
+                /*visibilityChecker=*/ null,
+                new JetpackRevocableFileDescriptorStore(mUnlimitedConfig),
+                ALWAYS_OPTIMIZE);
+        byte[] data = generateRandomBytes(20 * 1024); // 20 KiB
+        byte[] digest = calculateDigest(data);
+        AppSearchBlobHandle handle = AppSearchBlobHandle.createWithSha256(
+                digest, "package", "db1", "ns");
+        try (ParcelFileDescriptor writePfd = mAppSearchImpl.openWriteBlob("package", "db1", handle);
+                OutputStream outputStream = new ParcelFileDescriptor
+                        .AutoCloseOutputStream(writePfd)) {
+            outputStream.write(data);
+            outputStream.flush();
+        }
+
+        // commit the change and read the blob.
+        mAppSearchImpl.commitBlob("package", "db1", handle);
+        byte[] readBytes = new byte[20 * 1024];
+        try (ParcelFileDescriptor readPfd =  mAppSearchImpl.openReadBlob("package", "db1", handle);
+                InputStream inputStream = new ParcelFileDescriptor.AutoCloseInputStream(readPfd)) {
+            inputStream.read(readBytes);
+        }
+        assertThat(readBytes).isEqualTo(data);
+    }
+
+    @Test
+    public void testOpenReadForWrite_notAllowed() throws Exception {
+        mAppSearchImpl = AppSearchImpl.create(
+                mAppSearchDir,
+                new AppSearchConfigImpl(new UnlimitedLimitConfig(),
+                        new LocalStorageIcingOptionsConfig()),
+                /*initStatsBuilder=*/ null,
+                /*visibilityChecker=*/ null,
+                new JetpackRevocableFileDescriptorStore(mUnlimitedConfig),
+                ALWAYS_OPTIMIZE);
+        byte[] data = generateRandomBytes(20); // 20 Bytes
+        byte[] digest = calculateDigest(data);
+        AppSearchBlobHandle handle = AppSearchBlobHandle.createWithSha256(
+                digest, "package", "db1", "ns");
+        try (ParcelFileDescriptor writePfd = mAppSearchImpl.openWriteBlob("package", "db1", handle);
+                OutputStream outputStream = new ParcelFileDescriptor
+                        .AutoCloseOutputStream(writePfd)) {
+            outputStream.write(data);
+            outputStream.flush();
+        }
+
+        // commit the change and read the blob.
+        mAppSearchImpl.commitBlob("package", "db1", handle);
+
+        // Open output stream on read-only pfd.
+        assertThrows(IOException.class, () -> {
+            try (ParcelFileDescriptor readPfd =
+                         mAppSearchImpl.openReadBlob("package", "db1", handle);
+                    OutputStream outputStream = new ParcelFileDescriptor
+                            .AutoCloseOutputStream(readPfd)) {
+                outputStream.write(data);
+            }
+        });
+    }
+
+    @Test
+    public void testOpenWriteForRead_allowed() throws Exception {
+        mAppSearchImpl = AppSearchImpl.create(
+                mAppSearchDir,
+                new AppSearchConfigImpl(new UnlimitedLimitConfig(),
+                        new LocalStorageIcingOptionsConfig()),
+                /*initStatsBuilder=*/ null,
+                /*visibilityChecker=*/ null,
+                new JetpackRevocableFileDescriptorStore(mUnlimitedConfig),
+                ALWAYS_OPTIMIZE);
+        byte[] data = generateRandomBytes(20); // 20 Bytes
+        byte[] digest = calculateDigest(data);
+        AppSearchBlobHandle handle = AppSearchBlobHandle.createWithSha256(
+                digest, "package", "db1", "ns");
+        // openWriteBlob returns read and write fd.
+        try (ParcelFileDescriptor writePfd = mAppSearchImpl.openWriteBlob("package", "db1", handle);
+                InputStream inputStream = new ParcelFileDescriptor
+                        .AutoCloseInputStream(writePfd)) {
+            inputStream.read(new byte[10]);
+        }
+    }
+
+    @Test
+    public void testRevokeFileDescriptor() throws Exception {
+        mAppSearchImpl = AppSearchImpl.create(
+                mAppSearchDir,
+                new AppSearchConfigImpl(new UnlimitedLimitConfig(),
+                        new LocalStorageIcingOptionsConfig()),
+                /*initStatsBuilder=*/ null,
+                /*visibilityChecker=*/ null,
+                new JetpackRevocableFileDescriptorStore(mUnlimitedConfig),
+                ALWAYS_OPTIMIZE);
+        byte[] data = generateRandomBytes(20 * 1024); // 20 KiB
+        byte[] digest = calculateDigest(data);
+        AppSearchBlobHandle handle = AppSearchBlobHandle.createWithSha256(
+                digest, "package", "db1", "ns");
+        try (ParcelFileDescriptor writePfd =
+                     mAppSearchImpl.openWriteBlob("package", "db1", handle)) {
+            // Clear package data and all file descriptor to that package will be revoked.
+            mAppSearchImpl.clearPackageData("package");
+
+            assertThrows(IOException.class, () -> {
+                try (OutputStream outputStream = new ParcelFileDescriptor
+                        .AutoCloseOutputStream(writePfd)) {
+                    outputStream.write(data);
+                }
+            });
+        }
+
+        // reopen file descriptor could work.
+        try (ParcelFileDescriptor writePfd2 =
+                     mAppSearchImpl.openWriteBlob("package", "db1", handle)) {
+            try (OutputStream outputStream = new ParcelFileDescriptor
+                    .AutoCloseOutputStream(writePfd2)) {
+                outputStream.write(data);
+            }
+            // close the AppSearchImpl will revoke all sent fds.
+            mAppSearchImpl.close();
+            assertThrows(IOException.class, () -> {
+                try (OutputStream outputStream = new ParcelFileDescriptor
+                        .AutoCloseOutputStream(writePfd2)) {
+                    outputStream.write(data);
+                }
+            });
+        }
+    }
+
+    @Test
+    public void testInvalidBlobHandle() throws Exception {
+        mAppSearchImpl = AppSearchImpl.create(
+                mAppSearchDir,
+                new AppSearchConfigImpl(new UnlimitedLimitConfig(),
+                        new LocalStorageIcingOptionsConfig()),
+                /*initStatsBuilder=*/ null,
+                /*visibilityChecker=*/ null,
+                new JetpackRevocableFileDescriptorStore(mUnlimitedConfig),
+                ALWAYS_OPTIMIZE);
+        byte[] data = generateRandomBytes(20 * 1024); // 20 KiB
+        byte[] digest = calculateDigest(data);
+        AppSearchBlobHandle handle = AppSearchBlobHandle.createWithSha256(
+                digest, "package", "db1", "ns");
+
+        AppSearchException e = assertThrows(AppSearchException.class,
+                () -> mAppSearchImpl.openWriteBlob("wrongPackageName", "db1", handle));
+        assertThat(e.getResultCode()).isEqualTo(RESULT_INVALID_ARGUMENT);
+        assertThat(e.getMessage()).contains("Blob package doesn't match calling package, "
+                + "calling package: wrongPackageName, blob package: package");
+
+        e = assertThrows(AppSearchException.class,
+                () -> mAppSearchImpl.openWriteBlob("package", "wrongDb", handle));
+        assertThat(e.getResultCode()).isEqualTo(RESULT_INVALID_ARGUMENT);
+        assertThat(e.getMessage()).contains("Blob database doesn't match calling database, "
+                + "calling database: wrongDb, blob database: db1");
+    }
+
+    @Test
+    public void testClearPackageData() throws Exception {
         List<SchemaTypeConfigProto> existingSchemas =
                 mAppSearchImpl.getSchemaProtoLocked().getTypesList();
         Map<String, Set<String>> existingDatabases = mAppSearchImpl.getPackageToDatabases();
@@ -2911,6 +3087,7 @@
                 ),
                 /*initStatsBuilder=*/ null,
                 /*visibilityChecker=*/ null,
+                /*revocableFileDescriptorStore=*/ null,
                 ALWAYS_OPTIMIZE);
         getResult = appSearchImpl2.getDocument("package", "database", "namespace1",
                 "id1",
@@ -2983,6 +3160,7 @@
                 ),
                 /*initStatsBuilder=*/ null,
                 /*visibilityChecker=*/ null,
+                /*revocableFileDescriptorStore=*/ null,
                 ALWAYS_OPTIMIZE);
         assertThrows(AppSearchException.class, () -> appSearchImpl2.getDocument("package",
                 "database",
@@ -3062,6 +3240,7 @@
                 ),
                 /*initStatsBuilder=*/ null,
                 /*visibilityChecker=*/ null,
+                /*revocableFileDescriptorStore=*/ null,
                 ALWAYS_OPTIMIZE);
         assertThrows(AppSearchException.class, () -> appSearchImpl2.getDocument("package",
                 "database",
@@ -3190,8 +3369,14 @@
                     public int getMaxSuggestionCount() {
                         return Integer.MAX_VALUE;
                     }
+
+                    @Override
+                    public int getMaxOpenBlobCount() {
+                        return Integer.MAX_VALUE;
+                    }
                 }, new LocalStorageIcingOptionsConfig()),
                 /*initStatsBuilder=*/ null, /*visibilityChecker=*/ null,
+                /*revocableFileDescriptorStore=*/ null,
                 ALWAYS_OPTIMIZE);
 
         // Insert schema
@@ -3274,8 +3459,14 @@
                     public int getMaxSuggestionCount() {
                         return Integer.MAX_VALUE;
                     }
+
+                    @Override
+                    public int getMaxOpenBlobCount() {
+                        return Integer.MAX_VALUE;
+                    }
                 }, new LocalStorageIcingOptionsConfig()),
                 /*initStatsBuilder=*/ null, /*visibilityChecker=*/ null,
+                /*revocableFileDescriptorStore=*/ null,
                 ALWAYS_OPTIMIZE);
 
         // Insert schema
@@ -3336,8 +3527,14 @@
                     public int getMaxSuggestionCount() {
                         return Integer.MAX_VALUE;
                     }
+
+                    @Override
+                    public int getMaxOpenBlobCount() {
+                        return Integer.MAX_VALUE;
+                    }
                 }, new LocalStorageIcingOptionsConfig()),
                 /*initStatsBuilder=*/ null, /*visibilityChecker=*/ null,
+                /*revocableFileDescriptorStore=*/ null,
                 ALWAYS_OPTIMIZE);
 
         // Make sure the limit is maintained
@@ -3378,8 +3575,14 @@
                     public int getMaxSuggestionCount() {
                         return Integer.MAX_VALUE;
                     }
+
+                    @Override
+                    public int getMaxOpenBlobCount() {
+                        return Integer.MAX_VALUE;
+                    }
                 }, new LocalStorageIcingOptionsConfig()),
                 /*initStatsBuilder=*/ null, /*visibilityChecker=*/ null,
+                /*revocableFileDescriptorStore=*/ null,
                 ALWAYS_OPTIMIZE);
 
         // Insert schema
@@ -3496,8 +3699,14 @@
                     public int getMaxSuggestionCount() {
                         return Integer.MAX_VALUE;
                     }
+
+                    @Override
+                    public int getMaxOpenBlobCount() {
+                        return Integer.MAX_VALUE;
+                    }
                 }, new LocalStorageIcingOptionsConfig()),
                 /*initStatsBuilder=*/ null, /*visibilityChecker=*/ null,
+                /*revocableFileDescriptorStore=*/ null,
                 ALWAYS_OPTIMIZE);
 
         // Insert schema
@@ -3597,8 +3806,14 @@
                     public int getMaxSuggestionCount() {
                         return Integer.MAX_VALUE;
                     }
+
+                    @Override
+                    public int getMaxOpenBlobCount() {
+                        return Integer.MAX_VALUE;
+                    }
                 }, new LocalStorageIcingOptionsConfig()),
                 /*initStatsBuilder=*/ null, /*visibilityChecker=*/ null,
+                /*revocableFileDescriptorStore=*/ null,
                 ALWAYS_OPTIMIZE);
 
         // package1 should still be out of space
@@ -3659,8 +3874,14 @@
                     public int getMaxSuggestionCount() {
                         return Integer.MAX_VALUE;
                     }
+
+                    @Override
+                    public int getMaxOpenBlobCount() {
+                        return Integer.MAX_VALUE;
+                    }
                 }, new LocalStorageIcingOptionsConfig()),
                 /*initStatsBuilder=*/ null, /*visibilityChecker=*/ null,
+                /*revocableFileDescriptorStore=*/ null,
                 ALWAYS_OPTIMIZE);
 
         // Insert schema
@@ -3817,8 +4038,14 @@
                     public int getMaxSuggestionCount() {
                         return Integer.MAX_VALUE;
                     }
+
+                    @Override
+                    public int getMaxOpenBlobCount() {
+                        return Integer.MAX_VALUE;
+                    }
                 }, new LocalStorageIcingOptionsConfig()),
                 /*initStatsBuilder=*/ null, /*visibilityChecker=*/ null,
+                /*revocableFileDescriptorStore=*/ null,
                 ALWAYS_OPTIMIZE);
 
         // Insert schema
@@ -3905,8 +4132,14 @@
                     public int getMaxSuggestionCount() {
                         return Integer.MAX_VALUE;
                     }
+
+                    @Override
+                    public int getMaxOpenBlobCount() {
+                        return Integer.MAX_VALUE;
+                    }
                 }, new LocalStorageIcingOptionsConfig()),
                 /*initStatsBuilder=*/ null, /*visibilityChecker=*/ null,
+                /*revocableFileDescriptorStore=*/ null,
                 ALWAYS_OPTIMIZE);
 
         // Insert schema
@@ -3967,8 +4200,14 @@
                     public int getMaxSuggestionCount() {
                         return Integer.MAX_VALUE;
                     }
+
+                    @Override
+                    public int getMaxOpenBlobCount() {
+                        return Integer.MAX_VALUE;
+                    }
                 }, new LocalStorageIcingOptionsConfig()),
                 /*initStatsBuilder=*/ null, /*visibilityChecker=*/ null,
+                /*revocableFileDescriptorStore=*/ null,
                 ALWAYS_OPTIMIZE);
 
         // Index id2. This should pass but only because we check for replacements.
@@ -4019,8 +4258,14 @@
                     public int getMaxSuggestionCount() {
                         return 2;
                     }
+
+                    @Override
+                    public int getMaxOpenBlobCount() {
+                        return Integer.MAX_VALUE;
+                    }
                 }, new LocalStorageIcingOptionsConfig()),
                 /*initStatsBuilder=*/ null, /*visibilityChecker=*/ null,
+                /*revocableFileDescriptorStore=*/ null,
                 ALWAYS_OPTIMIZE);
 
         AppSearchException e = assertThrows(AppSearchException.class, () ->
@@ -4060,8 +4305,14 @@
                     public int getMaxSuggestionCount() {
                         return Integer.MAX_VALUE;
                     }
+
+                    @Override
+                    public int getMaxOpenBlobCount() {
+                        return Integer.MAX_VALUE;
+                    }
                 }, new LocalStorageIcingOptionsConfig()),
                 /*initStatsBuilder=*/ null, /*visibilityChecker=*/ null,
+                /*revocableFileDescriptorStore=*/ null,
                 ALWAYS_OPTIMIZE);
 
         // Insert schema
@@ -4120,8 +4371,14 @@
                     public int getMaxSuggestionCount() {
                         return Integer.MAX_VALUE;
                     }
+
+                    @Override
+                    public int getMaxOpenBlobCount() {
+                        return Integer.MAX_VALUE;
+                    }
                 }, new LocalStorageIcingOptionsConfig()),
                 /*initStatsBuilder=*/ null, /*visibilityChecker=*/ null,
+                /*revocableFileDescriptorStore=*/ null,
                 ALWAYS_OPTIMIZE);
 
         // Insert schemas for thress packages
@@ -4236,8 +4493,14 @@
                     public int getMaxSuggestionCount() {
                         return Integer.MAX_VALUE;
                     }
+
+                    @Override
+                    public int getMaxOpenBlobCount() {
+                        return Integer.MAX_VALUE;
+                    }
                 }, new LocalStorageIcingOptionsConfig()),
                 /*initStatsBuilder=*/ null, /*visibilityChecker=*/ null,
+                /*revocableFileDescriptorStore=*/ null,
                 ALWAYS_OPTIMIZE);
 
         // Insert schema
@@ -4307,8 +4570,14 @@
                     public int getMaxSuggestionCount() {
                         return Integer.MAX_VALUE;
                     }
+
+                    @Override
+                    public int getMaxOpenBlobCount() {
+                        return Integer.MAX_VALUE;
+                    }
                 }, new LocalStorageIcingOptionsConfig()),
                 /*initStatsBuilder=*/ null, /*visibilityChecker=*/ null,
+                /*revocableFileDescriptorStore=*/ null,
                 ALWAYS_OPTIMIZE);
 
         // Insert schema
@@ -4424,8 +4693,14 @@
                     public int getMaxSuggestionCount() {
                         return Integer.MAX_VALUE;
                     }
+
+                    @Override
+                    public int getMaxOpenBlobCount() {
+                        return Integer.MAX_VALUE;
+                    }
                 }, new LocalStorageIcingOptionsConfig()),
                 /*initStatsBuilder=*/ null, /*visibilityChecker=*/ null,
+                /*revocableFileDescriptorStore=*/ null,
                 ALWAYS_OPTIMIZE);
 
         // Insert schema
@@ -4530,6 +4805,68 @@
                 /*logger=*/ null);
     }
 
+    @Test
+    public void testLimitConfig_activeFds() throws Exception {
+        mAppSearchImpl.close();
+        File tempFolder = mTemporaryFolder.newFolder();
+        AppSearchConfig config = new AppSearchConfigImpl(new LimitConfig() {
+            @Override
+            public int getMaxDocumentSizeBytes() {
+                return Integer.MAX_VALUE;
+            }
+
+            @Override
+            public int getPerPackageDocumentCountLimit() {
+                return Integer.MAX_VALUE;
+            }
+
+            @Override
+            public int getDocumentCountLimitStartThreshold() {
+                return Integer.MAX_VALUE;
+            }
+
+            @Override
+            public int getMaxSuggestionCount() {
+                return Integer.MAX_VALUE;
+            }
+
+            @Override
+            public int getMaxOpenBlobCount() {
+                return 2;
+            }
+        }, new LocalStorageIcingOptionsConfig());
+        mAppSearchImpl = AppSearchImpl.create(
+                tempFolder,
+                config,
+                /*initStatsBuilder=*/ null, /*visibilityChecker=*/ null,
+                new JetpackRevocableFileDescriptorStore(config),
+                ALWAYS_OPTIMIZE);
+        // We could open only 2 fds per package.
+        byte[] data1 = generateRandomBytes(20 * 1024); // 20 KiB
+        byte[] digest1 = calculateDigest(data1);
+        AppSearchBlobHandle handle1 = AppSearchBlobHandle.createWithSha256(
+                digest1, "package", "db1", "ns");
+        mAppSearchImpl.openWriteBlob("package", "db1", handle1);
+
+        byte[] data2 = generateRandomBytes(20 * 1024); // 20 KiB
+        byte[] digest2 = calculateDigest(data2);
+        AppSearchBlobHandle handle2 = AppSearchBlobHandle.createWithSha256(
+                digest2, "package", "db1", "ns");
+        mAppSearchImpl.openWriteBlob("package", "db1", handle2);
+
+        // Open 3rd fd will fail.
+        byte[] data3 = generateRandomBytes(20 * 1024); // 20 KiB
+        byte[] digest3 = calculateDigest(data3);
+        AppSearchBlobHandle handle3 = AppSearchBlobHandle.createWithSha256(
+                digest3, "package", "db1", "ns");
+        AppSearchException e = assertThrows(AppSearchException.class,
+                () -> mAppSearchImpl.openWriteBlob("package", "db1", handle3));
+        assertThat(e.getResultCode()).isEqualTo(RESULT_OUT_OF_SPACE);
+        assertThat(e).hasMessageThat().contains(
+                "Package \"package\" exceeded limit of 2 opened file descriptors. "
+                        + "Some file descriptors must be closed to open additional ones.");
+    }
+
     /**
      * Ensure that it is okay to register the same observer for multiple packages and that removing
      * the observer for one package doesn't remove it for the other.
@@ -4620,8 +4957,8 @@
                         new LocalStorageIcingOptionsConfig()
                 ),
                 /*initStatsBuilder=*/ null,
-                mockVisibilityChecker, ALWAYS_OPTIMIZE
-        );
+                mockVisibilityChecker, /*revocableFileDescriptorStore=*/ null,
+                ALWAYS_OPTIMIZE);
 
         InternalSetSchemaResponse internalSetSchemaResponse = mAppSearchImpl.setSchema(
                 "package",
@@ -4672,8 +5009,8 @@
                         new LocalStorageIcingOptionsConfig()
                 ),
                 /*initStatsBuilder=*/ null,
-                mockVisibilityChecker, ALWAYS_OPTIMIZE
-        );
+                mockVisibilityChecker, /*revocableFileDescriptorStore=*/ null,
+                ALWAYS_OPTIMIZE);
 
         InternalSetSchemaResponse internalSetSchemaResponse = mAppSearchImpl.setSchema(
                 "package",
@@ -4722,8 +5059,8 @@
                         new LocalStorageIcingOptionsConfig()
                 ),
                 /*initStatsBuilder=*/ null,
-                mockVisibilityChecker, ALWAYS_OPTIMIZE
-        );
+                mockVisibilityChecker, /*revocableFileDescriptorStore=*/ null,
+                ALWAYS_OPTIMIZE);
 
         InternalSetSchemaResponse internalSetSchemaResponse = mAppSearchImpl.setSchema(
                 "package",
@@ -4786,8 +5123,8 @@
                         new LocalStorageIcingOptionsConfig()
                 ),
                 /*initStatsBuilder=*/ null,
-                mockVisibilityChecker, ALWAYS_OPTIMIZE
-        );
+                mockVisibilityChecker, /*revocableFileDescriptorStore=*/ null,
+                ALWAYS_OPTIMIZE);
 
         InternalSetSchemaResponse internalSetSchemaResponse = mAppSearchImpl.setSchema(
                 "package",
@@ -5149,6 +5486,7 @@
                 ),
                 /*initStatsBuilder=*/ null,
                 /*visibilityChecker=*/ null,
+                /*revocableFileDescriptorStore=*/ null,
                 ALWAYS_OPTIMIZE);
 
         String prefix = PrefixUtil.createPrefix("packageName", "databaseName");
@@ -5194,6 +5532,7 @@
                 ),
                 /*initStatsBuilder=*/ null,
                 /*visibilityChecker=*/ null,
+                /*revocableFileDescriptorStore=*/ null,
                 ALWAYS_OPTIMIZE);
 
         assertThat(mAppSearchImpl.mVisibilityStoreLocked.getVisibility(prefix + "Email")).isNull();
@@ -5225,8 +5564,8 @@
                         new LocalStorageIcingOptionsConfig()
                 ),
                 /*initStatsBuilder=*/ null,
-                mockVisibilityChecker, ALWAYS_OPTIMIZE
-        );
+                mockVisibilityChecker, /*revocableFileDescriptorStore=*/ null,
+                ALWAYS_OPTIMIZE);
 
         // Add a schema type that is not displayed by the system
         InternalSetSchemaResponse internalSetSchemaResponse = mAppSearchImpl.setSchema(
@@ -5334,8 +5673,8 @@
                         new LocalStorageIcingOptionsConfig()
                 ),
                 /*initStatsBuilder=*/ null,
-                mockVisibilityChecker, ALWAYS_OPTIMIZE
-        );
+                mockVisibilityChecker, /*revocableFileDescriptorStore=*/ null,
+                ALWAYS_OPTIMIZE);
 
         // Add two schema types that are not displayed by the system.
         InternalSetSchemaResponse internalSetSchemaResponse = mAppSearchImpl.setSchema(
@@ -5409,8 +5748,8 @@
                         new LocalStorageIcingOptionsConfig()
                 ),
                 /*initStatsBuilder=*/ null,
-                publicAclMockChecker, ALWAYS_OPTIMIZE
-        );
+                publicAclMockChecker, /*revocableFileDescriptorStore=*/ null,
+                ALWAYS_OPTIMIZE);
 
         List<InternalVisibilityConfig> visibilityConfigs = ImmutableList.of(
                 new InternalVisibilityConfig.Builder("PublicTypeA")
@@ -5504,8 +5843,8 @@
                         new LocalStorageIcingOptionsConfig()
                 ),
                 /*initStatsBuilder=*/ null,
-                publicAclMockChecker, ALWAYS_OPTIMIZE
-        );
+                publicAclMockChecker, /*revocableFileDescriptorStore=*/ null,
+                ALWAYS_OPTIMIZE);
 
         List<InternalVisibilityConfig> visibilityConfigs = ImmutableList.of(
                 new InternalVisibilityConfig.Builder("PublicTypeA")
@@ -5667,8 +6006,8 @@
                         new LocalStorageIcingOptionsConfig()
                 ),
                 /*initStatsBuilder=*/null,
-                rejectChecker, ALWAYS_OPTIMIZE
-        );
+                rejectChecker, /*revocableFileDescriptorStore=*/ null,
+                ALWAYS_OPTIMIZE);
 
         // Add a schema type
         InternalSetSchemaResponse internalSetSchemaResponse = mAppSearchImpl.setSchema(
@@ -5781,8 +6120,8 @@
                         new LocalStorageIcingOptionsConfig()
                 ),
                 /*initStatsBuilder=*/null,
-                visibilityChecker, ALWAYS_OPTIMIZE
-        );
+                visibilityChecker, /*revocableFileDescriptorStore=*/ null,
+                ALWAYS_OPTIMIZE);
 
         // Add a schema type
         InternalSetSchemaResponse internalSetSchemaResponse = mAppSearchImpl.setSchema(
@@ -5842,8 +6181,8 @@
                         new LocalStorageIcingOptionsConfig()
                 ),
                 /*initStatsBuilder=*/null,
-                rejectChecker, ALWAYS_OPTIMIZE
-        );
+                rejectChecker, /*revocableFileDescriptorStore=*/ null,
+                ALWAYS_OPTIMIZE);
 
         // Add a schema type
         InternalSetSchemaResponse internalSetSchemaResponse = mAppSearchImpl.setSchema(
@@ -6184,8 +6523,8 @@
                         new LocalStorageIcingOptionsConfig()
                 ),
                 /*initStatsBuilder=*/null,
-                visibilityChecker, ALWAYS_OPTIMIZE
-        );
+                visibilityChecker, /*revocableFileDescriptorStore=*/ null,
+                ALWAYS_OPTIMIZE);
 
         // Register an observer
         TestObserverCallback observer = new TestObserverCallback();
@@ -6349,8 +6688,8 @@
                         new LocalStorageIcingOptionsConfig()
                 ),
                 /*initStatsBuilder=*/null,
-                visibilityChecker, ALWAYS_OPTIMIZE
-        );
+                visibilityChecker, /*revocableFileDescriptorStore=*/ null,
+                ALWAYS_OPTIMIZE);
 
         // Add a schema.
         InternalSetSchemaResponse internalSetSchemaResponse = mAppSearchImpl.setSchema(
@@ -6448,8 +6787,8 @@
                         new LocalStorageIcingOptionsConfig()
                 ),
                 /*initStatsBuilder=*/null,
-                visibilityChecker, ALWAYS_OPTIMIZE
-        );
+                visibilityChecker, /*revocableFileDescriptorStore=*/ null,
+                ALWAYS_OPTIMIZE);
 
         // Add a schema.
         InternalSetSchemaResponse internalSetSchemaResponse = mAppSearchImpl.setSchema(
@@ -6549,8 +6888,8 @@
                         new LocalStorageIcingOptionsConfig()
                 ),
                 /*initStatsBuilder=*/null,
-                visibilityChecker, ALWAYS_OPTIMIZE
-        );
+                visibilityChecker, /*revocableFileDescriptorStore=*/ null,
+                ALWAYS_OPTIMIZE);
 
         // Add a schema.
         InternalSetSchemaResponse internalSetSchemaResponse = mAppSearchImpl.setSchema(
diff --git a/appsearch/appsearch-local-storage/src/androidTest/java/androidx/appsearch/localstorage/AppSearchLoggerTest.java b/appsearch/appsearch-local-storage/src/androidTest/java/androidx/appsearch/localstorage/AppSearchLoggerTest.java
index aa4c96a..4fe47c6 100644
--- a/appsearch/appsearch-local-storage/src/androidTest/java/androidx/appsearch/localstorage/AppSearchLoggerTest.java
+++ b/appsearch/appsearch-local-storage/src/androidTest/java/androidx/appsearch/localstorage/AppSearchLoggerTest.java
@@ -70,17 +70,19 @@
     public TemporaryFolder mTemporaryFolder = new TemporaryFolder();
     private AppSearchImpl mAppSearchImpl;
     private SimpleTestLogger mLogger;
+    private AppSearchConfig mConfig = new AppSearchConfigImpl(
+            new UnlimitedLimitConfig(),
+            new LocalStorageIcingOptionsConfig()
+    );
 
     @Before
     public void setUp() throws Exception {
         mAppSearchImpl = AppSearchImpl.create(
                 mTemporaryFolder.newFolder(),
-                new AppSearchConfigImpl(
-                        new UnlimitedLimitConfig(),
-                        new LocalStorageIcingOptionsConfig()
-                ),
+                mConfig,
                 /*initStatsBuilder=*/ null,
                 /*visibilityChecker=*/ null,
+                /*revocableFileDescriptorStore=*/ null,
                 ALWAYS_OPTIMIZE);
         mLogger = new SimpleTestLogger();
     }
@@ -368,12 +370,10 @@
         InitializeStats.Builder initStatsBuilder = new InitializeStats.Builder();
         AppSearchImpl appSearchImpl = AppSearchImpl.create(
                 mTemporaryFolder.newFolder(),
-                new AppSearchConfigImpl(
-                        new UnlimitedLimitConfig(),
-                        new LocalStorageIcingOptionsConfig()
-                ),
+                mConfig,
                 initStatsBuilder,
                 /*visibilityChecker=*/ null,
+                /*revocableFileDescriptorStore=*/ null,
                 ALWAYS_OPTIMIZE);
         InitializeStats iStats = initStatsBuilder.build();
         appSearchImpl.close();
@@ -401,12 +401,10 @@
 
         AppSearchImpl appSearchImpl = AppSearchImpl.create(
                 folder,
-                new AppSearchConfigImpl(
-                        new UnlimitedLimitConfig(),
-                        new LocalStorageIcingOptionsConfig()
-                ),
+                mConfig,
                 /*initStatsBuilder=*/ null,
                 /*visibilityChecker=*/ null,
+                /*revocableFileDescriptorStore=*/ null,
                 ALWAYS_OPTIMIZE);
         List<AppSearchSchema> schemas = ImmutableList.of(
                 new AppSearchSchema.Builder("Type1").build(),
@@ -440,10 +438,10 @@
 
         // Create another appsearchImpl on the same folder
         InitializeStats.Builder initStatsBuilder = new InitializeStats.Builder();
-        appSearchImpl = AppSearchImpl.create(
-                folder, new AppSearchConfigImpl(new UnlimitedLimitConfig(),
-                        new LocalStorageIcingOptionsConfig()),
-                initStatsBuilder, /*visibilityChecker=*/ null, ALWAYS_OPTIMIZE);
+        appSearchImpl = AppSearchImpl.create(folder, mConfig,
+                initStatsBuilder, /*visibilityChecker=*/ null,
+                /*revocableFileDescriptorStore=*/ null,
+                ALWAYS_OPTIMIZE);
         InitializeStats iStats = initStatsBuilder.build();
 
         assertThat(iStats).isNotNull();
@@ -469,10 +467,10 @@
         final String testDatabase = "testDatabase";
         final File folder = mTemporaryFolder.newFolder();
 
-        AppSearchImpl appSearchImpl = AppSearchImpl.create(
-                folder, new AppSearchConfigImpl(new UnlimitedLimitConfig(),
-                        new LocalStorageIcingOptionsConfig()),
-                /*initStatsBuilder=*/ null, /*visibilityChecker=*/ null, ALWAYS_OPTIMIZE);
+        AppSearchImpl appSearchImpl = AppSearchImpl.create(folder, mConfig,
+                /*initStatsBuilder=*/ null, /*visibilityChecker=*/ null,
+                /*revocableFileDescriptorStore=*/ null,
+                ALWAYS_OPTIMIZE);
 
         List<AppSearchSchema> schemas = ImmutableList.of(
                 new AppSearchSchema.Builder("Type1").build(),
@@ -509,10 +507,10 @@
 
         // Create another appsearchImpl on the same folder
         InitializeStats.Builder initStatsBuilder = new InitializeStats.Builder();
-        appSearchImpl = AppSearchImpl.create(
-                folder, new AppSearchConfigImpl(new UnlimitedLimitConfig(),
-                        new LocalStorageIcingOptionsConfig()),
-                initStatsBuilder, /*visibilityChecker=*/ null, ALWAYS_OPTIMIZE);
+        appSearchImpl = AppSearchImpl.create(folder, mConfig,
+                initStatsBuilder, /*visibilityChecker=*/ null,
+                /*revocableFileDescriptorStore=*/ null,
+                ALWAYS_OPTIMIZE);
         InitializeStats iStats = initStatsBuilder.build();
 
         // Some of other fields are already covered by AppSearchImplTest#testReset()
diff --git a/appsearch/appsearch-local-storage/src/androidTest/java/androidx/appsearch/localstorage/SearchResultsImplTest.java b/appsearch/appsearch-local-storage/src/androidTest/java/androidx/appsearch/localstorage/SearchResultsImplTest.java
index f189baf..a83134e6 100644
--- a/appsearch/appsearch-local-storage/src/androidTest/java/androidx/appsearch/localstorage/SearchResultsImplTest.java
+++ b/appsearch/appsearch-local-storage/src/androidTest/java/androidx/appsearch/localstorage/SearchResultsImplTest.java
@@ -49,13 +49,16 @@
 
     @Before
     public void setUp() throws Exception {
+        AppSearchConfig config = new AppSearchConfigImpl(
+                new UnlimitedLimitConfig(),
+                new LocalStorageIcingOptionsConfig()
+        );
         mAppSearchImpl = AppSearchImpl.create(
                 mTemporaryFolder.newFolder(),
-                new AppSearchConfigImpl(
-                        new UnlimitedLimitConfig(),
-                        new LocalStorageIcingOptionsConfig()
-                ),
-                /*initStatsBuilder=*/ null, /*visibilityChecker=*/ null,
+                config,
+                /*initStatsBuilder=*/ null,
+                /*visibilityChecker=*/ null,
+                /*revocableFileDescriptorStore=*/ null,
                 ALWAYS_OPTIMIZE);
     }
 
diff --git a/appsearch/appsearch-local-storage/src/androidTest/java/androidx/appsearch/localstorage/converter/BlobHandleToProtoConverterTest.java b/appsearch/appsearch-local-storage/src/androidTest/java/androidx/appsearch/localstorage/converter/BlobHandleToProtoConverterTest.java
new file mode 100644
index 0000000..65f252b
--- /dev/null
+++ b/appsearch/appsearch-local-storage/src/androidTest/java/androidx/appsearch/localstorage/converter/BlobHandleToProtoConverterTest.java
@@ -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.appsearch.localstorage.converter;
+
+import static androidx.appsearch.testutil.AppSearchTestUtils.calculateDigest;
+import static androidx.appsearch.testutil.AppSearchTestUtils.generateRandomBytes;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import androidx.appsearch.app.AppSearchBlobHandle;
+import androidx.appsearch.localstorage.util.PrefixUtil;
+
+import com.google.android.icing.proto.PropertyProto;
+import com.google.android.icing.protobuf.ByteString;
+
+import org.junit.Test;
+
+public class BlobHandleToProtoConverterTest {
+
+    @Test
+    public void testToBlobHandleProto() throws Exception {
+        byte[] data = generateRandomBytes(20 * 1024); // 20 KiB
+        byte[] digest = calculateDigest(data);
+        AppSearchBlobHandle handle = AppSearchBlobHandle.createWithSha256(
+                digest, "package", "db1", "ns");
+
+        PropertyProto.BlobHandleProto proto = BlobHandleToProtoConverter.toBlobHandleProto(handle);
+
+        assertThat(proto.getDigest().toByteArray()).isEqualTo(digest);
+        assertThat(proto.getNamespace()).isEqualTo(
+                PrefixUtil.createPrefix("package", "db1") + "ns");
+    }
+
+    @Test
+    public void testToBlobHandle() throws Exception {
+        byte[] data = generateRandomBytes(20 * 1024); // 20 KiB
+        byte[] digest = calculateDigest(data);
+
+        PropertyProto.BlobHandleProto proto = PropertyProto.BlobHandleProto.newBuilder()
+                .setNamespace(PrefixUtil.createPrefix("package", "db1") + "ns")
+                .setDigest(ByteString.copyFrom(digest))
+                .build();
+        AppSearchBlobHandle handle = BlobHandleToProtoConverter.toAppSearchBlobHandle(proto);
+
+        assertThat(handle.getPackageName()).isEqualTo("package");
+        assertThat(handle.getDatabaseName()).isEqualTo("db1");
+        assertThat(handle.getNamespace()).isEqualTo("ns");
+        assertThat(handle.getSha256Digest()).isEqualTo(digest);
+    }
+}
diff --git a/appsearch/appsearch-local-storage/src/androidTest/java/androidx/appsearch/localstorage/converter/SearchSpecToProtoConverterTest.java b/appsearch/appsearch-local-storage/src/androidTest/java/androidx/appsearch/localstorage/converter/SearchSpecToProtoConverterTest.java
index dbb8f58..11d0a21 100644
--- a/appsearch/appsearch-local-storage/src/androidTest/java/androidx/appsearch/localstorage/converter/SearchSpecToProtoConverterTest.java
+++ b/appsearch/appsearch-local-storage/src/androidTest/java/androidx/appsearch/localstorage/converter/SearchSpecToProtoConverterTest.java
@@ -25,6 +25,7 @@
 
 import androidx.appsearch.app.JoinSpec;
 import androidx.appsearch.app.SearchSpec;
+import androidx.appsearch.localstorage.AppSearchConfig;
 import androidx.appsearch.localstorage.AppSearchConfigImpl;
 import androidx.appsearch.localstorage.AppSearchImpl;
 import androidx.appsearch.localstorage.IcingOptionsConfig;
@@ -72,14 +73,16 @@
 
     @Before
     public void setUp() throws Exception {
+        AppSearchConfig config = new AppSearchConfigImpl(
+                new UnlimitedLimitConfig(),
+                mLocalStorageIcingOptionsConfig
+        );
         mAppSearchImpl = AppSearchImpl.create(
                 mTemporaryFolder.newFolder(),
-                new AppSearchConfigImpl(
-                        new UnlimitedLimitConfig(),
-                        mLocalStorageIcingOptionsConfig
-                ),
+                config,
                 /*initStatsBuilder=*/ null,
                 /*visibilityChecker=*/ null,
+                /*revocableFileDescriptorStore=*/ null,
                 ALWAYS_OPTIMIZE);
     }
 
diff --git a/appsearch/appsearch-local-storage/src/androidTest/java/androidx/appsearch/localstorage/visibilitystore/VisibilityStoreMigrationFromV2Test.java b/appsearch/appsearch-local-storage/src/androidTest/java/androidx/appsearch/localstorage/visibilitystore/VisibilityStoreMigrationFromV2Test.java
index e50274b..a7d2fd9 100644
--- a/appsearch/appsearch-local-storage/src/androidTest/java/androidx/appsearch/localstorage/visibilitystore/VisibilityStoreMigrationFromV2Test.java
+++ b/appsearch/appsearch-local-storage/src/androidTest/java/androidx/appsearch/localstorage/visibilitystore/VisibilityStoreMigrationFromV2Test.java
@@ -30,6 +30,7 @@
 import androidx.appsearch.app.SetSchemaRequest;
 import androidx.appsearch.app.VisibilityPermissionConfig;
 import androidx.appsearch.exceptions.AppSearchException;
+import androidx.appsearch.localstorage.AppSearchConfig;
 import androidx.appsearch.localstorage.AppSearchConfigImpl;
 import androidx.appsearch.localstorage.AppSearchImpl;
 import androidx.appsearch.localstorage.LocalStorageIcingOptionsConfig;
@@ -60,6 +61,8 @@
     @Rule
     public TemporaryFolder mTemporaryFolder = new TemporaryFolder();
     private File mFile;
+    private AppSearchConfig mConfig = new AppSearchConfigImpl(new UnlimitedLimitConfig(),
+            new LocalStorageIcingOptionsConfig());
 
     @Before
     public void setUp() throws Exception {
@@ -86,9 +89,10 @@
 
         // Create AppSearchImpl with visibility document version 2;
         AppSearchImpl appSearchImplInV2 = AppSearchImpl.create(mFile,
-                new AppSearchConfigImpl(new UnlimitedLimitConfig(),
-                        new LocalStorageIcingOptionsConfig()), /*initStatsBuilder=*/ null,
+                mConfig,
+                /*initStatsBuilder=*/ null,
                 /*visibilityChecker=*/ null,
+                /*revocableFileDescriptorStore=*/ null,
                 ALWAYS_OPTIMIZE);
 
         // Erase overlay schemas since it doesn't exist in released V2 schema.
@@ -160,9 +164,10 @@
         // Persist to disk and re-open the AppSearchImpl
         appSearchImplInV2.close();
         AppSearchImpl appSearchImpl = AppSearchImpl.create(mFile,
-                new AppSearchConfigImpl(new UnlimitedLimitConfig(),
-                        new LocalStorageIcingOptionsConfig()), /*initStatsBuilder=*/ null,
+                mConfig,
+                /*initStatsBuilder=*/ null,
                 /*visibilityChecker=*/ null,
+                /*revocableFileDescriptorStore=*/ null,
                 ALWAYS_OPTIMIZE);
 
         InternalVisibilityConfig actualConfig =
diff --git a/appsearch/appsearch-local-storage/src/androidTest/java/androidx/appsearch/localstorage/visibilitystore/VisibilityStoreMigrationHelperFromV0Test.java b/appsearch/appsearch-local-storage/src/androidTest/java/androidx/appsearch/localstorage/visibilitystore/VisibilityStoreMigrationHelperFromV0Test.java
index c91f51c..eedd6a6 100644
--- a/appsearch/appsearch-local-storage/src/androidTest/java/androidx/appsearch/localstorage/visibilitystore/VisibilityStoreMigrationHelperFromV0Test.java
+++ b/appsearch/appsearch-local-storage/src/androidTest/java/androidx/appsearch/localstorage/visibilitystore/VisibilityStoreMigrationHelperFromV0Test.java
@@ -31,6 +31,7 @@
 import androidx.appsearch.app.InternalSetSchemaResponse;
 import androidx.appsearch.app.InternalVisibilityConfig;
 import androidx.appsearch.app.PackageIdentifier;
+import androidx.appsearch.localstorage.AppSearchConfig;
 import androidx.appsearch.localstorage.AppSearchConfigImpl;
 import androidx.appsearch.localstorage.AppSearchImpl;
 import androidx.appsearch.localstorage.LocalStorageIcingOptionsConfig;
@@ -58,6 +59,8 @@
     @Rule
     public TemporaryFolder mTemporaryFolder = new TemporaryFolder();
     private File mFile;
+    private AppSearchConfig mConfig = new AppSearchConfigImpl(new UnlimitedLimitConfig(),
+            new LocalStorageIcingOptionsConfig());
 
     @Before
     public void setUp() throws Exception {
@@ -131,9 +134,10 @@
         // Persist to disk and re-open the AppSearchImpl
         appSearchImplInV0.close();
         AppSearchImpl appSearchImpl = AppSearchImpl.create(mFile,
-                new AppSearchConfigImpl(new UnlimitedLimitConfig(),
-                        new LocalStorageIcingOptionsConfig()), /*initStatsBuilder=*/ null,
+                mConfig,
+                /*initStatsBuilder=*/ null,
                 /*visibilityChecker=*/ null,
+                /*revocableFileDescriptorStore=*/ null,
                 ALWAYS_OPTIMIZE);
 
         GenericDocument actualDocument1 =
@@ -204,9 +208,10 @@
                 .build();
         // Set deprecated visibility schema version 0 into AppSearchImpl.
         AppSearchImpl appSearchImpl = AppSearchImpl.create(mFile,
-                new AppSearchConfigImpl(new UnlimitedLimitConfig(),
-                        new LocalStorageIcingOptionsConfig()), /*initStatsBuilder=*/ null,
+                mConfig,
+                /*initStatsBuilder=*/ null,
                 /*visibilityChecker=*/ null,
+                /*revocableFileDescriptorStore=*/ null,
                 ALWAYS_OPTIMIZE);
         InternalSetSchemaResponse internalSetSchemaResponse = appSearchImpl.setSchema(
                 VisibilityStore.VISIBILITY_PACKAGE_NAME,
diff --git a/appsearch/appsearch-local-storage/src/androidTest/java/androidx/appsearch/localstorage/visibilitystore/VisibilityStoreMigrationHelperFromV1Test.java b/appsearch/appsearch-local-storage/src/androidTest/java/androidx/appsearch/localstorage/visibilitystore/VisibilityStoreMigrationHelperFromV1Test.java
index 4b837ae..0018ea3 100644
--- a/appsearch/appsearch-local-storage/src/androidTest/java/androidx/appsearch/localstorage/visibilitystore/VisibilityStoreMigrationHelperFromV1Test.java
+++ b/appsearch/appsearch-local-storage/src/androidTest/java/androidx/appsearch/localstorage/visibilitystore/VisibilityStoreMigrationHelperFromV1Test.java
@@ -26,6 +26,7 @@
 import androidx.appsearch.app.InternalVisibilityConfig;
 import androidx.appsearch.app.PackageIdentifier;
 import androidx.appsearch.app.SetSchemaRequest;
+import androidx.appsearch.localstorage.AppSearchConfig;
 import androidx.appsearch.localstorage.AppSearchConfigImpl;
 import androidx.appsearch.localstorage.AppSearchImpl;
 import androidx.appsearch.localstorage.LocalStorageIcingOptionsConfig;
@@ -54,6 +55,8 @@
     @Rule
     public TemporaryFolder mTemporaryFolder = new TemporaryFolder();
     private File mFile;
+    private AppSearchConfig mConfig = new AppSearchConfigImpl(new UnlimitedLimitConfig(),
+                    new LocalStorageIcingOptionsConfig());
 
     @Before
     public void setUp() throws Exception {
@@ -77,9 +80,10 @@
 
         // Create AppSearchImpl with visibility document version 1;
         AppSearchImpl appSearchImplInV1 = AppSearchImpl.create(mFile,
-                new AppSearchConfigImpl(new UnlimitedLimitConfig(),
-                        new LocalStorageIcingOptionsConfig()), /*initStatsBuilder=*/ null,
+                mConfig,
+                /*initStatsBuilder=*/ null,
                 /*visibilityChecker=*/ null,
+                /*revocableFileDescriptorStore=*/ null,
                 ALWAYS_OPTIMIZE);
         InternalSetSchemaResponse internalSetSchemaResponse = appSearchImplInV1.setSchema(
                 VisibilityStore.VISIBILITY_PACKAGE_NAME,
@@ -127,9 +131,10 @@
         // Persist to disk and re-open the AppSearchImpl
         appSearchImplInV1.close();
         AppSearchImpl appSearchImpl = AppSearchImpl.create(mFile,
-                new AppSearchConfigImpl(new UnlimitedLimitConfig(),
-                        new LocalStorageIcingOptionsConfig()), /*initStatsBuilder=*/ null,
+                mConfig,
+                /*initStatsBuilder=*/ null,
                 /*visibilityChecker=*/ null,
+                /*revocableFileDescriptorStore=*/ null,
                 ALWAYS_OPTIMIZE);
 
         InternalVisibilityConfig actualConfig =
diff --git a/appsearch/appsearch-local-storage/src/androidTest/java/androidx/appsearch/localstorage/visibilitystore/VisibilityStoreTest.java b/appsearch/appsearch-local-storage/src/androidTest/java/androidx/appsearch/localstorage/visibilitystore/VisibilityStoreTest.java
index 5ac5bda..7139dcc 100644
--- a/appsearch/appsearch-local-storage/src/androidTest/java/androidx/appsearch/localstorage/visibilitystore/VisibilityStoreTest.java
+++ b/appsearch/appsearch-local-storage/src/androidTest/java/androidx/appsearch/localstorage/visibilitystore/VisibilityStoreTest.java
@@ -29,6 +29,7 @@
 import androidx.appsearch.app.SchemaVisibilityConfig;
 import androidx.appsearch.app.VisibilityPermissionConfig;
 import androidx.appsearch.exceptions.AppSearchException;
+import androidx.appsearch.localstorage.AppSearchConfig;
 import androidx.appsearch.localstorage.AppSearchConfigImpl;
 import androidx.appsearch.localstorage.AppSearchImpl;
 import androidx.appsearch.localstorage.LocalStorageIcingOptionsConfig;
@@ -62,14 +63,16 @@
     @Before
     public void setUp() throws Exception {
         File appSearchDir = mTemporaryFolder.newFolder();
+        AppSearchConfig config = new AppSearchConfigImpl(
+                new UnlimitedLimitConfig(),
+                new LocalStorageIcingOptionsConfig()
+        );
         mAppSearchImpl = AppSearchImpl.create(
                 appSearchDir,
-                new AppSearchConfigImpl(
-                        new UnlimitedLimitConfig(),
-                        new LocalStorageIcingOptionsConfig()
-                ),
+                config,
                 /*initStatsBuilder=*/ null,
                 /*visibilityChecker=*/ null,
+                /*revocableFileDescriptorStore=*/ null,
                 ALWAYS_OPTIMIZE);
         mVisibilityStore = new VisibilityStore(mAppSearchImpl);
     }
diff --git a/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/AppSearchConfigImpl.java b/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/AppSearchConfigImpl.java
index 2537e6f..b8f5e22 100644
--- a/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/AppSearchConfigImpl.java
+++ b/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/AppSearchConfigImpl.java
@@ -124,6 +124,11 @@
     }
 
     @Override
+    public boolean getEnableBlobStore() {
+        return mIcingOptionsConfig.getEnableBlobStore();
+    }
+
+    @Override
     public int getMaxDocumentSizeBytes() {
         return mLimitConfig.getMaxDocumentSizeBytes();
     }
@@ -144,6 +149,11 @@
     }
 
     @Override
+    public int getMaxOpenBlobCount() {
+        return mLimitConfig.getMaxOpenBlobCount();
+    }
+
+    @Override
     public boolean shouldStoreParentInfoAsSyntheticProperty() {
         return mStoreParentInfoAsSyntheticProperty;
     }
@@ -152,4 +162,9 @@
     public boolean shouldRetrieveParentInfo() {
         return mShouldRetrieveParentInfo;
     }
+
+    @Override
+    public long getOrphanBlobTimeToLiveMs() {
+        return mIcingOptionsConfig.getOrphanBlobTimeToLiveMs();
+    }
 }
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 34b1d87..299f123 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
@@ -26,6 +26,7 @@
 import static androidx.appsearch.localstorage.util.PrefixUtil.getPrefix;
 import static androidx.appsearch.localstorage.util.PrefixUtil.removePrefixesFromDocument;
 
+import android.os.ParcelFileDescriptor;
 import android.os.SystemClock;
 import android.util.Log;
 
@@ -35,8 +36,11 @@
 import androidx.annotation.RestrictTo;
 import androidx.annotation.VisibleForTesting;
 import androidx.annotation.WorkerThread;
+import androidx.appsearch.app.AppSearchBlobHandle;
 import androidx.appsearch.app.AppSearchResult;
 import androidx.appsearch.app.AppSearchSchema;
+import androidx.appsearch.app.ExperimentalAppSearchApi;
+import androidx.appsearch.app.Features;
 import androidx.appsearch.app.GenericDocument;
 import androidx.appsearch.app.GetByDocumentIdRequest;
 import androidx.appsearch.app.GetSchemaResponse;
@@ -53,6 +57,7 @@
 import androidx.appsearch.app.StorageInfo;
 import androidx.appsearch.exceptions.AppSearchException;
 import androidx.appsearch.flags.Flags;
+import androidx.appsearch.localstorage.converter.BlobHandleToProtoConverter;
 import androidx.appsearch.localstorage.converter.GenericDocumentToProtoConverter;
 import androidx.appsearch.localstorage.converter.ResultCodeToProtoConverter;
 import androidx.appsearch.localstorage.converter.SchemaToProtoConverter;
@@ -81,6 +86,7 @@
 import androidx.core.util.Preconditions;
 
 import com.google.android.icing.IcingSearchEngine;
+import com.google.android.icing.proto.BlobProto;
 import com.google.android.icing.proto.DebugInfoProto;
 import com.google.android.icing.proto.DebugInfoResultProto;
 import com.google.android.icing.proto.DebugInfoVerbosity;
@@ -120,6 +126,7 @@
 
 import java.io.Closeable;
 import java.io.File;
+import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.HashMap;
@@ -240,6 +247,9 @@
     @GuardedBy("mReadWriteLock")
     private int mOptimizeIntervalCountLocked = 0;
 
+    @Nullable
+    private AppSearchRevocableFileDescriptorStore mRevocableFileDescriptorStore;
+
     /** Whether this instance has been closed, and therefore unusable. */
     @GuardedBy("mReadWriteLock")
     private boolean mClosedLocked = false;
@@ -266,10 +276,11 @@
             @NonNull AppSearchConfig config,
             @Nullable InitializeStats.Builder initStatsBuilder,
             @Nullable VisibilityChecker visibilityChecker,
+            @Nullable AppSearchRevocableFileDescriptorStore revocableFileDescriptorStore,
             @NonNull OptimizeStrategy optimizeStrategy)
             throws AppSearchException {
-        return new AppSearchImpl(icingDir, config, initStatsBuilder, optimizeStrategy,
-                visibilityChecker);
+        return new AppSearchImpl(icingDir, config, initStatsBuilder, visibilityChecker,
+                revocableFileDescriptorStore, optimizeStrategy);
     }
 
     /**
@@ -279,13 +290,15 @@
             @NonNull File icingDir,
             @NonNull AppSearchConfig config,
             @Nullable InitializeStats.Builder initStatsBuilder,
-            @NonNull OptimizeStrategy optimizeStrategy,
-            @Nullable VisibilityChecker visibilityChecker)
+            @Nullable VisibilityChecker visibilityChecker,
+            @Nullable AppSearchRevocableFileDescriptorStore revocableFileDescriptorStore,
+            @NonNull OptimizeStrategy optimizeStrategy)
             throws AppSearchException {
         Preconditions.checkNotNull(icingDir);
         mConfig = Preconditions.checkNotNull(config);
         mOptimizeStrategy = Preconditions.checkNotNull(optimizeStrategy);
         mVisibilityCheckerLocked = visibilityChecker;
+        mRevocableFileDescriptorStore = revocableFileDescriptorStore;
 
         mReadWriteLock.writeLock().lock();
         try {
@@ -311,6 +324,8 @@
                     .setUseNewQualifiedIdJoinIndex(mConfig.getUseNewQualifiedIdJoinIndex())
                     .setBuildPropertyExistenceMetadataHits(
                             mConfig.getBuildPropertyExistenceMetadataHits())
+                    .setEnableBlobStore(mConfig.getEnableBlobStore())
+                    .setOrphanBlobTimeToLiveMs(mConfig.getOrphanBlobTimeToLiveMs())
                     .build();
             LogUtil.piiTrace(TAG, "Constructing IcingSearchEngine, request", options);
             mIcingSearchEngineLocked = new IcingSearchEngine(options);
@@ -451,8 +466,11 @@
             LogUtil.piiTrace(TAG, "icingSearchEngine.close, request");
             mIcingSearchEngineLocked.close();
             LogUtil.piiTrace(TAG, "icingSearchEngine.close, response");
+            if (mRevocableFileDescriptorStore != null) {
+                mRevocableFileDescriptorStore.revokeAll();
+            }
             mClosedLocked = true;
-        } catch (AppSearchException e) {
+        } catch (AppSearchException | IOException e) {
             Log.w(TAG, "Error when closing AppSearchImpl.", e);
         } finally {
             mReadWriteLock.writeLock().unlock();
@@ -1108,6 +1126,110 @@
         }
     }
 
+
+    /**
+     * Gets the {@link ParcelFileDescriptor} for write purpose of the given
+     * {@link AppSearchBlobHandle}.
+     *
+     * @param handle    The {@link AppSearchBlobHandle} represent the blob.
+     */
+    @NonNull
+    @ExperimentalAppSearchApi
+    public ParcelFileDescriptor openWriteBlob(
+            @NonNull String packageName,
+            @NonNull String databaseName,
+            @NonNull AppSearchBlobHandle handle)
+            throws AppSearchException, IOException {
+        if (mRevocableFileDescriptorStore == null) {
+            throw new UnsupportedOperationException(Features.BLOB_STORAGE
+                    + " is not available on this AppSearch implementation.");
+        }
+        mReadWriteLock.writeLock().lock();
+        try {
+            throwIfClosedLocked();
+            verifyCallingBlobHandle(packageName, databaseName, handle);
+            mRevocableFileDescriptorStore.checkBlobStoreLimit(packageName);
+            BlobProto result = mIcingSearchEngineLocked.openWriteBlob(
+                    BlobHandleToProtoConverter.toBlobHandleProto(handle));
+
+            checkSuccess(result.getStatus());
+            ParcelFileDescriptor pfd = ParcelFileDescriptor.fromFd(result.getFileDescriptor());
+
+            return mRevocableFileDescriptorStore
+                    .wrapToRevocableFileDescriptor(handle.getPackageName(), pfd);
+        } finally {
+            mReadWriteLock.writeLock().unlock();
+        }
+    }
+
+    /**
+     * Commits and seals the blob represented by the given {@link AppSearchBlobHandle}.
+     *
+     * <p>After this call, the blob is readable via {@link #openReadBlob}. And any rewrite is not
+     * allowed.
+     *
+     * @param handle    The {@link AppSearchBlobHandle} represent the blob.
+     */
+    @ExperimentalAppSearchApi
+    public void commitBlob(
+            @NonNull String packageName,
+            @NonNull String databaseName,
+            @NonNull AppSearchBlobHandle handle) throws AppSearchException {
+        if (mRevocableFileDescriptorStore == null) {
+            throw new UnsupportedOperationException(Features.BLOB_STORAGE
+                    + " is not available on this AppSearch implementation.");
+        }
+        mReadWriteLock.writeLock().lock();
+        try {
+            throwIfClosedLocked();
+            verifyCallingBlobHandle(packageName, databaseName, handle);
+            BlobProto result = mIcingSearchEngineLocked.commitBlob(
+                    BlobHandleToProtoConverter.toBlobHandleProto(handle));
+
+            checkSuccess(result.getStatus());
+        } finally {
+            mReadWriteLock.writeLock().unlock();
+        }
+    }
+
+    /**
+     * Gets the {@link ParcelFileDescriptor} for read only purpose of the given
+     * {@link AppSearchBlobHandle}.
+     *
+     * <p>The target must be committed via {@link #commitBlob};
+     *
+     * @param handle    The {@link AppSearchBlobHandle} represent the blob.
+     */
+    @NonNull
+    @ExperimentalAppSearchApi
+    public ParcelFileDescriptor openReadBlob(
+            @NonNull String packageName,
+            @NonNull String databaseName,
+            @NonNull AppSearchBlobHandle handle)
+            throws AppSearchException, IOException {
+        if (mRevocableFileDescriptorStore == null) {
+            throw new UnsupportedOperationException(Features.BLOB_STORAGE
+                    + " is not available on this AppSearch implementation.");
+        }
+
+        mReadWriteLock.readLock().lock();
+        try {
+            throwIfClosedLocked();
+            verifyCallingBlobHandle(packageName, databaseName, handle);
+            mRevocableFileDescriptorStore.checkBlobStoreLimit(packageName);
+            BlobProto result = mIcingSearchEngineLocked.openReadBlob(
+                    BlobHandleToProtoConverter.toBlobHandleProto(handle));
+
+            checkSuccess(result.getStatus());
+
+            ParcelFileDescriptor pfd = ParcelFileDescriptor.fromFd(result.getFileDescriptor());
+            return mRevocableFileDescriptorStore
+                    .wrapToRevocableFileDescriptor(handle.getPackageName(), pfd);
+        } finally {
+            mReadWriteLock.readLock().unlock();
+        }
+    }
+
     /**
      * Checks that a new document can be added to the given packageName with the given serialized
      * size without violating our {@link LimitConfig}.
@@ -2246,7 +2368,8 @@
      * @param packageName The name of package to be removed.
      * @throws AppSearchException if we cannot remove the data.
      */
-    public void clearPackageData(@NonNull String packageName) throws AppSearchException {
+    public void clearPackageData(@NonNull String packageName) throws AppSearchException,
+            IOException {
         mReadWriteLock.writeLock().lock();
         try {
             throwIfClosedLocked();
@@ -2263,6 +2386,9 @@
                 existingPackages.remove(packageName);
                 prunePackageData(existingPackages);
             }
+            if (mRevocableFileDescriptorStore != null) {
+                mRevocableFileDescriptorStore.revokeForPackage(packageName);
+            }
         } finally {
             mReadWriteLock.writeLock().unlock();
         }
@@ -2775,4 +2901,23 @@
             @NonNull StatusProto statusProto) {
         return ResultCodeToProtoConverter.toResultCode(statusProto.getCode());
     }
+
+    @ExperimentalAppSearchApi
+    private static void verifyCallingBlobHandle(@NonNull String callingPackageName,
+            @NonNull String callingDatabaseName, @NonNull AppSearchBlobHandle blobHandle)
+            throws AppSearchException {
+        if (!blobHandle.getPackageName().equals(callingPackageName)) {
+            throw new AppSearchException(AppSearchResult.RESULT_INVALID_ARGUMENT,
+                    "Blob package doesn't match calling package, calling package: "
+                            + callingPackageName + ", blob package: "
+                            + blobHandle.getPackageName());
+        }
+        if (!blobHandle.getDatabaseName().equals(callingDatabaseName)) {
+            throw new AppSearchException(AppSearchResult.RESULT_INVALID_ARGUMENT,
+                    "Blob database doesn't match calling database, calling database: "
+                            + callingDatabaseName + ", blob database: "
+                            + blobHandle.getDatabaseName());
+        }
+    }
+
 }
diff --git a/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/AppSearchRevocableFileDescriptorStore.java b/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/AppSearchRevocableFileDescriptorStore.java
new file mode 100644
index 0000000..86bcc64
--- /dev/null
+++ b/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/AppSearchRevocableFileDescriptorStore.java
@@ -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.appsearch.localstorage;
+
+import android.os.ParcelFileDescriptor;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.RestrictTo;
+import androidx.appsearch.exceptions.AppSearchException;
+
+import java.io.IOException;
+
+/**
+ * Interface for revocable file descriptors storage.
+ *
+ * <p>This store allows wrapping {@link ParcelFileDescriptor} instances into revocable file
+ * descriptors, enabling the ability to close and revoke it's access to the file even if the
+ * {@link ParcelFileDescriptor} has been sent to the client side.
+ *
+ * <p> Implementations of this interface can provide controlled access to resources by associating
+ * each file descriptor with a package and allowing them to be individually revoked by package
+ * or revoked all at once.
+ *
+ * @exportToFramework:hide
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+public interface AppSearchRevocableFileDescriptorStore {
+
+    /**
+     * Wraps the provided ParcelFileDescriptor into a revocable file descriptor.
+     * This allows for controlled access to the file descriptor, making it revocable by the store.
+     *
+     * @param packageName The package name requesting the revocable file descriptor.
+     * @param parcelFileDescriptor The original ParcelFileDescriptor to be wrapped.
+     * @return A ParcelFileDescriptor that can be revoked by the store.
+     */
+    @NonNull
+    ParcelFileDescriptor wrapToRevocableFileDescriptor(@NonNull String packageName,
+            @NonNull ParcelFileDescriptor parcelFileDescriptor) throws IOException;
+
+    /**
+     * Revokes all revocable file descriptors previously issued by the store.
+     * After calling this method, any access to these file descriptors will fail.
+     *
+     * @throws IOException If an I/O error occurs while revoking file descriptors.
+     */
+    void revokeAll() throws IOException;
+
+    /**
+     * Revokes all revocable file descriptors for a specified package.
+     * Only file descriptors associated with the given package name will be revoked.
+     *
+     * @param packageName The package name whose file descriptors should be revoked.
+     * @throws IOException If an I/O error occurs while revoking file descriptors.
+     */
+    void revokeForPackage(@NonNull String packageName) throws IOException;
+
+    /**  Checks if the specified package has reached its blob storage limit. */
+    void checkBlobStoreLimit(@NonNull String packageName) throws AppSearchException;
+}
diff --git a/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/IcingOptionsConfig.java b/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/IcingOptionsConfig.java
index d3246bf..9f25acb 100644
--- a/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/IcingOptionsConfig.java
+++ b/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/IcingOptionsConfig.java
@@ -74,6 +74,8 @@
 
     boolean DEFAULT_BUILD_PROPERTY_EXISTENCE_METADATA_HITS = false;
 
+    long DEFAULT_ORPHAN_BLOB_TIME_TO_LIVE_MS = 7 * 24 * 60 * 60 * 1000L; // 1 week.
+
     /**
      * The maximum allowable token length. All tokens in excess of this size will be truncated to
      * max_token_length before being indexed.
@@ -225,4 +227,22 @@
      * to support the hasProperty function in advanced query.
      */
     boolean getBuildPropertyExistenceMetadataHits();
+
+
+    /**
+     * Flag for {@link com.google.android.icing.proto.IcingSearchEngineOptions}.
+     *
+     * <p>Whether to enable the blob store. If set to true, the BlobStore will be created to store
+     * and retrieve blobs.
+     */
+    boolean getEnableBlobStore();
+
+
+    /**
+     * Config for {@link com.google.android.icing.proto.IcingSearchEngineOptions}.
+     *
+     * <p>The maximum time in millisecond for a orphan blob to get recycled and deleted if there is
+     * no reference document linked to it.
+     */
+    long getOrphanBlobTimeToLiveMs();
 }
diff --git a/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/JetpackRevocableFileDescriptorStore.java b/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/JetpackRevocableFileDescriptorStore.java
new file mode 100644
index 0000000..70d78aa
--- /dev/null
+++ b/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/JetpackRevocableFileDescriptorStore.java
@@ -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.
+ */
+
+// @exportToFramework:copyToPath(../../../cts/tests/appsearch/testutils/src/android/app/appsearch/testutil/external/JetpackRevocableFileDescriptorStore.java)
+package androidx.appsearch.localstorage;
+
+import android.os.ParcelFileDescriptor;
+
+import androidx.annotation.GuardedBy;
+import androidx.annotation.NonNull;
+import androidx.annotation.RestrictTo;
+import androidx.appsearch.app.AppSearchResult;
+import androidx.appsearch.exceptions.AppSearchException;
+import androidx.collection.ArrayMap;
+import androidx.core.util.Preconditions;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * The local storage implementation of {@link AppSearchRevocableFileDescriptorStore}.
+ *
+ * <p> The {@link ParcelFileDescriptor} sent to the client side from the local storage won't cross
+ * the binder, we could revoke the {@link ParcelFileDescriptor} in the client side by directly close
+ * the one in AppSearch side. This won't work in the framework, since even if we close the
+ * {@link ParcelFileDescriptor} in the server side, the one in the client side could still work.
+ *
+ * <p>This class is thread safety.
+ *
+ * @exportToFramework:hide
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+public class JetpackRevocableFileDescriptorStore implements
+        AppSearchRevocableFileDescriptorStore {
+
+    private final Object mLock  = new Object();
+    private final AppSearchConfig mConfig;
+
+    public JetpackRevocableFileDescriptorStore(@NonNull AppSearchConfig config) {
+        mConfig = Preconditions.checkNotNull(config);
+    }
+
+    @GuardedBy("mLock")
+    // <package, List<sent rfds> map to tracking all sent rfds.
+    private final Map<String, List<JetpackRevocableFileDescriptor>>
+            mSentAppSearchParcelFileDescriptorsLocked = new ArrayMap<>();
+
+    @Override
+    @NonNull
+    public ParcelFileDescriptor wrapToRevocableFileDescriptor(@NonNull String packageName,
+            @NonNull ParcelFileDescriptor parcelFileDescriptor) {
+        JetpackRevocableFileDescriptor revocableFileDescriptor =
+                new JetpackRevocableFileDescriptor(parcelFileDescriptor);
+        setCloseListenerToFd(revocableFileDescriptor, packageName);
+        addToSentAppSearchParcelFileDescriptorMap(revocableFileDescriptor, packageName);
+        return revocableFileDescriptor;
+    }
+
+    @Override
+    public void revokeAll() throws IOException {
+        synchronized (mLock) {
+            for (String packageName : mSentAppSearchParcelFileDescriptorsLocked.keySet()) {
+                revokeForPackage(packageName);
+            }
+        }
+    }
+
+    @Override
+    public void revokeForPackage(@NonNull String packageName) throws IOException {
+        synchronized (mLock) {
+            List<JetpackRevocableFileDescriptor> rfds =
+                    mSentAppSearchParcelFileDescriptorsLocked.remove(packageName);
+            if (rfds != null) {
+                for (int i = rfds.size() - 1; i >= 0; i--) {
+                    rfds.get(i).closeSuperDirectly();
+                }
+            }
+        }
+    }
+
+    @Override
+    public void checkBlobStoreLimit(@NonNull String packageName) throws AppSearchException {
+        synchronized (mLock) {
+            List<JetpackRevocableFileDescriptor> rfdsForPackage =
+                    mSentAppSearchParcelFileDescriptorsLocked.get(packageName);
+            if (rfdsForPackage == null) {
+                return;
+            }
+            if (rfdsForPackage.size() >= mConfig.getMaxOpenBlobCount()) {
+                throw new AppSearchException(AppSearchResult.RESULT_OUT_OF_SPACE,
+                        "Package \"" + packageName + "\" exceeded limit of "
+                                + mConfig.getMaxOpenBlobCount()
+                                + " opened file descriptors. Some file descriptors "
+                                + "must be closed to open additional ones.");
+            }
+        }
+    }
+
+    private void setCloseListenerToFd(
+            @NonNull JetpackRevocableFileDescriptor revocableFileDescriptor,
+            @NonNull String packageName) {
+        revocableFileDescriptor.setCloseListener(e -> {
+            synchronized (mLock) {
+                List<JetpackRevocableFileDescriptor> fdsForPackage =
+                        mSentAppSearchParcelFileDescriptorsLocked.get(packageName);
+                if (fdsForPackage != null) {
+                    fdsForPackage.remove(revocableFileDescriptor);
+                    if (fdsForPackage.isEmpty()) {
+                        mSentAppSearchParcelFileDescriptorsLocked.remove(packageName);
+                    }
+                }
+            }
+        });
+    }
+
+    private void addToSentAppSearchParcelFileDescriptorMap(
+            @NonNull JetpackRevocableFileDescriptor revocableFileDescriptor,
+            @NonNull String packageName) {
+        synchronized (mLock) {
+            List<JetpackRevocableFileDescriptor> rfdsForPackage =
+                    mSentAppSearchParcelFileDescriptorsLocked.get(packageName);
+            if (rfdsForPackage == null) {
+                rfdsForPackage = new ArrayList<>();
+                mSentAppSearchParcelFileDescriptorsLocked.put(packageName, rfdsForPackage);
+            }
+            rfdsForPackage.add(revocableFileDescriptor);
+        }
+    }
+
+    /**
+     * A custom {@link ParcelFileDescriptor} that provides an additional mechanism to register
+     * a {@link ParcelFileDescriptor.OnCloseListener} which will be invoked when the file
+     * descriptor is closed.
+     *
+     * <p> Since the {@link ParcelFileDescriptor} sent to the client side from the local storage
+     * won't cross the binder, we could revoke the {@link ParcelFileDescriptor} in the client side
+     * by directly close the one in AppSearch side. This class just adding close listener to the
+     * inner {@link ParcelFileDescriptor}.
+     */
+    static class JetpackRevocableFileDescriptor extends ParcelFileDescriptor {
+        private ParcelFileDescriptor.OnCloseListener mOnCloseListener;
+
+        /**
+         * Create a new ParcelFileDescriptor wrapped around another descriptor. By
+         * default all method calls are delegated to the wrapped descriptor.
+         */
+        JetpackRevocableFileDescriptor(@NonNull ParcelFileDescriptor parcelFileDescriptor) {
+            super(parcelFileDescriptor);
+        }
+
+        void setCloseListener(
+                @NonNull ParcelFileDescriptor.OnCloseListener onCloseListener) {
+            Preconditions.checkState(mOnCloseListener == null,
+                    "The close listener has already been set.");
+            mOnCloseListener = Preconditions.checkNotNull(onCloseListener);
+        }
+
+        /**  Close the super {@link ParcelFileDescriptor} without invoke close listener.         */
+        void closeSuperDirectly() throws IOException {
+            super.close();
+        }
+
+        @Override
+        public void close() throws IOException {
+            try {
+                super.close();
+                if (mOnCloseListener != null) {
+                    mOnCloseListener.onClose(null);
+                }
+            } catch (IOException e) {
+                if (mOnCloseListener != null) {
+                    mOnCloseListener.onClose(e);
+                }
+                throw e;
+            }
+        }
+    }
+}
diff --git a/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/LimitConfig.java b/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/LimitConfig.java
index 0844b3f..9c2d10f 100644
--- a/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/LimitConfig.java
+++ b/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/LimitConfig.java
@@ -70,4 +70,11 @@
      * from being overwhelmed by a single app.
      */
     int getMaxSuggestionCount();
+
+
+    /**
+     * Returns the maximum number of {@link android.os.ParcelFileDescriptor} that a single app could
+     * open for read and write blob from AppSearch.
+     */
+    int getMaxOpenBlobCount();
 }
diff --git a/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/LocalStorage.java b/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/LocalStorage.java
index 44f894b..4263ec1 100644
--- a/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/LocalStorage.java
+++ b/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/LocalStorage.java
@@ -30,6 +30,7 @@
 import androidx.appsearch.app.AppSearchSession;
 import androidx.appsearch.app.GlobalSearchSession;
 import androidx.appsearch.exceptions.AppSearchException;
+import androidx.appsearch.flags.Flags;
 import androidx.appsearch.localstorage.stats.InitializeStats;
 import androidx.appsearch.localstorage.stats.OptimizeStats;
 import androidx.appsearch.localstorage.util.FutureUtil;
@@ -353,16 +354,22 @@
         // Syncing the current logging level to Icing before creating the AppSearch object, so that
         // the correct logging level will cover the period of Icing initialization.
         AppSearchImpl.syncLoggingLevelToIcing();
+        AppSearchConfig config = new AppSearchConfigImpl(
+                new UnlimitedLimitConfig(),
+                new LocalStorageIcingOptionsConfig(),
+                /* storeParentInfoAsSyntheticProperty= */ false,
+                /* shouldRetrieveParentInfo= */ true
+        );
+        AppSearchRevocableFileDescriptorStore revocableFileDescriptorStore = null;
+        if (Flags.enableBlobStore()) {
+            revocableFileDescriptorStore = new JetpackRevocableFileDescriptorStore(config);
+        }
         mAppSearchImpl = AppSearchImpl.create(
                 icingDir,
-                new AppSearchConfigImpl(
-                        new UnlimitedLimitConfig(),
-                        new LocalStorageIcingOptionsConfig(),
-                        /* storeParentInfoAsSyntheticProperty= */ false,
-                        /* shouldRetrieveParentInfo= */ true
-                ),
+                config,
                 initStatsBuilder,
                 /*visibilityChecker=*/ null,
+                revocableFileDescriptorStore,
                 new JetpackOptimizeStrategy());
 
         if (logger != null) {
diff --git a/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/LocalStorageIcingOptionsConfig.java b/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/LocalStorageIcingOptionsConfig.java
index 11b530e..51e1125 100644
--- a/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/LocalStorageIcingOptionsConfig.java
+++ b/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/LocalStorageIcingOptionsConfig.java
@@ -98,4 +98,14 @@
     public boolean getBuildPropertyExistenceMetadataHits() {
         return true;
     }
+
+    @Override
+    public boolean getEnableBlobStore() {
+        return true;
+    }
+
+    @Override
+    public long getOrphanBlobTimeToLiveMs() {
+        return DEFAULT_ORPHAN_BLOB_TIME_TO_LIVE_MS;
+    }
 }
diff --git a/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/UnlimitedLimitConfig.java b/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/UnlimitedLimitConfig.java
index eee5583..8c8a342 100644
--- a/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/UnlimitedLimitConfig.java
+++ b/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/UnlimitedLimitConfig.java
@@ -44,4 +44,9 @@
     public int getMaxSuggestionCount() {
         return Integer.MAX_VALUE;
     }
+
+    @Override
+    public int getMaxOpenBlobCount() {
+        return Integer.MAX_VALUE;
+    }
 }
diff --git a/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/converter/BlobHandleToProtoConverter.java b/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/converter/BlobHandleToProtoConverter.java
new file mode 100644
index 0000000..095aa0b
--- /dev/null
+++ b/appsearch/appsearch-local-storage/src/main/java/androidx/appsearch/localstorage/converter/BlobHandleToProtoConverter.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.appsearch.localstorage.converter;
+
+
+import androidx.annotation.NonNull;
+import androidx.annotation.RestrictTo;
+import androidx.appsearch.app.AppSearchBlobHandle;
+import androidx.appsearch.app.ExperimentalAppSearchApi;
+import androidx.appsearch.exceptions.AppSearchException;
+import androidx.appsearch.localstorage.util.PrefixUtil;
+
+import com.google.android.icing.proto.PropertyProto;
+import com.google.android.icing.protobuf.ByteString;
+
+/**
+ * Translates a {@link android.app.blob.BlobHandle} into {@link PropertyProto.BlobHandleProto}.
+
+ * @exportToFramework:hide
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+@ExperimentalAppSearchApi
+public final class BlobHandleToProtoConverter {
+    private BlobHandleToProtoConverter() {}
+
+    /**  Converters a {@link AppSearchBlobHandle} into {@link PropertyProto.BlobHandleProto}. */
+    @NonNull
+    public static PropertyProto.BlobHandleProto toBlobHandleProto(
+            @NonNull AppSearchBlobHandle blobHandle) {
+        return PropertyProto.BlobHandleProto.newBuilder()
+                .setNamespace(PrefixUtil.createPrefix(
+                        blobHandle.getPackageName(), blobHandle.getDatabaseName())
+                        + blobHandle.getNamespace())
+                .setDigest(ByteString.copyFrom(blobHandle.getSha256Digest()))
+                .build();
+    }
+
+    /**  Converters a {@link PropertyProto.BlobHandleProto} into {@link AppSearchBlobHandle}. */
+    @NonNull
+    public static AppSearchBlobHandle toAppSearchBlobHandle(
+            @NonNull PropertyProto.BlobHandleProto proto) throws AppSearchException {
+        String prefix = PrefixUtil.getPrefix(proto.getNamespace());
+        return AppSearchBlobHandle.createWithSha256(
+                proto.getDigest().toByteArray(),
+                PrefixUtil.getPackageName(prefix),
+                PrefixUtil.getDatabaseName(prefix),
+                PrefixUtil.removePrefix(proto.getNamespace()));
+    }
+}
diff --git a/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/NegationNodeCtsTest.java b/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/NegationNodeCtsTest.java
index 0a691a7..0ed9372 100644
--- a/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/NegationNodeCtsTest.java
+++ b/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/NegationNodeCtsTest.java
@@ -39,6 +39,15 @@
     public final CheckFlagsRule mCheckFlagsRule = DeviceFlagsValueProvider.createCheckFlagsRule();
 
     @Test
+    public void testEquals_identical() {
+        NegationNode nodeOne = new NegationNode(new TextNode("foo"));
+        NegationNode nodeTwo = new NegationNode(new TextNode("foo"));
+
+        assertThat(nodeOne).isEqualTo(nodeTwo);
+        assertThat(nodeOne.hashCode()).isEqualTo(nodeTwo.hashCode());
+    }
+
+    @Test
     public void testSetChildren_throwsOnNullNode() {
         TextNode textNode = new TextNode("foo");
 
diff --git a/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/TextNodeCtsTest.java b/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/TextNodeCtsTest.java
index b727b5a..574a49f 100644
--- a/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/TextNodeCtsTest.java
+++ b/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/TextNodeCtsTest.java
@@ -35,6 +35,18 @@
     public final CheckFlagsRule mCheckFlagsRule = DeviceFlagsValueProvider.createCheckFlagsRule();
 
     @Test
+    public void testEquals_identical() {
+        TextNode nodeOne = new TextNode("foo");
+        nodeOne.setPrefix(true);
+
+        TextNode nodeTwo = new TextNode("foo");
+        nodeTwo.setPrefix(true);
+
+        assertThat(nodeOne).isEqualTo(nodeTwo);
+        assertThat(nodeOne.hashCode()).isEqualTo(nodeTwo.hashCode());
+    }
+
+    @Test
     public void testConstructor_prefixVerbatimFalseByDefault() {
         TextNode defaultTextNode = new TextNode("foo");
 
diff --git a/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/operators/AndNodeCtsTest.java b/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/operators/AndNodeCtsTest.java
index d603a06..c61a6fe 100644
--- a/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/operators/AndNodeCtsTest.java
+++ b/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/operators/AndNodeCtsTest.java
@@ -42,6 +42,20 @@
     public final CheckFlagsRule mCheckFlagsRule = DeviceFlagsValueProvider.createCheckFlagsRule();
 
     @Test
+    public void testEquals_identical() {
+        TextNode foo = new TextNode("foo");
+        TextNode bar = new TextNode("bar");
+        AndNode andNodeOne = new AndNode(List.of(foo, bar));
+
+        TextNode fooTwo = new TextNode("foo");
+        TextNode barTwo = new TextNode("bar");
+        AndNode andNodeTwo = new AndNode(List.of(fooTwo, barTwo));
+
+        assertThat(andNodeOne).isEqualTo(andNodeTwo);
+        assertThat(andNodeOne.hashCode()).isEqualTo(andNodeTwo.hashCode());
+    }
+
+    @Test
     public void testConstructor_buildsAndNode() {
         TextNode foo = new TextNode("foo");
         TextNode bar = new TextNode("bar");
diff --git a/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/operators/OrNodeCtsTest.java b/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/operators/OrNodeCtsTest.java
index e2b3341..574134e 100644
--- a/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/operators/OrNodeCtsTest.java
+++ b/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/operators/OrNodeCtsTest.java
@@ -42,6 +42,20 @@
     public final CheckFlagsRule mCheckFlagsRule = DeviceFlagsValueProvider.createCheckFlagsRule();
 
     @Test
+    public void testEquals_identical() {
+        TextNode foo = new TextNode("foo");
+        TextNode bar = new TextNode("bar");
+        OrNode orNodeOne = new OrNode(List.of(foo, bar));
+
+        TextNode fooTwo = new TextNode("foo");
+        TextNode barTwo = new TextNode("bar");
+        OrNode orNodeTwo = new OrNode(List.of(fooTwo, barTwo));
+
+        assertThat(orNodeOne).isEqualTo(orNodeTwo);
+        assertThat(orNodeOne.hashCode()).isEqualTo(orNodeTwo.hashCode());
+    }
+
+    @Test
     public void testConstructor_buildsOrNode() {
         TextNode foo = new TextNode("foo");
         TextNode bar = new TextNode("bar");
diff --git a/appsearch/appsearch/src/main/java/androidx/appsearch/ast/NegationNode.java b/appsearch/appsearch/src/main/java/androidx/appsearch/ast/NegationNode.java
index 13e9686..645d312 100644
--- a/appsearch/appsearch/src/main/java/androidx/appsearch/ast/NegationNode.java
+++ b/appsearch/appsearch/src/main/java/androidx/appsearch/ast/NegationNode.java
@@ -25,6 +25,7 @@
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
+import java.util.Objects;
 
 /**
  * {@link Node} that stores a child node to be logically negated with a negative sign ("-")
@@ -118,4 +119,17 @@
     public String toString() {
         return "NOT " + getChild();
     }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+        NegationNode that = (NegationNode) o;
+        return Objects.equals(mChildren, that.mChildren);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hashCode(mChildren);
+    }
 }
diff --git a/appsearch/appsearch/src/main/java/androidx/appsearch/ast/TextNode.java b/appsearch/appsearch/src/main/java/androidx/appsearch/ast/TextNode.java
index 3eefa56..473ee94 100644
--- a/appsearch/appsearch/src/main/java/androidx/appsearch/ast/TextNode.java
+++ b/appsearch/appsearch/src/main/java/androidx/appsearch/ast/TextNode.java
@@ -22,6 +22,8 @@
 import androidx.appsearch.flags.Flags;
 import androidx.core.util.Preconditions;
 
+import java.util.Objects;
+
 /**
  * {@link Node} that stores text.
  *
@@ -259,4 +261,18 @@
     private boolean isLatinLetter(char c) {
         return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z');
     }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+        TextNode textNode = (TextNode) o;
+        return mPrefix == textNode.mPrefix && mVerbatim == textNode.mVerbatim
+                && Objects.equals(mValue, textNode.mValue);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(mValue, mPrefix, mVerbatim);
+    }
 }
diff --git a/appsearch/appsearch/src/main/java/androidx/appsearch/ast/operators/AndNode.java b/appsearch/appsearch/src/main/java/androidx/appsearch/ast/operators/AndNode.java
index 8774fab..82f1bbd 100644
--- a/appsearch/appsearch/src/main/java/androidx/appsearch/ast/operators/AndNode.java
+++ b/appsearch/appsearch/src/main/java/androidx/appsearch/ast/operators/AndNode.java
@@ -28,6 +28,7 @@
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
+import java.util.Objects;
 
 /**
  * {@link Node} that represents logical AND of nodes.
@@ -135,4 +136,17 @@
     public String toString() {
         return "(" + TextUtils.join(" AND ", mChildren) + ")";
     }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+        AndNode andNode = (AndNode) o;
+        return Objects.equals(mChildren, andNode.mChildren);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hashCode(mChildren);
+    }
 }
diff --git a/appsearch/appsearch/src/main/java/androidx/appsearch/ast/operators/OrNode.java b/appsearch/appsearch/src/main/java/androidx/appsearch/ast/operators/OrNode.java
index c073122..fe87dbf 100644
--- a/appsearch/appsearch/src/main/java/androidx/appsearch/ast/operators/OrNode.java
+++ b/appsearch/appsearch/src/main/java/androidx/appsearch/ast/operators/OrNode.java
@@ -28,6 +28,7 @@
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
+import java.util.Objects;
 
 /**
  * {@link Node} that represents logical OR of nodes.
@@ -134,4 +135,17 @@
     public String toString() {
         return "(" + TextUtils.join(" OR ", mChildren) + ")";
     }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+        OrNode orNode = (OrNode) o;
+        return Objects.equals(mChildren, orNode.mChildren);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hashCode(mChildren);
+    }
 }
diff --git a/benchmark/baseline-profile-gradle-plugin/src/main/kotlin/androidx/baselineprofile/gradle/producer/BaselineProfileProducerExtension.kt b/benchmark/baseline-profile-gradle-plugin/src/main/kotlin/androidx/baselineprofile/gradle/producer/BaselineProfileProducerExtension.kt
index 571621c..48fced5 100644
--- a/benchmark/baseline-profile-gradle-plugin/src/main/kotlin/androidx/baselineprofile/gradle/producer/BaselineProfileProducerExtension.kt
+++ b/benchmark/baseline-profile-gradle-plugin/src/main/kotlin/androidx/baselineprofile/gradle/producer/BaselineProfileProducerExtension.kt
@@ -44,7 +44,7 @@
      * managed devices. For example, in the following configuration, the name is `pixel6Api31`.
      *
      * ```
-     *  testOptions.managedDevices.devices {
+     *  testOptions.managedDevices.allDevices {
      *      pixel6Api31(ManagedVirtualDevice) {
      *          device = "Pixel 6"
      *          apiLevel = 31
diff --git a/benchmark/baseline-profile-gradle-plugin/src/main/kotlin/androidx/baselineprofile/gradle/producer/BaselineProfileProducerPlugin.kt b/benchmark/baseline-profile-gradle-plugin/src/main/kotlin/androidx/baselineprofile/gradle/producer/BaselineProfileProducerPlugin.kt
index aa184ec..bfdecd6 100644
--- a/benchmark/baseline-profile-gradle-plugin/src/main/kotlin/androidx/baselineprofile/gradle/producer/BaselineProfileProducerPlugin.kt
+++ b/benchmark/baseline-profile-gradle-plugin/src/main/kotlin/androidx/baselineprofile/gradle/producer/BaselineProfileProducerPlugin.kt
@@ -398,7 +398,7 @@
                 existing gradle managed devices. Example:
 
                 android {
-                    testOptions.managedDevices.devices {
+                    testOptions.managedDevices.allDevices {
                         pixel6Api31(ManagedVirtualDevice) {
                             device = "Pixel 6"
                             apiLevel = 31
diff --git a/benchmark/baseline-profile-gradle-plugin/src/test/kotlin/androidx/baselineprofile/gradle/utils/BaselineProfileProjectSetupRule.kt b/benchmark/baseline-profile-gradle-plugin/src/test/kotlin/androidx/baselineprofile/gradle/utils/BaselineProfileProjectSetupRule.kt
index fb29f65..1021c7c 100644
--- a/benchmark/baseline-profile-gradle-plugin/src/test/kotlin/androidx/baselineprofile/gradle/utils/BaselineProfileProjectSetupRule.kt
+++ b/benchmark/baseline-profile-gradle-plugin/src/test/kotlin/androidx/baselineprofile/gradle/utils/BaselineProfileProjectSetupRule.kt
@@ -72,13 +72,23 @@
             rule = producerSetupRule,
             name = producerName,
             tempFolder = tempFolder,
-            consumer = consumer
+            consumer = consumer,
+            managedDeviceContainerName = managedDeviceContainerName,
         )
     }
 
     /** Represents a simple java library dependency module. */
     val dependency by lazy { DependencyModule(name = dependencyName) }
 
+    /** The managed device container name to use in the build.gradle file. */
+    val managedDeviceContainerName: String
+        get() =
+            if (forcedTestAgpVersion.isAtLeast(TestAgpVersion.TEST_AGP_VERSION_8_1_0)) {
+                "allDevices"
+            } else {
+                "devices"
+            }
+
     // Temp folder for temp generated files that need to be referenced by a module.
     private val tempFolder by lazy { File(rootFolder.root, "temp").apply { mkdirs() } }
 
@@ -371,6 +381,7 @@
     override val name: String,
     private val tempFolder: File,
     private val consumer: Module,
+    private val managedDeviceContainerName: String,
 ) : Module {
 
     fun setupWithFreeAndPaidFlavors(
@@ -494,7 +505,7 @@
             if (managedDevices.isEmpty()) ""
             else
                 """
-            testOptions.managedDevices.devices {
+            testOptions.managedDevices.$managedDeviceContainerName {
             ${
                     managedDevices.joinToString("\n") {
                         """
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 93abd81..fae211b 100644
--- a/benchmark/benchmark-common/src/main/java/androidx/benchmark/Arguments.kt
+++ b/benchmark/benchmark-common/src/main/java/androidx/benchmark/Arguments.kt
@@ -91,6 +91,7 @@
     internal val cpuEventCounterMask: Int
     internal val requireAot: Boolean
     internal val requireJitDisabledIfRooted: Boolean
+    val throwOnMainThreadMeasureRepeated: Boolean // non-internal, used in BenchmarkRule
     val runOnMainDeadlineSeconds: Long // non-internal, used in BenchmarkRule
 
     internal var error: String? = null
@@ -341,6 +342,9 @@
         requireJitDisabledIfRooted =
             arguments.getBenchmarkArgument("requireJitDisabledIfRooted")?.toBoolean() ?: false
 
+        throwOnMainThreadMeasureRepeated =
+            arguments.getBenchmarkArgument("throwOnMainThreadMeasureRepeated")?.toBoolean() ?: false
+
         if (arguments.getString("orchestratorService") != null) {
             InstrumentationResults.scheduleIdeWarningOnNextReport(
                 """
diff --git a/benchmark/benchmark-common/src/main/java/androidx/benchmark/DeviceInfo.kt b/benchmark/benchmark-common/src/main/java/androidx/benchmark/DeviceInfo.kt
index 9cfbbd5..3083747 100644
--- a/benchmark/benchmark-common/src/main/java/androidx/benchmark/DeviceInfo.kt
+++ b/benchmark/benchmark-common/src/main/java/androidx/benchmark/DeviceInfo.kt
@@ -269,7 +269,7 @@
      *
      * See b/292294133
      */
-    const val ART_MAINLINE_MIN_VERSION_CLASS_INIT_TRACING = 341511000L
+    const val ART_MAINLINE_MIN_VERSION_CLASS_LOAD_TRACING = 341511000L
 
     /**
      * Starting with an API 34 change cherry-picked to mainline, when `verify`-compiled, ART will
@@ -324,14 +324,14 @@
         Build.VERSION.SDK_INT in 26..30 || // b/313868903
             artMainlineVersion in ART_MAINLINE_VERSIONS_AFFECTING_METHOD_TRACING // b/303660864
 
-    fun isClassInitTracingAvailable(targetApiLevel: Int, targetArtMainlineVersion: Long?): Boolean =
+    fun isClassLoadTracingAvailable(targetApiLevel: Int, targetArtMainlineVersion: Long?): Boolean =
         targetApiLevel >= 35 ||
             (targetApiLevel >= 31 &&
                 (targetArtMainlineVersion == null ||
-                    targetArtMainlineVersion >= ART_MAINLINE_MIN_VERSION_CLASS_INIT_TRACING))
+                    targetArtMainlineVersion >= ART_MAINLINE_MIN_VERSION_CLASS_LOAD_TRACING))
 
-    val supportsClassInitTracing =
-        isClassInitTracingAvailable(Build.VERSION.SDK_INT, artMainlineVersion)
+    val supportsClassLoadTracing =
+        isClassLoadTracingAvailable(Build.VERSION.SDK_INT, artMainlineVersion)
 
     val supportsRuntimeImages =
         Build.VERSION.SDK_INT >= 34 || artMainlineVersion >= ART_MAINLINE_MIN_VERSION_RUNTIME_IMAGE
diff --git a/benchmark/benchmark-junit4/src/androidTest/java/androidx/benchmark/junit4/ActivityBenchmarkTests.kt b/benchmark/benchmark-junit4/src/androidTest/java/androidx/benchmark/junit4/ActivityBenchmarkTests.kt
index 4bcfcdb..27219fe 100644
--- a/benchmark/benchmark-junit4/src/androidTest/java/androidx/benchmark/junit4/ActivityBenchmarkTests.kt
+++ b/benchmark/benchmark-junit4/src/androidTest/java/androidx/benchmark/junit4/ActivityBenchmarkTests.kt
@@ -17,8 +17,8 @@
 package androidx.benchmark.junit4
 
 import android.app.Activity
+import androidx.annotation.WorkerThread
 import androidx.benchmark.IsolationActivity
-import androidx.test.annotation.UiThreadTest
 import androidx.test.core.app.ActivityScenario
 import androidx.test.ext.junit.rules.ActivityScenarioRule
 import androidx.test.ext.junit.runners.AndroidJUnit4
@@ -30,11 +30,17 @@
 import org.junit.Test
 import org.junit.runner.RunWith
 
+@WorkerThread
 fun BenchmarkRule.validateRunWithIsolationActivityHidden() {
-    // isolation activity *not* on top
-    assertFalse(IsolationActivity.resumed)
+    var first = true
 
-    measureRepeated {}
+    measureRepeatedOnMainThread {
+        if (first) {
+            // isolation activity *not* on top
+            assertFalse(IsolationActivity.resumed)
+            first = false
+        }
+    }
 }
 
 @LargeTest
@@ -51,7 +57,7 @@
 
     @Test
     fun verifyActivityLaunched() {
-        activityScenario.onActivity { benchmarkRule.validateRunWithIsolationActivityHidden() }
+        benchmarkRule.validateRunWithIsolationActivityHidden()
     }
 }
 
@@ -63,7 +69,6 @@
     @get:Rule val activityRule = ActivityScenarioRule(Activity::class.java)
 
     @FlakyTest(bugId = 187106319)
-    @UiThreadTest
     @Test
     fun verifyActivityLaunched() {
         benchmarkRule.validateRunWithIsolationActivityHidden()
@@ -79,7 +84,6 @@
     @get:Rule
     val activityRule = androidx.test.rule.ActivityTestRule(Activity::class.java)
 
-    @UiThreadTest
     @Test
     fun verifyActivityLaunched() {
         benchmarkRule.validateRunWithIsolationActivityHidden()
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 86dec73..149df7a 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
@@ -317,6 +317,13 @@
 public inline fun BenchmarkRule.measureRepeated(crossinline block: BenchmarkRule.Scope.() -> Unit) {
     // Note: this is an extension function to discourage calling from Java.
 
+    if (Arguments.throwOnMainThreadMeasureRepeated) {
+        check(Looper.myLooper() != Looper.getMainLooper()) {
+            "Cannot invoke measureRepeated from the main thread. Instead use" +
+                " measureRepeatedOnMainThread()"
+        }
+    }
+
     // Extract members to locals, to ensure we check #applied, and we don't hit accessors
     val localState = getState()
     val localScope = scope
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 6c36c4f..0b31597 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
@@ -39,17 +39,17 @@
             apiLevel = 35,
             artMainlineVersion = null, // unknown, but not important on 35
             expectedJit = SubMetric(177, 433.488508),
-            expectedClassInit = SubMetric(2013, 147.052337),
+            expectedClassLoad = SubMetric(2013, 147.052337),
             expectedClassVerify = SubMetric(0, 0.0)
         )
 
     @Test
-    fun filterOutClassInit() =
+    fun filterOutClassLoad() =
         verifyArtMetrics(
             apiLevel = 31,
-            artMainlineVersion = DeviceInfo.ART_MAINLINE_MIN_VERSION_CLASS_INIT_TRACING - 1,
+            artMainlineVersion = DeviceInfo.ART_MAINLINE_MIN_VERSION_CLASS_LOAD_TRACING - 1,
             expectedJit = SubMetric(177, 433.488508),
-            expectedClassInit = null, // drops class init
+            expectedClassLoad = null, // drops class load
             expectedClassVerify = SubMetric(0, 0.0)
         )
 
@@ -57,9 +57,9 @@
     fun oldVersionMainline() =
         verifyArtMetrics(
             apiLevel = 31,
-            artMainlineVersion = DeviceInfo.ART_MAINLINE_MIN_VERSION_CLASS_INIT_TRACING,
+            artMainlineVersion = DeviceInfo.ART_MAINLINE_MIN_VERSION_CLASS_LOAD_TRACING,
             expectedJit = SubMetric(177, 433.488508),
-            expectedClassInit = SubMetric(2013, 147.052337),
+            expectedClassLoad = SubMetric(2013, 147.052337),
             expectedClassVerify = SubMetric(0, 0.0)
         )
 
@@ -68,7 +68,7 @@
             apiLevel: Int,
             artMainlineVersion: Long?,
             expectedJit: SubMetric,
-            expectedClassInit: SubMetric?,
+            expectedClassLoad: SubMetric?,
             expectedClassVerify: SubMetric
         ) {
             val tracePath =
@@ -107,12 +107,12 @@
                     Metric.Measurement("artVerifyClassSumMs", expectedClassVerify.sum),
                     Metric.Measurement("artVerifyClassCount", expectedClassVerify.count.toDouble()),
                 ) +
-                    if (expectedClassInit != null) {
+                    if (expectedClassLoad != null) {
                         listOf(
-                            Metric.Measurement("artClassInitSumMs", expectedClassInit.sum),
+                            Metric.Measurement("artClassLoadSumMs", expectedClassLoad.sum),
                             Metric.Measurement(
-                                "artClassInitCount",
-                                expectedClassInit.count.toDouble()
+                                "artClassLoadCount",
+                                expectedClassLoad.count.toDouble()
                             ),
                         )
                     } else {
diff --git a/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/RuntimeImageTest.kt b/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/RuntimeImageTest.kt
index 86fb1c3..5251431 100644
--- a/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/RuntimeImageTest.kt
+++ b/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/RuntimeImageTest.kt
@@ -58,19 +58,19 @@
 
     @LargeTest
     @Test
-    fun classInitCount() {
+    fun classLoadCount() {
         assumeTrue("Test requires runtime image support", DeviceInfo.supportsRuntimeImages)
-        assumeTrue("Test requires class init tracing", DeviceInfo.supportsClassInitTracing)
+        assumeTrue("Test requires class load tracing", DeviceInfo.supportsClassLoadTracing)
 
-        val testName = RuntimeImageTest::classInitCount.name
+        val testName = RuntimeImageTest::classLoadCount.name
         val results = captureRecyclerViewListStartupMetrics(testName)
 
-        val classInitCount = results["artClassInitCount"]!!.runs
+        val classLoadCount = results["artClassLoadCount"]!!.runs
 
         // observed >700 in practice, lower threshold used to be resilient
         assertTrue(
-            classInitCount.all { it > 500 },
-            "too few class inits seen, observed: $classInitCount"
+            classLoadCount.all { it > 500 },
+            "too few class loads seen, observed: $classLoadCount"
         )
     }
 }
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 05f8a1c..db9bb44 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
@@ -638,15 +638,15 @@
 /**
  * Captures metrics about ART method/class compilation and initialization.
  *
- * JIT Compilation, Class Verification, and (on supported devices) Class Initialization.
+ * JIT Compilation, Class Verification, and (on supported devices) Class Loading.
  *
  * For more information on how ART compilation works, see
  * [ART Runtime docs](https://source.android.com/docs/core/runtime/configure).
  *
  * ## JIT Compilation
- * As interpreted (uncompiled) dex code from your APK is run, methods will be Just-In-Time (JIT)
- * compiled, and this compilation is traced by ART. This does not apply to code AOT compiled either
- * from Baseline Profiles, Warmup Profiles, or Full AOT.
+ * As interpreted (uncompiled) dex code from the APK is run, some methods will be Just-In-Time (JIT)
+ * compiled, and this compilation is traced by ART. This does not apply to methods AOT compiled
+ * either from Baseline Profiles, Warmup Profiles, or Full AOT.
  *
  * The number of traces and total duration (reported as `artJitCount` and `artJitSumMs`) indicate
  * how many uncompiled methods were considered hot by the runtime, and were JITted during
@@ -655,38 +655,45 @@
  * Note that framework code on the system image that is not AOT compiled on the system image may
  * also be JITted, and will also show up in this metric. If you see this metric reporting non-zero
  * values when compiled with [CompilationMode.Full] or [CompilationMode.Partial], this may be the
- * reason. See also "Class Verification" below.
+ * reason.
  *
- * ## Class Initialization
- * Class Initialization tracing requires either API 35, or API 31+ with ART mainline version >=
+ * Some methods can't be AOTed or JIT compiled. Generally these are either methods too large for the
+ * Android runtime compiler, or due to a malformed class definition.
+ *
+ * ## Class Loading
+ * Class Loading tracing requires either API 35, or API 31+ with ART mainline version >=
  * `341511000`. If a device doesn't support these tracepoints, the measurements will not be reported
  * in Studio UI or in JSON results. You can check your device's ART mainline version with:
  * ```
  * adb shell cmd package list packages --show-versioncode --apex-only art
  * ```
  *
- * Classes must be initialized by ART in order to be used at runtime. In [CompilationMode.None]
- * (with `warmupRuntimeImageEnabled=false`) and [CompilationMode.Full], this is deferred until
- * runtime, and the cost of this can significantly slow down scenarios where code is run for the
- * first time, such as startup. In [CompilationMode.Partial], this is done at compile time if the
- * class is `trivial` (that is, has no static initializers).
+ * Classes must be loaded by ART in order to be used at runtime. In [CompilationMode.None] and
+ * [CompilationMode.Full], this is deferred until runtime, and the cost of this can significantly
+ * slow down scenarios where code is run for the first time, such as startup.
  *
- * The number of traces and total duration (reported as `artClassInitCount` and `artClassInitSumMs`)
- * indicate how many classes were initialized during measurement, at runtime, without
- * pre-initialization at compile time (or in the case of `CompilationMode.None(true), a previous app
- * launch)`.
+ * In `CompilationMode.Partial(warmupIterations=...)` classes captured in the warmup profile (used
+ * during the warmup iterations) are persisted into the `.art` file at compile time to allow them to
+ * be preloaded during app start, before app code begins to execute. If a class is preloaded by the
+ * runtime, it will not appear in traces.
+ *
+ * Even if a class is captured in the warmup profile, it will not be persisted at compile time if
+ * any of the superclasses are not in the app's profile (extremely unlikely) or the Boot Image
+ * profile (for Boot Image classes).
+ *
+ * The number of traces and total duration (reported as `artClassLoadCount` and `artClassLoadSumMs`)
+ * indicate how many classes were loaded during measurement, at runtime, without preloading at
+ * compile time.
  *
  * These tracepoints are slices of the form `Lcom/example/MyClassName;` for a class named
  * `com.example.MyClassName`.
  *
- * Even using `CompilationMode.Partial(warmupIterations=...)`, this number will often be non-zero,
- * even if every class is captured in the profile. This can be caused by a static initializer in the
- * class, preventing it from being compile-time initialized.
+ * Class loading is not affected by class verification.
  *
  * ## Class Verification
- *
- * Before initialization, classes must be verified by the runtime. Typically all classes in a
- * release APK are verified at install time, regardless of [CompilationMode].
+ * Most usages of a class require classes to be verified by the runtime (some usage only require
+ * loading). Typically all classes in a release APK are verified at install time, regardless of
+ * [CompilationMode].
  *
  * The number of traces and total duration (reported as `artVerifyClass` and `artVerifyClassSumMs`)
  * indicate how many classes were verified during measurement, at runtime.
@@ -695,14 +702,13 @@
  * 1) If install-time verification fails for a class, it will remain unverified, and be verified at
  *    runtime.
  * 2) Debuggable=true apps are not verified at install time, to save on iteration speed at the cost
- *    of runtime performance. This results in runtime verification of each class as its loaded which
- *    is the source of much of the slowdown between a debug app and a release app (assuming you're
- *    not using a compile-time optimizing dexer, like R8). This is only relevant in macrobenchmark
- *    if suppressing warnings about measuring debug app performance.
+ *    of runtime performance. This results in runtime verification of each class as it's loaded
+ *    which is the source of much of the slowdown between a debug app and a release app. As
+ *    Macrobenchmark treats `debuggable=true` as a measurement error, this won't be the case for
+ *    `ArtMetric` measurements unless you suppress that error.
  *
- * Class Verification at runtime prevents both install-time class initialization, and install-time
- * method compilation. If you see JIT from classes in your apk despite using [CompilationMode.Full],
- * install-time verification failures could be the cause, and would show up in this metric.
+ * Some classes will be verified at runtime rather than install time due to limitations in the
+ * compiler and runtime or due to being malformed.
  */
 @RequiresApi(24)
 class ArtMetric : Metric() {
@@ -717,14 +723,14 @@
                 .querySlices("VerifyClass %", packageName = captureInfo.targetPackageName)
                 .asMeasurements("artVerifyClass") +
             if (
-                DeviceInfo.isClassInitTracingAvailable(
+                DeviceInfo.isClassLoadTracingAvailable(
                     targetApiLevel = captureInfo.apiLevel,
                     targetArtMainlineVersion = captureInfo.artMainlineVersion
                 )
             ) {
                 traceSession
-                    .querySlices("L%;", packageName = captureInfo.targetPackageName)
-                    .asMeasurements("artClassInit")
+                    .querySlices("L%/%;", packageName = captureInfo.targetPackageName)
+                    .asMeasurements("artClassLoad")
             } else emptyList()
     }
 
diff --git a/benchmark/integration-tests/baselineprofile-flavors-producer/build.gradle b/benchmark/integration-tests/baselineprofile-flavors-producer/build.gradle
index 25b403d..a86b882 100644
--- a/benchmark/integration-tests/baselineprofile-flavors-producer/build.gradle
+++ b/benchmark/integration-tests/baselineprofile-flavors-producer/build.gradle
@@ -35,7 +35,7 @@
         minSdkVersion 23
         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
     }
-    testOptions.managedDevices.devices {
+    testOptions.managedDevices.allDevices {
         pixel6Api31(ManagedVirtualDevice) {
             device = "Pixel 6"
             apiLevel = 31
diff --git a/benchmark/integration-tests/baselineprofile-library-producer/build.gradle b/benchmark/integration-tests/baselineprofile-library-producer/build.gradle
index 45e81a0..a5432f1 100644
--- a/benchmark/integration-tests/baselineprofile-library-producer/build.gradle
+++ b/benchmark/integration-tests/baselineprofile-library-producer/build.gradle
@@ -35,7 +35,7 @@
         minSdkVersion 23
         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
     }
-    testOptions.managedDevices.devices {
+    testOptions.managedDevices.allDevices {
         pixel6Api31(ManagedVirtualDevice) {
             device = "Pixel 6"
             apiLevel = 31
diff --git a/benchmark/integration-tests/baselineprofile-producer/build.gradle b/benchmark/integration-tests/baselineprofile-producer/build.gradle
index 3a320b1..b4132ee 100644
--- a/benchmark/integration-tests/baselineprofile-producer/build.gradle
+++ b/benchmark/integration-tests/baselineprofile-producer/build.gradle
@@ -35,7 +35,7 @@
         minSdkVersion 24
         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
     }
-    testOptions.managedDevices.devices {
+    testOptions.managedDevices.allDevices {
         pixel6Api31(ManagedVirtualDevice) {
             device = "Pixel 6"
             apiLevel = 31
diff --git a/browser/browser/api/current.txt b/browser/browser/api/current.txt
index 8e2c2e1..96677b3 100644
--- a/browser/browser/api/current.txt
+++ b/browser/browser/api/current.txt
@@ -1,7 +1,22 @@
 // Signature format: 4.0
 package androidx.browser.auth {
 
+  public final class AuthTabColorSchemeParams {
+    method @ColorInt public Integer? getNavigationBarColor();
+    method @ColorInt public Integer? getNavigationBarDividerColor();
+    method @ColorInt public Integer? getToolbarColor();
+  }
+
+  public static final class AuthTabColorSchemeParams.Builder {
+    ctor public AuthTabColorSchemeParams.Builder();
+    method public androidx.browser.auth.AuthTabColorSchemeParams build();
+    method public androidx.browser.auth.AuthTabColorSchemeParams.Builder setNavigationBarColor(@ColorInt int);
+    method public androidx.browser.auth.AuthTabColorSchemeParams.Builder setNavigationBarDividerColor(@ColorInt int);
+    method public androidx.browser.auth.AuthTabColorSchemeParams.Builder setToolbarColor(@ColorInt int);
+  }
+
   @SuppressCompatibility @androidx.browser.auth.ExperimentalAuthTab public class AuthTabIntent {
+    method public static androidx.browser.auth.AuthTabColorSchemeParams getColorSchemeParams(android.content.Intent, @IntRange(from=androidx.browser.customtabs.CustomTabsIntent.COLOR_SCHEME_LIGHT, to=androidx.browser.customtabs.CustomTabsIntent.COLOR_SCHEME_DARK) int);
     method @SuppressCompatibility @androidx.browser.customtabs.ExperimentalEphemeralBrowsing public boolean isEphemeralBrowsingEnabled();
     method public void launch(androidx.activity.result.ActivityResultLauncher<android.content.Intent!>, android.net.Uri, String);
     method public void launch(androidx.activity.result.ActivityResultLauncher<android.content.Intent!>, android.net.Uri, String, String);
@@ -26,6 +41,9 @@
   public static final class AuthTabIntent.Builder {
     ctor public AuthTabIntent.Builder();
     method public androidx.browser.auth.AuthTabIntent build();
+    method public androidx.browser.auth.AuthTabIntent.Builder setColorScheme(@IntRange(from=androidx.browser.customtabs.CustomTabsIntent.COLOR_SCHEME_SYSTEM, to=androidx.browser.customtabs.CustomTabsIntent.COLOR_SCHEME_DARK) int);
+    method public androidx.browser.auth.AuthTabIntent.Builder setColorSchemeParams(@IntRange(from=androidx.browser.customtabs.CustomTabsIntent.COLOR_SCHEME_LIGHT, to=androidx.browser.customtabs.CustomTabsIntent.COLOR_SCHEME_DARK) int, androidx.browser.auth.AuthTabColorSchemeParams);
+    method public androidx.browser.auth.AuthTabIntent.Builder setDefaultColorSchemeParams(androidx.browser.auth.AuthTabColorSchemeParams);
     method @SuppressCompatibility @androidx.browser.customtabs.ExperimentalEphemeralBrowsing public androidx.browser.auth.AuthTabIntent.Builder setEphemeralBrowsingEnabled(boolean);
   }
 
diff --git a/browser/browser/api/restricted_current.txt b/browser/browser/api/restricted_current.txt
index 90363d2..0ead376 100644
--- a/browser/browser/api/restricted_current.txt
+++ b/browser/browser/api/restricted_current.txt
@@ -1,7 +1,22 @@
 // Signature format: 4.0
 package androidx.browser.auth {
 
+  public final class AuthTabColorSchemeParams {
+    method @ColorInt public Integer? getNavigationBarColor();
+    method @ColorInt public Integer? getNavigationBarDividerColor();
+    method @ColorInt public Integer? getToolbarColor();
+  }
+
+  public static final class AuthTabColorSchemeParams.Builder {
+    ctor public AuthTabColorSchemeParams.Builder();
+    method public androidx.browser.auth.AuthTabColorSchemeParams build();
+    method public androidx.browser.auth.AuthTabColorSchemeParams.Builder setNavigationBarColor(@ColorInt int);
+    method public androidx.browser.auth.AuthTabColorSchemeParams.Builder setNavigationBarDividerColor(@ColorInt int);
+    method public androidx.browser.auth.AuthTabColorSchemeParams.Builder setToolbarColor(@ColorInt int);
+  }
+
   @SuppressCompatibility @androidx.browser.auth.ExperimentalAuthTab public class AuthTabIntent {
+    method public static androidx.browser.auth.AuthTabColorSchemeParams getColorSchemeParams(android.content.Intent, @IntRange(from=androidx.browser.customtabs.CustomTabsIntent.COLOR_SCHEME_LIGHT, to=androidx.browser.customtabs.CustomTabsIntent.COLOR_SCHEME_DARK) int);
     method @SuppressCompatibility @androidx.browser.customtabs.ExperimentalEphemeralBrowsing public boolean isEphemeralBrowsingEnabled();
     method public void launch(androidx.activity.result.ActivityResultLauncher<android.content.Intent!>, android.net.Uri, String);
     method public void launch(androidx.activity.result.ActivityResultLauncher<android.content.Intent!>, android.net.Uri, String, String);
@@ -26,6 +41,9 @@
   public static final class AuthTabIntent.Builder {
     ctor public AuthTabIntent.Builder();
     method public androidx.browser.auth.AuthTabIntent build();
+    method public androidx.browser.auth.AuthTabIntent.Builder setColorScheme(@IntRange(from=androidx.browser.customtabs.CustomTabsIntent.COLOR_SCHEME_SYSTEM, to=androidx.browser.customtabs.CustomTabsIntent.COLOR_SCHEME_DARK) int);
+    method public androidx.browser.auth.AuthTabIntent.Builder setColorSchemeParams(@IntRange(from=androidx.browser.customtabs.CustomTabsIntent.COLOR_SCHEME_LIGHT, to=androidx.browser.customtabs.CustomTabsIntent.COLOR_SCHEME_DARK) int, androidx.browser.auth.AuthTabColorSchemeParams);
+    method public androidx.browser.auth.AuthTabIntent.Builder setDefaultColorSchemeParams(androidx.browser.auth.AuthTabColorSchemeParams);
     method @SuppressCompatibility @androidx.browser.customtabs.ExperimentalEphemeralBrowsing public androidx.browser.auth.AuthTabIntent.Builder setEphemeralBrowsingEnabled(boolean);
   }
 
diff --git a/browser/browser/src/main/java/androidx/browser/auth/AuthTabColorSchemeParams.java b/browser/browser/src/main/java/androidx/browser/auth/AuthTabColorSchemeParams.java
new file mode 100644
index 0000000..14df0bb
--- /dev/null
+++ b/browser/browser/src/main/java/androidx/browser/auth/AuthTabColorSchemeParams.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.browser.auth;
+
+import static androidx.browser.customtabs.CustomTabsIntent.EXTRA_NAVIGATION_BAR_COLOR;
+import static androidx.browser.customtabs.CustomTabsIntent.EXTRA_NAVIGATION_BAR_DIVIDER_COLOR;
+import static androidx.browser.customtabs.CustomTabsIntent.EXTRA_TOOLBAR_COLOR;
+
+import android.os.Bundle;
+
+import androidx.annotation.ColorInt;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+/**
+ * Contains visual parameters of an Auth Tab that may depend on the color scheme.
+ *
+ * @see AuthTabIntent.Builder#setColorSchemeParams(int, AuthTabColorSchemeParams)
+ */
+public final class AuthTabColorSchemeParams {
+    /** Toolbar color. */
+    @Nullable
+    @ColorInt
+    private final Integer mToolbarColor;
+
+    /** Navigation bar color. */
+    @Nullable
+    @ColorInt
+    private final Integer mNavigationBarColor;
+
+    /** Navigation bar divider color. */
+    @Nullable
+    @ColorInt
+    private final Integer mNavigationBarDividerColor;
+
+    private AuthTabColorSchemeParams(@Nullable @ColorInt Integer toolbarColor,
+            @Nullable @ColorInt Integer navigationBarColor,
+            @Nullable @ColorInt Integer navigationBarDividerColor) {
+        mToolbarColor = toolbarColor;
+        mNavigationBarColor = navigationBarColor;
+        mNavigationBarDividerColor = navigationBarDividerColor;
+    }
+
+    @SuppressWarnings("AutoBoxing")
+    @Nullable
+    @ColorInt
+    public Integer getToolbarColor() {
+        return mToolbarColor;
+    }
+
+    @SuppressWarnings("AutoBoxing")
+    @Nullable
+    @ColorInt
+    public Integer getNavigationBarColor() {
+        return mNavigationBarColor;
+    }
+
+    @SuppressWarnings("AutoBoxing")
+    @Nullable
+    @ColorInt
+    public Integer getNavigationBarDividerColor() {
+        return mNavigationBarDividerColor;
+    }
+
+    /**
+     * Packs the parameters into a {@link Bundle}.
+     * For backward compatibility and ease of use, the names of keys and the structure of the Bundle
+     * are the same as that of Intent extras in {@link CustomTabsIntent}.
+     */
+    @NonNull
+    Bundle toBundle() {
+        Bundle bundle = new Bundle();
+        if (mToolbarColor != null) {
+            bundle.putInt(EXTRA_TOOLBAR_COLOR, mToolbarColor);
+        }
+        if (mNavigationBarColor != null) {
+            bundle.putInt(EXTRA_NAVIGATION_BAR_COLOR, mNavigationBarColor);
+        }
+        if (mNavigationBarDividerColor != null) {
+            bundle.putInt(EXTRA_NAVIGATION_BAR_DIVIDER_COLOR, mNavigationBarDividerColor);
+        }
+        return bundle;
+    }
+
+    /**
+     * Unpacks parameters from a {@link Bundle}. Sets all parameters to null if provided bundle is
+     * null.
+     */
+    @NonNull
+    @SuppressWarnings("deprecation")
+    static AuthTabColorSchemeParams fromBundle(@Nullable Bundle bundle) {
+        if (bundle == null) {
+            bundle = new Bundle(0);
+        }
+        // Using bundle.get() instead of bundle.getInt() to default to null without calling
+        // bundle.containsKey().
+        return new AuthTabColorSchemeParams((Integer) bundle.get(EXTRA_TOOLBAR_COLOR),
+                (Integer) bundle.get(EXTRA_NAVIGATION_BAR_COLOR),
+                (Integer) bundle.get(EXTRA_NAVIGATION_BAR_DIVIDER_COLOR));
+    }
+
+    /**
+     * Returns a new {@link AuthTabColorSchemeParams} with the null fields replaced with the
+     * provided defaults.
+     */
+    @NonNull
+    AuthTabColorSchemeParams withDefaults(@NonNull AuthTabColorSchemeParams defaults) {
+        return new AuthTabColorSchemeParams(
+                mToolbarColor == null ? defaults.mToolbarColor : mToolbarColor,
+                mNavigationBarColor == null ? defaults.mNavigationBarColor : mNavigationBarColor,
+                mNavigationBarDividerColor == null ? defaults.mNavigationBarDividerColor
+                        : mNavigationBarDividerColor);
+    }
+
+    /**
+     * Builder class for {@link AuthTabColorSchemeParams} objects.
+     * The browser's default colors will be used for any unset value.
+     */
+    public static final class Builder {
+        @Nullable
+        @ColorInt
+        private Integer mToolbarColor;
+        @Nullable
+        @ColorInt
+        private Integer mNavigationBarColor;
+        @Nullable
+        @ColorInt
+        private Integer mNavigationBarDividerColor;
+
+        /**
+         * Sets the toolbar color.
+         *
+         * This color is also applied to the status bar. To ensure good contrast between status bar
+         * icons and the background, Auth Tab implementations may use
+         * {@link WindowInsetsController#APPEARANCE_LIGHT_STATUS_BARS}.
+         *
+         * @param color The color integer. The alpha value will be ignored.
+         */
+        @NonNull
+        public Builder setToolbarColor(@ColorInt int color) {
+            mToolbarColor = color | 0xff000000;
+            return this;
+        }
+
+        /**
+         * Sets the navigation bar color.
+         *
+         * To ensure good contrast between navigation bar icons and the background, Auth Tab
+         * implementations may use {@link WindowInsetsController#APPEARANCE_LIGHT_NAVIGATION_BARS}.
+         *
+         * @param color The color integer. The alpha value will be ignored.
+         */
+        @NonNull
+        public Builder setNavigationBarColor(@ColorInt int color) {
+            mNavigationBarColor = color | 0xff000000;
+            return this;
+        }
+
+        /**
+         * Sets the navigation bar divider color.
+         *
+         * @param color The color integer.
+         */
+        @NonNull
+        public Builder setNavigationBarDividerColor(@ColorInt int color) {
+            mNavigationBarDividerColor = color;
+            return this;
+        }
+
+        /**
+         * Combines all the options that have been set and returns a new
+         * {@link AuthTabColorSchemeParams} object.
+         */
+        @NonNull
+        public AuthTabColorSchemeParams build() {
+            return new AuthTabColorSchemeParams(mToolbarColor, mNavigationBarColor,
+                    mNavigationBarDividerColor);
+        }
+    }
+}
diff --git a/browser/browser/src/main/java/androidx/browser/auth/AuthTabIntent.java b/browser/browser/src/main/java/androidx/browser/auth/AuthTabIntent.java
index d1c4338..cd20b6e 100644
--- a/browser/browser/src/main/java/androidx/browser/auth/AuthTabIntent.java
+++ b/browser/browser/src/main/java/androidx/browser/auth/AuthTabIntent.java
@@ -16,6 +16,11 @@
 
 package androidx.browser.auth;
 
+import static androidx.browser.customtabs.CustomTabsIntent.COLOR_SCHEME_DARK;
+import static androidx.browser.customtabs.CustomTabsIntent.COLOR_SCHEME_LIGHT;
+import static androidx.browser.customtabs.CustomTabsIntent.COLOR_SCHEME_SYSTEM;
+import static androidx.browser.customtabs.CustomTabsIntent.EXTRA_COLOR_SCHEME;
+import static androidx.browser.customtabs.CustomTabsIntent.EXTRA_COLOR_SCHEME_PARAMS;
 import static androidx.browser.customtabs.CustomTabsIntent.EXTRA_ENABLE_EPHEMERAL_BROWSING;
 import static androidx.browser.customtabs.CustomTabsIntent.EXTRA_SESSION;
 
@@ -24,17 +29,20 @@
 import android.content.Intent;
 import android.net.Uri;
 import android.os.Bundle;
+import android.util.SparseArray;
 
 import androidx.activity.result.ActivityResultCallback;
 import androidx.activity.result.ActivityResultCaller;
 import androidx.activity.result.ActivityResultLauncher;
 import androidx.activity.result.contract.ActivityResultContract;
 import androidx.annotation.IntDef;
+import androidx.annotation.IntRange;
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import androidx.annotation.RestrictTo;
 import androidx.browser.customtabs.CustomTabsIntent;
 import androidx.browser.customtabs.ExperimentalEphemeralBrowsing;
+import androidx.core.os.BundleCompat;
 
 import java.lang.annotation.Retention;
 import java.lang.annotation.RetentionPolicy;
@@ -136,7 +144,8 @@
     public static final int RESULT_UNKNOWN_CODE = -2;
 
     /** An {@link Intent} used to start the Auth Tab Activity. */
-    @NonNull public final Intent intent;
+    @NonNull
+    public final Intent intent;
 
     /**
      * Launches an Auth Tab Activity. Must be used for flows that result in a redirect with a custom
@@ -182,6 +191,36 @@
         return intent.getBooleanExtra(EXTRA_ENABLE_EPHEMERAL_BROWSING, false);
     }
 
+    /**
+     * Retrieves the instance of {@link AuthTabColorSchemeParams} from an {@link Intent} for a given
+     * color scheme.
+     *
+     * @param intent      {@link Intent} to retrieve the color scheme params from.
+     * @param colorScheme A constant representing a color scheme. Must not be
+     *                    {@link #COLOR_SCHEME_SYSTEM}.
+     * @return An instance of {@link AuthTabColorSchemeParams} with retrieved params.
+     */
+    @NonNull
+    public static AuthTabColorSchemeParams getColorSchemeParams(@NonNull Intent intent,
+            @CustomTabsIntent.ColorScheme @IntRange(from = COLOR_SCHEME_LIGHT, to =
+                    COLOR_SCHEME_DARK) int colorScheme) {
+        Bundle extras = intent.getExtras();
+        if (extras == null) {
+            return AuthTabColorSchemeParams.fromBundle(null);
+        }
+
+        AuthTabColorSchemeParams defaults = AuthTabColorSchemeParams.fromBundle(extras);
+        SparseArray<Bundle> paramBundles = BundleCompat.getSparseParcelableArray(extras,
+                EXTRA_COLOR_SCHEME_PARAMS, Bundle.class);
+        if (paramBundles != null) {
+            Bundle bundleForScheme = paramBundles.get(colorScheme);
+            if (bundleForScheme != null) {
+                return AuthTabColorSchemeParams.fromBundle(bundleForScheme).withDefaults(defaults);
+            }
+        }
+        return defaults;
+    }
+
     private AuthTabIntent(@NonNull Intent intent) {
         this.intent = intent;
     }
@@ -191,6 +230,12 @@
      */
     public static final class Builder {
         private final Intent mIntent = new Intent(Intent.ACTION_VIEW);
+        private final AuthTabColorSchemeParams.Builder mDefaultColorSchemeBuilder =
+                new AuthTabColorSchemeParams.Builder();
+        @Nullable
+        private SparseArray<Bundle> mColorSchemeParamBundles;
+        @Nullable
+        private Bundle mDefaultColorSchemeBundle;
 
         public Builder() {
         }
@@ -211,6 +256,94 @@
         }
 
         /**
+         * Sets the color scheme that should be applied to the user interface in the Auth Tab.
+         *
+         * @param colorScheme Desired color scheme.
+         * @see CustomTabsIntent#COLOR_SCHEME_SYSTEM
+         * @see CustomTabsIntent#COLOR_SCHEME_LIGHT
+         * @see CustomTabsIntent#COLOR_SCHEME_DARK
+         */
+        @SuppressWarnings("MissingGetterMatchingBuilder")
+        @NonNull
+        public Builder setColorScheme(
+                @CustomTabsIntent.ColorScheme @IntRange(from = COLOR_SCHEME_SYSTEM, to =
+                        COLOR_SCHEME_DARK) int colorScheme) {
+            mIntent.putExtra(EXTRA_COLOR_SCHEME, colorScheme);
+            return this;
+        }
+
+        /**
+         * Sets {@link AuthTabColorSchemeParams} for the given color scheme.
+         *
+         * This allows specifying two different toolbar colors for light and dark schemes.
+         * It can be useful if {@link CustomTabsIntent#COLOR_SCHEME_SYSTEM} is set: the Auth Tab
+         * will follow the system settings and apply the corresponding
+         * {@link AuthTabColorSchemeParams} "on the fly" when the settings change.
+         *
+         * If there is no {@link AuthTabColorSchemeParams} for the current scheme, or a particular
+         * field of it is null, the Auth Tab will fall back to the defaults provided via
+         * {@link #setDefaultColorSchemeParams}.
+         *
+         * Example:
+         * <pre><code>
+         *     AuthTabColorSchemeParams darkParams = new AuthTabColorSchemeParams.Builder()
+         *             .setToolbarColor(darkColor)
+         *             .build();
+         *     AuthTabColorSchemeParams otherParams = new AuthTabColorSchemeParams.Builder()
+         *             .setNavigationBarColor(otherColor)
+         *             .build();
+         *     AuthTabIntent intent = new AuthTabIntent.Builder()
+         *             .setColorScheme(COLOR_SCHEME_SYSTEM)
+         *             .setColorSchemeParams(COLOR_SCHEME_DARK, darkParams)
+         *             .setDefaultColorSchemeParams(otherParams)
+         *             .build();
+         *
+         *     // Setting colors independently of color scheme
+         *     AuthTabColorSchemeParams params = new AuthTabColorSchemeParams.Builder()
+         *             .setToolbarColor(color)
+         *             .setNavigationBarColor(color)
+         *             .build();
+         *     AuthTabIntent intent = new AuthTabIntent.Builder()
+         *             .setDefaultColorSchemeParams(params)
+         *             .build();
+         * </code></pre>
+         *
+         * @param colorScheme A constant representing a color scheme (see {@link #setColorScheme}).
+         *                    It should not be {@link #COLOR_SCHEME_SYSTEM}, because that represents
+         *                    a behavior rather than a particular color scheme.
+         * @param params      An instance of {@link AuthTabColorSchemeParams}.
+         */
+        @SuppressWarnings("MissingGetterMatchingBuilder")
+        @NonNull
+        public AuthTabIntent.Builder setColorSchemeParams(
+                @CustomTabsIntent.ColorScheme @IntRange(from = COLOR_SCHEME_LIGHT, to =
+                        COLOR_SCHEME_DARK) int colorScheme,
+                @NonNull AuthTabColorSchemeParams params) {
+            if (mColorSchemeParamBundles == null) {
+                mColorSchemeParamBundles = new SparseArray<>();
+            }
+            mColorSchemeParamBundles.put(colorScheme, params.toBundle());
+            return this;
+        }
+
+        /**
+         * Sets the default {@link AuthTabColorSchemeParams}.
+         *
+         * This will set a default color scheme that applies when no
+         * {@link AuthTabColorSchemeParams} specified for current color scheme via
+         * {@link #setColorSchemeParams}.
+         *
+         * @param params An instance of {@link AuthTabColorSchemeParams}.
+         */
+        @SuppressWarnings("MissingGetterMatchingBuilder")
+        @NonNull
+        public AuthTabIntent.Builder setDefaultColorSchemeParams(
+                @NonNull AuthTabColorSchemeParams params) {
+            mDefaultColorSchemeBundle = params.toBundle();
+            return this;
+        }
+
+        /**
          * Combines all the options that have been set and returns a new {@link AuthTabIntent}
          * object.
          */
@@ -220,9 +353,23 @@
 
             // Put a null EXTRA_SESSION as a fallback so that this is interpreted as a Custom Tab
             // intent by browser implementations that don't support Auth Tab.
-            Bundle bundle = new Bundle();
-            bundle.putBinder(EXTRA_SESSION, null);
-            mIntent.putExtras(bundle);
+            {
+                Bundle bundle = new Bundle();
+                bundle.putBinder(EXTRA_SESSION, null);
+                mIntent.putExtras(bundle);
+            }
+
+            mIntent.putExtras(mDefaultColorSchemeBuilder.build().toBundle());
+            if (mDefaultColorSchemeBundle != null) {
+                mIntent.putExtras(mDefaultColorSchemeBundle);
+            }
+
+            if (mColorSchemeParamBundles != null) {
+                Bundle bundle = new Bundle();
+                bundle.putSparseParcelableArray(EXTRA_COLOR_SCHEME_PARAMS,
+                        mColorSchemeParamBundles);
+                mIntent.putExtras(bundle);
+            }
 
             return new AuthTabIntent(mIntent);
         }
diff --git a/browser/browser/src/test/java/androidx/browser/auth/AuthTabColorSchemeParamsTest.java b/browser/browser/src/test/java/androidx/browser/auth/AuthTabColorSchemeParamsTest.java
new file mode 100644
index 0000000..90e084a
--- /dev/null
+++ b/browser/browser/src/test/java/androidx/browser/auth/AuthTabColorSchemeParamsTest.java
@@ -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.browser.auth;
+
+import static androidx.browser.customtabs.CustomTabsIntent.COLOR_SCHEME_DARK;
+import static androidx.browser.customtabs.CustomTabsIntent.COLOR_SCHEME_LIGHT;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+
+import android.content.Intent;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.internal.DoNotInstrument;
+
+/** Tests for {@link AuthTabColorSchemeParams}. */
+@RunWith(RobolectricTestRunner.class)
+@DoNotInstrument
+public class AuthTabColorSchemeParamsTest {
+    @Test
+    public void testParamsForBothSchemes() {
+        AuthTabColorSchemeParams lightParams = new AuthTabColorSchemeParams.Builder()
+                .setToolbarColor(0x0000ff)
+                .setNavigationBarColor(0xff0000)
+                .setNavigationBarDividerColor(0x00ff00)
+                .build();
+
+        AuthTabColorSchemeParams darkParams = new AuthTabColorSchemeParams.Builder()
+                .setToolbarColor(0xff0000)
+                .setNavigationBarColor(0xffaa00)
+                .setNavigationBarDividerColor(0xffffff)
+                .build();
+
+        Intent intent = new AuthTabIntent.Builder()
+                .setColorSchemeParams(COLOR_SCHEME_LIGHT, lightParams)
+                .setColorSchemeParams(COLOR_SCHEME_DARK, darkParams)
+                .build().intent;
+
+        AuthTabColorSchemeParams lightParamsFromIntent = AuthTabIntent.getColorSchemeParams(intent,
+                COLOR_SCHEME_LIGHT);
+
+        AuthTabColorSchemeParams darkParamsFromIntent = AuthTabIntent.getColorSchemeParams(intent,
+                COLOR_SCHEME_DARK);
+
+        assertSchemeParamsEqual(lightParams, lightParamsFromIntent);
+        assertSchemeParamsEqual(darkParams, darkParamsFromIntent);
+    }
+
+    @Test
+    public void testWithDefaultsForOneScheme() {
+        int defaultToolbarColor = 0x0000ff;
+        int defaultNavigationBarColor = 0xaabbcc;
+        int defaultNavigationBarDividerColor = 0xdddddd;
+
+        AuthTabColorSchemeParams darkParams = new AuthTabColorSchemeParams.Builder()
+                .setToolbarColor(0xff0000)
+                .setNavigationBarColor(0xccbbaa)
+                .setNavigationBarDividerColor(0xffffff)
+                .build();
+
+        AuthTabColorSchemeParams defaultParams = new AuthTabColorSchemeParams.Builder()
+                .setToolbarColor(defaultToolbarColor)
+                .setNavigationBarColor(defaultNavigationBarColor)
+                .setNavigationBarDividerColor(defaultNavigationBarDividerColor)
+                .build();
+
+        Intent intent = new AuthTabIntent.Builder()
+                .setDefaultColorSchemeParams(defaultParams)
+                .setColorSchemeParams(COLOR_SCHEME_DARK, darkParams)
+                .build()
+                .intent;
+
+        AuthTabColorSchemeParams lightParamsFromIntent = AuthTabIntent.getColorSchemeParams(intent,
+                COLOR_SCHEME_LIGHT);
+
+        AuthTabColorSchemeParams darkParamsFromIntent = AuthTabIntent.getColorSchemeParams(intent,
+                COLOR_SCHEME_DARK);
+
+        assertSchemeParamsEqual(defaultParams, lightParamsFromIntent);
+        assertSchemeParamsEqual(darkParams, darkParamsFromIntent);
+    }
+
+    @Test
+    public void testParamsNotProvided() {
+        Intent intent = new AuthTabIntent.Builder().build().intent;
+
+        AuthTabColorSchemeParams lightParamsFromIntent = AuthTabIntent.getColorSchemeParams(intent,
+                COLOR_SCHEME_LIGHT);
+
+        AuthTabColorSchemeParams darkParamsFromIntent = AuthTabIntent.getColorSchemeParams(intent,
+                COLOR_SCHEME_DARK);
+
+        assertNull(lightParamsFromIntent.getToolbarColor());
+        assertNull(lightParamsFromIntent.getNavigationBarColor());
+        assertNull(lightParamsFromIntent.getNavigationBarDividerColor());
+
+        assertNull(darkParamsFromIntent.getToolbarColor());
+        assertNull(darkParamsFromIntent.getNavigationBarColor());
+        assertNull(darkParamsFromIntent.getNavigationBarDividerColor());
+    }
+
+    @Test
+    public void testColorsAreSolid() {
+        AuthTabColorSchemeParams params = new AuthTabColorSchemeParams.Builder()
+                .setToolbarColor(0x610000ff)
+                .setNavigationBarColor(0x88ff0000)
+                .setNavigationBarDividerColor(0x00ff00)
+                .build();
+
+        Intent intent = new AuthTabIntent.Builder()
+                .setDefaultColorSchemeParams(params)
+                .build()
+                .intent;
+
+        AuthTabColorSchemeParams paramsFromIntent = AuthTabIntent.getColorSchemeParams(intent,
+                COLOR_SCHEME_LIGHT);
+
+        assertEquals(0xff0000ff, paramsFromIntent.getToolbarColor().intValue());
+        assertEquals(0xffff0000, paramsFromIntent.getNavigationBarColor().intValue());
+    }
+
+    private void assertSchemeParamsEqual(AuthTabColorSchemeParams params1,
+            AuthTabColorSchemeParams params2) {
+        assertEquals(params1.getToolbarColor(), params2.getToolbarColor());
+        assertEquals(params1.getNavigationBarColor(), params2.getNavigationBarColor());
+        assertEquals(params1.getNavigationBarDividerColor(),
+                params2.getNavigationBarDividerColor());
+    }
+}
diff --git a/buildSrc/private/src/main/kotlin/androidx/build/AndroidXImplPlugin.kt b/buildSrc/private/src/main/kotlin/androidx/build/AndroidXImplPlugin.kt
index 5dc8ca3a..fe6521f 100644
--- a/buildSrc/private/src/main/kotlin/androidx/build/AndroidXImplPlugin.kt
+++ b/buildSrc/private/src/main/kotlin/androidx/build/AndroidXImplPlugin.kt
@@ -28,6 +28,7 @@
 import androidx.build.docs.CheckTipOfTreeDocsTask.Companion.setUpCheckDocsTask
 import androidx.build.gitclient.getHeadShaProvider
 import androidx.build.gradle.isRoot
+import androidx.build.kythe.configureProjectForKzipTasks
 import androidx.build.license.addLicensesToPublishedArtifacts
 import androidx.build.resources.CopyPublicResourcesDirTask
 import androidx.build.resources.configurePublicResourcesStub
@@ -694,6 +695,7 @@
         project.disableStrictVersionConstraints()
 
         project.configureProjectForApiTasks(AndroidMultiplatformApiTaskConfig, androidXExtension)
+        project.configureProjectForKzipTasks(AndroidMultiplatformApiTaskConfig, androidXExtension)
 
         kotlinMultiplatformAndroidComponentsExtension.onVariant { it.configureTests() }
 
@@ -933,6 +935,10 @@
                     LibraryApiTaskConfig(variant),
                     androidXExtension
                 )
+                project.configureProjectForKzipTasks(
+                    LibraryApiTaskConfig(variant),
+                    androidXExtension
+                )
             }
             if (variant.name == DEFAULT_PUBLISH_CONFIG) {
                 project.configureSourceJarForAndroid(variant, androidXExtension.samplesProjects)
@@ -973,22 +979,6 @@
         SdkResourceGenerator.generateForHostTest(project)
     }
 
-    private fun getDefaultTargetJavaVersion(
-        libraryType: LibraryType,
-        projectName: String? = null,
-        targetName: String? = null
-    ): JavaVersion {
-        return when {
-            // TODO(b/353328300): Move room-compiler-processing to Java 17 once Dagger is ready.
-            projectName != null && projectName.contains("room-compiler-processing") -> VERSION_11
-            projectName != null && projectName.contains("desktop") -> VERSION_11
-            targetName != null && (targetName == "desktop" || targetName == "jvmStubs") ->
-                VERSION_11
-            libraryType.compilationTarget == CompilationTarget.HOST -> VERSION_17
-            else -> VERSION_1_8
-        }
-    }
-
     private fun configureWithJavaPlugin(project: Project, androidXExtension: AndroidXExtension) {
         if (
             project.multiplatformExtension != null &&
@@ -1035,6 +1025,7 @@
             }
 
         project.configureProjectForApiTasks(apiTaskConfig, androidXExtension)
+        project.configureProjectForKzipTasks(apiTaskConfig, androidXExtension)
         project.setUpCheckDocsTask(androidXExtension)
 
         if (project.multiplatformExtension == null) {
@@ -1451,6 +1442,21 @@
     }
 }
 
+internal fun getDefaultTargetJavaVersion(
+    libraryType: LibraryType,
+    projectName: String? = null,
+    targetName: String? = null
+): JavaVersion {
+    return when {
+        // TODO(b/353328300): Move room-compiler-processing to Java 17 once Dagger is ready.
+        projectName != null && projectName.contains("room-compiler-processing") -> VERSION_11
+        projectName != null && projectName.contains("desktop") -> VERSION_11
+        targetName != null && (targetName == "desktop" || targetName == "jvmStubs") -> VERSION_11
+        libraryType.compilationTarget == CompilationTarget.HOST -> VERSION_17
+        else -> VERSION_1_8
+    }
+}
+
 private fun Project.validateLintVersionTestExists(androidXExtension: AndroidXExtension) {
     if (!androidXExtension.type.isLint()) {
         return
diff --git a/buildSrc/private/src/main/kotlin/androidx/build/binarycompatibilityvalidator/BinaryCompatibilityValidation.kt b/buildSrc/private/src/main/kotlin/androidx/build/binarycompatibilityvalidator/BinaryCompatibilityValidation.kt
index e11cf28..220083b 100644
--- a/buildSrc/private/src/main/kotlin/androidx/build/binarycompatibilityvalidator/BinaryCompatibilityValidation.kt
+++ b/buildSrc/private/src/main/kotlin/androidx/build/binarycompatibilityvalidator/BinaryCompatibilityValidation.kt
@@ -60,12 +60,14 @@
 private const val UPDATE_NAME = "updateAbi"
 private const val EXTRACT_NAME = "extractAbi"
 private const val EXTRACT_RELEASE_NAME = "extractAbiRelease"
+private const val IGNORE_CHANGES_NAME = "ignoreAbiChanges"
 
 private const val KLIB_DUMPS_DIRECTORY = "klib"
 private const val KLIB_MERGE_DIRECTORY = "merged"
 private const val KLIB_EXTRACTED_DIRECTORY = "extracted"
 private const val NATIVE_SUFFIX = "native"
 internal const val CURRENT_API_FILE_NAME = "current.txt"
+private const val IGNORE_FILE_NAME = "current.ignore"
 private const val ABI_GROUP_NAME = "abi"
 
 class BinaryCompatibilityValidation(
@@ -104,6 +106,7 @@
         }
         val projectVersion: Version = project.version()
         val projectAbiDir = project.getBcvFileDirectory().dir(NATIVE_SUFFIX)
+        val currentIgnoreFile = projectAbiDir.file(IGNORE_FILE_NAME)
         val buildAbiDir = project.getBuiltBcvFileDirectory().map { it.dir(NATIVE_SUFFIX) }
 
         val klibDumpDir = project.layout.buildDirectory.dir(KLIB_DUMPS_DIRECTORY)
@@ -128,7 +131,8 @@
             project.checkKlibAbiReleaseTask(
                 generatedAndMergedApiFile,
                 projectAbiDir,
-                klibExtractedFileDir
+                klibExtractedFileDir,
+                currentIgnoreFile
             )
 
         updateKlibAbi.configure { update ->
@@ -164,7 +168,8 @@
     private fun Project.checkKlibAbiReleaseTask(
         mergedApiFile: Provider<RegularFileProperty>,
         klibApiDir: Directory,
-        klibExtractDir: Provider<Directory>
+        klibExtractDir: Provider<Directory>,
+        ignoreFile: RegularFile,
     ) =
         project.getRequiredCompatibilityAbiLocation(NATIVE_SUFFIX)?.let { requiredCompatFile ->
             val extractReleaseTask =
@@ -181,6 +186,13 @@
                     it.outputAbiFile.set(klibExtractDir.map { it.file(requiredCompatFile.name) })
                     (it as DefaultTask).group = ABI_GROUP_NAME
                 }
+            project.tasks.register(IGNORE_CHANGES_NAME, IgnoreAbiChangesTask::class.java) {
+                it.currentApiDump.set(mergedApiFile.map { fileProperty -> fileProperty.get() })
+                it.previousApiDump.set(
+                    extractReleaseTask.map { extract -> extract.outputAbiFile.get() }
+                )
+                it.ignoreFile.set(ignoreFile)
+            }
             project.tasks
                 .register(CHECK_RELEASE_NAME, CheckAbiIsCompatibleTask::class.java) {
                     it.currentApiDump.set(mergedApiFile.map { fileProperty -> fileProperty.get() })
@@ -192,6 +204,7 @@
                         extractReleaseTask.map { extract ->
                             extract.outputAbiFile.get().asFile.nameWithoutExtension
                         }
+                    it.ignoreFile.set(ignoreFile)
                     it.group = ABI_GROUP_NAME
                 }
                 .also { checkRelease ->
diff --git a/buildSrc/private/src/main/kotlin/androidx/build/binarycompatibilityvalidator/CheckAbiIsCompatibleTask.kt b/buildSrc/private/src/main/kotlin/androidx/build/binarycompatibilityvalidator/CheckAbiIsCompatibleTask.kt
index c5c6509..2a61e7e 100644
--- a/buildSrc/private/src/main/kotlin/androidx/build/binarycompatibilityvalidator/CheckAbiIsCompatibleTask.kt
+++ b/buildSrc/private/src/main/kotlin/androidx/build/binarycompatibilityvalidator/CheckAbiIsCompatibleTask.kt
@@ -22,6 +22,7 @@
 import androidx.build.Version
 import androidx.build.metalava.shouldFreezeApis
 import androidx.build.metalava.summarizeDiff
+import java.io.File
 import org.gradle.api.DefaultTask
 import org.gradle.api.GradleException
 import org.gradle.api.file.RegularFileProperty
@@ -29,6 +30,8 @@
 import org.gradle.api.tasks.CacheableTask
 import org.gradle.api.tasks.Input
 import org.gradle.api.tasks.InputFile
+import org.gradle.api.tasks.Internal
+import org.gradle.api.tasks.Optional
 import org.gradle.api.tasks.PathSensitive
 import org.gradle.api.tasks.PathSensitivity
 import org.gradle.api.tasks.TaskAction
@@ -38,6 +41,9 @@
 @OptIn(ExperimentalLibraryAbiReader::class)
 abstract class CheckAbiIsCompatibleTask : DefaultTask() {
 
+    // Input annotation is handled by getIgnoreFile
+    @get:Internal abstract val ignoreFile: RegularFileProperty
+
     /** Text file from which API signatures will be read. */
     @get:PathSensitive(PathSensitivity.RELATIVE)
     @get:InputFile
@@ -51,6 +57,11 @@
 
     @get:Input abstract var projectVersion: Provider<String>
 
+    @PathSensitive(PathSensitivity.RELATIVE)
+    @InputFile
+    @Optional
+    fun getBaseline(): File? = ignoreFile.get().asFile.takeIf { it.exists() }
+
     @TaskAction
     fun execute() {
         val (previousApiPath, previousApiDumpText) =
@@ -67,7 +78,11 @@
         val currentDump = KlibDumpParser(currentApiDumpText, currentApiPath).parse()
 
         try {
-            BinaryCompatibilityChecker.checkAllBinariesAreCompatible(currentDump, previousDump)
+            BinaryCompatibilityChecker.checkAllBinariesAreCompatible(
+                currentDump,
+                previousDump,
+                getBaseline()
+            )
         } catch (e: ValidationException) {
             throw GradleException(compatErrorMessage(e), e)
         }
diff --git a/buildSrc/private/src/main/kotlin/androidx/build/binarycompatibilityvalidator/IgnoreAbiChangesTask.kt b/buildSrc/private/src/main/kotlin/androidx/build/binarycompatibilityvalidator/IgnoreAbiChangesTask.kt
new file mode 100644
index 0000000..073de55
--- /dev/null
+++ b/buildSrc/private/src/main/kotlin/androidx/build/binarycompatibilityvalidator/IgnoreAbiChangesTask.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.build.binarycompatibilityvalidator
+
+import androidx.binarycompatibilityvalidator.BinaryCompatibilityChecker
+import androidx.binarycompatibilityvalidator.KlibDumpParser
+import org.gradle.api.DefaultTask
+import org.gradle.api.file.RegularFileProperty
+import org.gradle.api.tasks.CacheableTask
+import org.gradle.api.tasks.InputFile
+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.jetbrains.kotlin.library.abi.ExperimentalLibraryAbiReader
+
+@OptIn(ExperimentalLibraryAbiReader::class)
+@CacheableTask
+abstract class IgnoreAbiChangesTask : DefaultTask() {
+
+    /** Text file from which API signatures will be read. */
+    @get:PathSensitive(PathSensitivity.RELATIVE)
+    @get:InputFile
+    abstract val previousApiDump: RegularFileProperty
+
+    @get:PathSensitive(PathSensitivity.RELATIVE)
+    @get:InputFile
+    abstract val currentApiDump: RegularFileProperty
+
+    @get:OutputFile abstract val ignoreFile: RegularFileProperty
+
+    @TaskAction
+    fun execute() {
+        val previousDump = KlibDumpParser(previousApiDump.get().asFile).parse()
+        val currentDump = KlibDumpParser(currentApiDump.get().asFile).parse()
+        val ignoredErrors =
+            BinaryCompatibilityChecker.checkAllBinariesAreCompatible(
+                    currentDump,
+                    previousDump,
+                    null,
+                    validate = false
+                )
+                .map { it.toString() }
+                .toSet()
+        ignoreFile.get().asFile.apply {
+            if (!exists()) {
+                createNewFile()
+            }
+            writeText(formatString + "\n" + ignoredErrors.joinToString("\n"))
+        }
+    }
+
+    private companion object {
+        const val BASELINE_FORMAT_VERSION = "1.0"
+        const val formatString = "// Baseline format: $BASELINE_FORMAT_VERSION"
+    }
+}
diff --git a/buildSrc/private/src/main/kotlin/androidx/build/checkapi/ApiTasks.kt b/buildSrc/private/src/main/kotlin/androidx/build/checkapi/ApiTasks.kt
index 73726b5..17d960d 100644
--- a/buildSrc/private/src/main/kotlin/androidx/build/checkapi/ApiTasks.kt
+++ b/buildSrc/private/src/main/kotlin/androidx/build/checkapi/ApiTasks.kt
@@ -30,10 +30,14 @@
 import androidx.build.stableaidl.setupWithStableAidlPlugin
 import androidx.build.version
 import com.android.build.api.artifact.SingleArtifact
+import com.android.build.api.attributes.BuildTypeAttr
 import com.android.build.api.variant.LibraryVariant
 import java.io.File
 import org.gradle.api.GradleException
 import org.gradle.api.Project
+import org.gradle.api.artifacts.Configuration
+import org.gradle.api.artifacts.type.ArtifactTypeDefinition
+import org.gradle.api.attributes.Usage
 import org.gradle.api.file.RegularFile
 import org.gradle.api.plugins.JavaPluginExtension
 import org.gradle.api.provider.Provider
@@ -158,37 +162,15 @@
                 listOf(currentApiLocation)
             }
 
-        val javaInputs: JavaCompileInputs
-        val androidManifest: Provider<RegularFile>?
-        when (config) {
-            is LibraryApiTaskConfig -> {
-                if (config.variant.name != Release.DEFAULT_PUBLISH_CONFIG) {
-                    return@afterEvaluate
-                }
-                javaInputs = JavaCompileInputs.fromLibraryVariant(config.variant, project)
-                androidManifest = config.variant.artifacts.get(SingleArtifact.MERGED_MANIFEST)
-            }
-            is AndroidMultiplatformApiTaskConfig -> {
-                javaInputs = JavaCompileInputs.fromKmpAndroidTarget(project)
-                androidManifest = null
-            }
-            is KmpApiTaskConfig -> {
-                javaInputs = JavaCompileInputs.fromKmpJvmTarget(project)
-                androidManifest = null
-            }
-            is JavaApiTaskConfig -> {
-                val javaExtension = extensions.getByType<JavaPluginExtension>()
-                val mainSourceSet = javaExtension.sourceSets.getByName("main")
-                javaInputs = JavaCompileInputs.fromSourceSet(mainSourceSet, this)
-                androidManifest = null
-            }
-        }
-
+        val (javaInputs, androidManifest) =
+            configureJavaInputsAndManifest(config) ?: return@afterEvaluate
         val baselinesApiLocation = ApiBaselinesLocation.fromApiLocation(currentApiLocation)
+        val generateApiDependencies = createReleaseApiConfiguration()
 
         MetalavaTasks.setupProject(
             project,
             javaInputs,
+            generateApiDependencies,
             extension,
             androidManifest,
             baselinesApiLocation,
@@ -222,6 +204,53 @@
     }
 }
 
+internal fun Project.configureJavaInputsAndManifest(
+    config: ApiTaskConfig
+): Pair<JavaCompileInputs, Provider<RegularFile>?>? {
+    return when (config) {
+        is LibraryApiTaskConfig -> {
+            if (config.variant.name != Release.DEFAULT_PUBLISH_CONFIG) {
+                return null
+            }
+            JavaCompileInputs.fromLibraryVariant(config.variant, project) to
+                config.variant.artifacts.get(SingleArtifact.MERGED_MANIFEST)
+        }
+        is AndroidMultiplatformApiTaskConfig -> {
+            JavaCompileInputs.fromKmpAndroidTarget(project) to null
+        }
+        is KmpApiTaskConfig -> {
+            JavaCompileInputs.fromKmpJvmTarget(project) to null
+        }
+        is JavaApiTaskConfig -> {
+            val javaExtension = extensions.getByType<JavaPluginExtension>()
+            val mainSourceSet = javaExtension.sourceSets.getByName("main")
+            JavaCompileInputs.fromSourceSet(mainSourceSet, this) to null
+        }
+    }
+}
+
+internal fun Project.createReleaseApiConfiguration(): Configuration {
+    return configurations.findByName("ReleaseApiDependencies")
+        ?: configurations
+            .create("ReleaseApiDependencies") {
+                it.isCanBeConsumed = false
+                it.isTransitive = false
+                it.attributes.attribute(
+                    BuildTypeAttr.ATTRIBUTE,
+                    project.objects.named(BuildTypeAttr::class.java, "release")
+                )
+                it.attributes.attribute(
+                    Usage.USAGE_ATTRIBUTE,
+                    objects.named(Usage::class.java, Usage.JAVA_API)
+                )
+                it.attributes.attribute(
+                    ArtifactTypeDefinition.ARTIFACT_TYPE_ATTRIBUTE,
+                    ArtifactTypeDefinition.JAR_TYPE
+                )
+            }
+            .apply { project.dependencies.add(name, project.project(path)) }
+}
+
 internal class BlankApiRegularFile(project: Project) : RegularFile {
     val file = File(project.getSupportRootFolder(), "buildSrc/blank-res-api/public.txt")
 
diff --git a/buildSrc/private/src/main/kotlin/androidx/build/kythe/GenerateKotlinKzipTask.kt b/buildSrc/private/src/main/kotlin/androidx/build/kythe/GenerateKotlinKzipTask.kt
new file mode 100644
index 0000000..5a60bf6
--- /dev/null
+++ b/buildSrc/private/src/main/kotlin/androidx/build/kythe/GenerateKotlinKzipTask.kt
@@ -0,0 +1,197 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR 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.kythe
+
+import androidx.build.KotlinTarget
+import androidx.build.OperatingSystem
+import androidx.build.addToBuildOnServer
+import androidx.build.getCheckoutRoot
+import androidx.build.getOperatingSystem
+import androidx.build.getPrebuiltsRoot
+import androidx.build.getSupportRootFolder
+import androidx.build.java.JavaCompileInputs
+import java.io.File
+import javax.inject.Inject
+import org.gradle.api.DefaultTask
+import org.gradle.api.JavaVersion
+import org.gradle.api.Project
+import org.gradle.api.artifacts.Configuration
+import org.gradle.api.file.ConfigurableFileCollection
+import org.gradle.api.file.RegularFileProperty
+import org.gradle.api.provider.Property
+import org.gradle.api.tasks.CacheableTask
+import org.gradle.api.tasks.Classpath
+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.OutputFile
+import org.gradle.api.tasks.PathSensitive
+import org.gradle.api.tasks.PathSensitivity
+import org.gradle.api.tasks.TaskAction
+import org.gradle.process.ExecOperations
+import org.jetbrains.kotlin.gradle.dsl.JvmTarget
+
+/** Generates kzip files that are used to index the Kotlin source code in Kythe. */
+@CacheableTask
+abstract class GenerateKotlinKzipTask
+@Inject
+constructor(private val execOperations: ExecOperations) : DefaultTask() {
+
+    @get:InputFile
+    @get:PathSensitive(PathSensitivity.NONE)
+    abstract val kotlincExtractorBin: RegularFileProperty
+
+    /** Must be run in the checkout root so as to be free of relative markers */
+    @get:Internal val checkoutRoot: File = project.getCheckoutRoot()
+
+    @get:InputFiles
+    @get:PathSensitive(PathSensitivity.RELATIVE)
+    abstract val sourcePaths: ConfigurableFileCollection
+
+    @get:InputFiles
+    @get:PathSensitive(PathSensitivity.RELATIVE)
+    abstract val commonModuleSourcePaths: ConfigurableFileCollection
+
+    /** Path to `vnames.json` file, used for name mappings within Kythe. */
+    @get:InputFiles
+    @get:PathSensitive(PathSensitivity.NONE)
+    abstract val vnamesJson: RegularFileProperty
+
+    @get:Classpath abstract val dependencyClasspath: ConfigurableFileCollection
+
+    @get:Classpath abstract val compiledSources: ConfigurableFileCollection
+
+    @get:Input abstract val kotlinTarget: Property<KotlinTarget>
+
+    @get:Input abstract val jvmTarget: Property<JvmTarget>
+
+    @get:OutputFile abstract val kzipOutputFile: RegularFileProperty
+
+    @TaskAction
+    fun exec() {
+        val sourceFiles =
+            sourcePaths.asFileTree.files
+                .takeIf { files -> files.any { it.extension == "kt" } }
+                ?.filter { it.extension == "kt" || it.extension == "java" }
+                ?.map { it.relativeTo(checkoutRoot) }
+                .orEmpty()
+
+        if (sourceFiles.isEmpty()) {
+            return
+        }
+
+        val args =
+            listOf(
+                "-jvm-target",
+                jvmTarget.get().target,
+                "-Xjdk-release",
+                jvmTarget.get().target,
+                "-Xjvm-default=all",
+                "-language-version",
+                kotlinTarget.get().apiVersion.version,
+                "-api-version",
+                kotlinTarget.get().apiVersion.version,
+                "-no-reflect",
+                "-no-stdlib",
+                "-opt-in=androidx.room.compiler.processing.ExperimentalProcessingApi",
+                "-opt-in=com.squareup.kotlinpoet.javapoet.KotlinPoetJavaPoetPreview",
+                "-opt-in=kotlin.contracts.ExperimentalContracts",
+            )
+
+        val commonSourceFiles =
+            commonModuleSourcePaths.asFileTree.files
+                .filter { it.extension == "kt" || it.extension == "java" }
+                .map { it.relativeTo(checkoutRoot) }
+
+        val commonSourcesArgs =
+            if (commonSourceFiles.isNotEmpty()) {
+                listOf("-Xmulti-platform", "-Xexpect-actual-classes")
+            } else emptyList()
+
+        val command = buildList {
+            add(kotlincExtractorBin.get().asFile)
+            addAll(
+                listOf(
+                    "-corpus",
+                    "android.googlesource.com/platform/frameworks/support//androidx-main",
+                    "-kotlin_out",
+                    compiledSources.singleFile.relativeTo(checkoutRoot).path,
+                    "-o",
+                    kzipOutputFile.get().asFile.relativeTo(checkoutRoot).path,
+                    "-vnames",
+                    vnamesJson.get().asFile.relativeTo(checkoutRoot).path,
+                    "-args",
+                    (args + commonSourcesArgs).joinToString(" ")
+                )
+            )
+            sourceFiles.forEach { file -> addAll(listOf("-srcs", file.path)) }
+            commonSourceFiles.forEach { file -> addAll(listOf("-common_srcs", file.path)) }
+            dependencyClasspath.files
+                .map { it.relativeTo(checkoutRoot).path }
+                .forEach { file -> addAll(listOf("-cp", file)) }
+        }
+
+        execOperations.exec {
+            it.commandLine(command)
+            it.workingDir = checkoutRoot
+        }
+    }
+
+    companion object {
+        fun setupProject(
+            project: Project,
+            javaInputs: JavaCompileInputs,
+            compiledSources: Configuration,
+            kotlinTarget: Property<KotlinTarget>,
+            javaVersion: JavaVersion,
+        ) {
+            project.tasks
+                .register("generateKotlinKzip", GenerateKotlinKzipTask::class.java) { task ->
+                    task.apply {
+                        kotlincExtractorBin.set(
+                            File(
+                                project.getPrebuiltsRoot(),
+                                "build-tools/${osName()}/bin/kotlinc_extractor"
+                            )
+                        )
+                        sourcePaths.setFrom(javaInputs.sourcePaths)
+                        commonModuleSourcePaths.from(javaInputs.commonModuleSourcePaths)
+                        vnamesJson.set(File(project.getSupportRootFolder(), "buildSrc/vnames.json"))
+                        dependencyClasspath.setFrom(javaInputs.dependencyClasspath)
+                        this.compiledSources.setFrom(compiledSources)
+                        this.kotlinTarget.set(kotlinTarget)
+                        jvmTarget.set(JvmTarget.fromTarget(javaVersion.toString()))
+                        kzipOutputFile.set(
+                            File(
+                                project.layout.buildDirectory.get().asFile,
+                                "kzips/${project.group}-${project.name}.kotlin.kzip"
+                            )
+                        )
+                    }
+                }
+                .also { project.addToBuildOnServer(it) }
+        }
+    }
+}
+
+private fun osName() =
+    when (getOperatingSystem()) {
+        OperatingSystem.LINUX -> "linux-x86"
+        OperatingSystem.MAC -> "darwin-x86"
+        OperatingSystem.WINDOWS -> error("Kzip generation not supported in Windows")
+    }
diff --git a/buildSrc/private/src/main/kotlin/androidx/build/kythe/KzipTasks.kt b/buildSrc/private/src/main/kotlin/androidx/build/kythe/KzipTasks.kt
new file mode 100644
index 0000000..e9a90d0
--- /dev/null
+++ b/buildSrc/private/src/main/kotlin/androidx/build/kythe/KzipTasks.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.build.kythe
+
+import androidx.build.AndroidXExtension
+import androidx.build.checkapi.ApiTaskConfig
+import androidx.build.checkapi.configureJavaInputsAndManifest
+import androidx.build.checkapi.createReleaseApiConfiguration
+import androidx.build.getDefaultTargetJavaVersion
+import org.gradle.api.Project
+
+fun Project.configureProjectForKzipTasks(config: ApiTaskConfig, extension: AndroidXExtension) =
+    // afterEvaluate required to read extension properties
+    afterEvaluate {
+        val (javaInputs, _) = configureJavaInputsAndManifest(config) ?: return@afterEvaluate
+        val generateApiDependencies = createReleaseApiConfiguration()
+
+        GenerateKotlinKzipTask.setupProject(
+            project,
+            javaInputs,
+            generateApiDependencies,
+            extension.kotlinTarget,
+            getDefaultTargetJavaVersion(extension.type, project.name)
+        )
+    }
diff --git a/buildSrc/private/src/main/kotlin/androidx/build/metalava/MetalavaTasks.kt b/buildSrc/private/src/main/kotlin/androidx/build/metalava/MetalavaTasks.kt
index 1f32b4e..f385c0f 100644
--- a/buildSrc/private/src/main/kotlin/androidx/build/metalava/MetalavaTasks.kt
+++ b/buildSrc/private/src/main/kotlin/androidx/build/metalava/MetalavaTasks.kt
@@ -26,11 +26,8 @@
 import androidx.build.java.JavaCompileInputs
 import androidx.build.uptodatedness.cacheEvenIfNoOutputs
 import androidx.build.version
-import com.android.build.api.attributes.BuildTypeAttr
 import org.gradle.api.Project
 import org.gradle.api.artifacts.Configuration
-import org.gradle.api.artifacts.type.ArtifactTypeDefinition
-import org.gradle.api.attributes.Usage
 import org.gradle.api.file.RegularFile
 import org.gradle.api.provider.Provider
 import org.gradle.api.tasks.TaskProvider
@@ -41,31 +38,13 @@
     fun setupProject(
         project: Project,
         javaCompileInputs: JavaCompileInputs,
+        generateApiDependencies: Configuration,
         extension: AndroidXExtension,
         androidManifest: Provider<RegularFile>?,
         baselinesApiLocation: ApiBaselinesLocation,
         builtApiLocation: ApiLocation,
         outputApiLocations: List<ApiLocation>
     ) {
-        val generateApiDependencies =
-            project.configurations.create("GenerateApiDependencies") {
-                it.isCanBeConsumed = false
-                it.isTransitive = false
-                it.attributes.attribute(
-                    BuildTypeAttr.ATTRIBUTE,
-                    project.objects.named(BuildTypeAttr::class.java, "debug")
-                )
-                it.attributes.attribute(
-                    Usage.USAGE_ATTRIBUTE,
-                    project.objects.named(Usage::class.java, Usage.JAVA_API)
-                )
-                it.attributes.attribute(
-                    ArtifactTypeDefinition.ARTIFACT_TYPE_ATTRIBUTE,
-                    ArtifactTypeDefinition.JAR_TYPE
-                )
-            }
-        project.dependencies.add(generateApiDependencies.name, project.project(project.path))
-
         val metalavaClasspath = project.getMetalavaClasspath()
         val version = project.version()
 
diff --git a/buildSrc/vnames.json b/buildSrc/vnames.json
new file mode 100644
index 0000000..f570a8e
--- /dev/null
+++ b/buildSrc/vnames.json
@@ -0,0 +1,35 @@
+[
+  {
+    "pattern": "out/(.*)",
+    "vname": {
+      "root": "out",
+      "path": "@1@"
+    }
+  },
+  {
+    "pattern": "prebuilts/androidx/external/(.*)",
+    "vname": {
+      "corpus": "android.googlesource.com/platform/prebuilts/androidx/external//androidx-main",
+      "path": "@1@"
+    }
+  },
+  {
+    "pattern": "prebuilts/androidx/internal/(.*)",
+    "vname": {
+      "corpus": "android.googlesource.com/platform/prebuilts/androidx/internal//androidx-main",
+      "path": "@1@"
+    }
+  },
+  {
+    "pattern": "frameworks/support/(.*)",
+    "vname": {
+      "path": "@1@"
+    }
+  },
+  {
+    "pattern": "(.*)",
+    "vname": {
+      "path": "@1@"
+    }
+  }
+]
\ No newline at end of file
diff --git a/busytown/androidx.sh b/busytown/androidx.sh
index 81e85ab..8c59791 100755
--- a/busytown/androidx.sh
+++ b/busytown/androidx.sh
@@ -25,6 +25,9 @@
       -Pandroidx.constraints=true \
       --no-daemon "$@"; then
     EXIT_VALUE=1
+  else
+    # Run merge-kzips only if Gradle succeeds. Script merges kzips outputted by bOS task
+    busytown/impl/merge-kzips.sh || EXIT_VALUE=1
   fi
 
   # Parse performance profile reports (generated with the --profile option) and re-export
diff --git a/busytown/impl/merge-kzips.sh b/busytown/impl/merge-kzips.sh
new file mode 100755
index 0000000..100f04f
--- /dev/null
+++ b/busytown/impl/merge-kzips.sh
@@ -0,0 +1,60 @@
+#!/bin/bash
+#
+# Copyright 2024 The Android Open Source Project
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+#
+# Merge kzip files (source files for the indexing pipeline) for the given configuration, and place
+# the resulting all.kzip into $DIST_DIR.
+# Most code from:
+# https://cs.android.com/android/platform/superproject/main/+/main:build/soong/build_kzip.bash;
+
+set -e
+
+# Absolute path of the directory where this script lives
+SCRIPT_DIR="$(cd $(dirname $0) && pwd)"
+
+PREBUILTS_DIR=$SCRIPT_DIR/../../../../prebuilts
+
+cd "$SCRIPT_DIR/../.."
+if [ "$OUT_DIR" == "" ]; then
+  OUT_DIR="../../out"
+fi
+mkdir -p "$OUT_DIR"
+export OUT_DIR="$(cd $OUT_DIR && pwd)"
+if [ "$DIST_DIR" == "" ]; then
+  DIST_DIR="$OUT_DIR/dist"
+fi
+mkdir -p "$DIST_DIR"
+export DIST_DIR="$DIST_DIR"
+
+# Default KZIP_NAME to the latest Git commit hash
+: ${KZIP_NAME:=$(git rev-parse HEAD)}
+
+# Fallback to a UUID if Git commit hash is not there
+: ${KZIP_NAME:=$(uuidgen)}
+
+
+rm -rf $DIST_DIR/*.kzip
+declare -r allkzip="$KZIP_NAME.kzip"
+echo "Merging Kzips..."
+
+# Determine the directory based on OS
+if [[ "$(uname)" == "Darwin" ]]; then
+  BUILD_TOOLS_DIR="$PREBUILTS_DIR/build-tools/darwin-x86/bin"
+else
+  BUILD_TOOLS_DIR="$PREBUILTS_DIR/build-tools/linux-x86/bin"
+fi
+
+"$BUILD_TOOLS_DIR/merge_zips" "$DIST_DIR/$allkzip" @<(find "$OUT_DIR/androidx" -name '*.kzip')
diff --git a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/compat/quirk/CloseCameraDeviceOnCameraGraphCloseQuirk.kt b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/compat/quirk/CloseCameraDeviceOnCameraGraphCloseQuirk.kt
index 1d037c7..d63e7df 100644
--- a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/compat/quirk/CloseCameraDeviceOnCameraGraphCloseQuirk.kt
+++ b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/compat/quirk/CloseCameraDeviceOnCameraGraphCloseQuirk.kt
@@ -21,13 +21,13 @@
 import androidx.camera.core.impl.Quirk
 
 /**
- * Quirk needed on devices where not closing the camera device before creating a new capture session
- * can lead to undesirable behaviors, such as native camera HAL crashes. On Exynos7870 platforms for
- * example, once their 3A pipeline times out, recreating a capture session has a high chance of
- * triggering use-after-free crashes.
+ * Quirk needed on devices where not closing the camera device can lead to undesirable behaviors,
+ * such as switching to a new session without closing the camera device may cause native camera HAL
+ * crashes, or the app getting "frozen" while CameraPipe awaits on a 1s cooldown to finally close
+ * the camera device.
  *
  * QuirkSummary
- * - Bug Id: 282871038
+ * - Bug Id: 282871038, 369300443
  * - Description: Instructs CameraPipe to close the camera device before creating a new capture
  *   session to avoid undesirable behaviors
  *
@@ -38,7 +38,21 @@
     public companion object {
         @JvmStatic
         public fun isEnabled(): Boolean {
-            return Build.HARDWARE == "samsungexynos7870"
+            if (Build.HARDWARE == "samsungexynos7870") {
+                // On Exynos7870 platforms, when their 3A pipeline times out, recreating a capture
+                // session has a high chance of triggering use-after-free crashes. Closing the
+                // camera device helps reduce the likelihood of this happening.
+                return true
+            } else if (
+                Build.VERSION.SDK_INT in Build.VERSION_CODES.R..Build.VERSION_CODES.TIRAMISU &&
+                    (Device.isOppoDevice() || Device.isOnePlusDevice() || Device.isRealmeDevice())
+            ) {
+                // On Oppo-family devices from Android 11 to Android 13, a process called
+                // OplusHansManager actively "freezes" app processes, which means we cannot delay
+                // closing the camera device for any amount of time.
+                return true
+            }
+            return false
         }
     }
 }
diff --git a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/compat/quirk/Device.kt b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/compat/quirk/Device.kt
index 908088f..9903ea5 100644
--- a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/compat/quirk/Device.kt
+++ b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/compat/quirk/Device.kt
@@ -39,6 +39,8 @@
 
     public fun isPositivoDevice(): Boolean = isDeviceFrom("Positivo")
 
+    public fun isRealmeDevice(): Boolean = isDeviceFrom("Realme")
+
     public fun isRedmiDevice(): Boolean = isDeviceFrom("Redmi")
 
     public fun isSamsungDevice(): Boolean = isDeviceFrom("Samsung")
diff --git a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/compat/workaround/SizeFilteredEncoderProfilesProvider.kt b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/compat/workaround/SizeFilteredEncoderProfilesProvider.kt
new file mode 100644
index 0000000..255a475
--- /dev/null
+++ b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/compat/workaround/SizeFilteredEncoderProfilesProvider.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.camera.camera2.pipe.integration.compat.workaround
+
+import android.media.CamcorderProfile.QUALITY_HIGH
+import android.media.CamcorderProfile.QUALITY_LOW
+import android.util.Size
+import androidx.camera.core.impl.EncoderProfilesProvider
+import androidx.camera.core.impl.EncoderProfilesProvider.QUALITY_HIGH_TO_LOW
+import androidx.camera.core.impl.EncoderProfilesProxy
+
+/**
+ * An [EncoderProfilesProvider] that filters profiles based on supported sizes.
+ *
+ * This class wraps another [EncoderProfilesProvider] and filters its output to only include
+ * profiles with resolutions that are supported by the camera, as indicated by the provided list of
+ * [supportedSizes].
+ */
+public class SizeFilteredEncoderProfilesProvider(
+    /** The original [EncoderProfilesProvider] to wrap. */
+    private val provider: EncoderProfilesProvider,
+
+    /** The list of supported sizes. */
+    private val supportedSizes: List<Size>
+) : EncoderProfilesProvider {
+
+    private val encoderProfilesCache = mutableMapOf<Int, EncoderProfilesProxy?>()
+
+    override fun hasProfile(quality: Int): Boolean {
+        return getAll(quality) != null
+    }
+
+    override fun getAll(quality: Int): EncoderProfilesProxy? {
+        if (!provider.hasProfile(quality)) {
+            return null
+        }
+
+        if (encoderProfilesCache.containsKey(quality)) {
+            return encoderProfilesCache[quality]
+        }
+
+        var profiles = provider.getAll(quality)
+        if (profiles != null && !isResolutionSupported(profiles)) {
+            profiles =
+                when (quality) {
+                    QUALITY_HIGH -> findFirstAvailableProfile(QUALITY_HIGH_TO_LOW)
+                    QUALITY_LOW -> findFirstAvailableProfile(QUALITY_HIGH_TO_LOW.reversed())
+                    else -> null
+                }
+        }
+
+        encoderProfilesCache[quality] = profiles
+        return profiles
+    }
+
+    /**
+     * Checks if the resolution of the given [EncoderProfilesProxy] is supported.
+     *
+     * @param profiles The [EncoderProfilesProxy] to check.
+     * @return `true` if the resolution is supported, `false` otherwise.
+     */
+    private fun isResolutionSupported(profiles: EncoderProfilesProxy): Boolean {
+        if (supportedSizes.isEmpty() || profiles.videoProfiles.isEmpty()) {
+            return false
+        }
+
+        // cts/CamcorderProfileTest.java ensures all video profiles have the same size so we just
+        // need to check the first video profile.
+        val videoProfile = profiles.videoProfiles[0]
+        return supportedSizes.contains(Size(videoProfile.width, videoProfile.height))
+    }
+
+    /**
+     * Finds the first available profile based on the given quality order.
+     *
+     * This method iterates through the provided [qualityOrder] and returns the first profile that
+     * is available.
+     *
+     * @param qualityOrder The order of qualities to search.
+     * @return The first available [EncoderProfilesProxy], or `null` if no suitable profile is
+     *   found.
+     */
+    private fun findFirstAvailableProfile(qualityOrder: List<Int>): EncoderProfilesProxy? {
+        for (quality in qualityOrder) {
+            getAll(quality)?.let {
+                return it
+            }
+        }
+        return null
+    }
+}
diff --git a/camera/camera-camera2-pipe-integration/src/test/java/androidx/camera/camera2/pipe/integration/compat/workaround/SizeFilteredEncoderProfilesProviderTest.kt b/camera/camera-camera2-pipe-integration/src/test/java/androidx/camera/camera2/pipe/integration/compat/workaround/SizeFilteredEncoderProfilesProviderTest.kt
new file mode 100644
index 0000000..ceccd91
--- /dev/null
+++ b/camera/camera-camera2-pipe-integration/src/test/java/androidx/camera/camera2/pipe/integration/compat/workaround/SizeFilteredEncoderProfilesProviderTest.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.camera.camera2.pipe.integration.compat.workaround
+
+import android.media.CamcorderProfile.QUALITY_1080P
+import android.media.CamcorderProfile.QUALITY_2160P
+import android.media.CamcorderProfile.QUALITY_480P
+import android.media.CamcorderProfile.QUALITY_720P
+import android.media.CamcorderProfile.QUALITY_HIGH
+import android.media.CamcorderProfile.QUALITY_LOW
+import android.os.Build
+import androidx.camera.testing.impl.EncoderProfilesUtil.PROFILES_1080P
+import androidx.camera.testing.impl.EncoderProfilesUtil.PROFILES_2160P
+import androidx.camera.testing.impl.EncoderProfilesUtil.PROFILES_480P
+import androidx.camera.testing.impl.EncoderProfilesUtil.PROFILES_720P
+import androidx.camera.testing.impl.EncoderProfilesUtil.RESOLUTION_1080P
+import androidx.camera.testing.impl.EncoderProfilesUtil.RESOLUTION_720P
+import androidx.camera.testing.impl.fakes.FakeEncoderProfilesProvider
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import org.robolectric.annotation.internal.DoNotInstrument
+
+@RunWith(RobolectricTestRunner::class)
+@DoNotInstrument
+@Config(minSdk = Build.VERSION_CODES.LOLLIPOP)
+class SizeFilteredEncoderProfilesProviderTest {
+
+    private val profilesProvider =
+        FakeEncoderProfilesProvider.Builder()
+            .add(QUALITY_HIGH, PROFILES_2160P)
+            .add(QUALITY_2160P, PROFILES_2160P)
+            .add(QUALITY_1080P, PROFILES_1080P)
+            .add(QUALITY_720P, PROFILES_720P)
+            .add(QUALITY_480P, PROFILES_480P)
+            .add(QUALITY_LOW, PROFILES_480P)
+            .build()
+
+    private val supportedSizes = listOf(RESOLUTION_1080P, RESOLUTION_720P)
+
+    private val sizeFilteredProvider =
+        SizeFilteredEncoderProfilesProvider(profilesProvider, supportedSizes)
+
+    @Test
+    fun quality_shouldBeFilteredBySupportedSizes() {
+        assertThat(sizeFilteredProvider.hasProfile(QUALITY_2160P)).isFalse()
+        assertThat(sizeFilteredProvider.getAll(QUALITY_2160P)).isNull()
+
+        assertThat(sizeFilteredProvider.hasProfile(QUALITY_2160P)).isFalse()
+        assertThat(sizeFilteredProvider.getAll(QUALITY_2160P)).isNull()
+        assertThat(sizeFilteredProvider.hasProfile(QUALITY_1080P)).isTrue()
+        assertThat(sizeFilteredProvider.getAll(QUALITY_1080P)).isSameInstanceAs(PROFILES_1080P)
+        assertThat(sizeFilteredProvider.hasProfile(QUALITY_720P)).isTrue()
+        assertThat(sizeFilteredProvider.getAll(QUALITY_720P)).isSameInstanceAs(PROFILES_720P)
+        assertThat(sizeFilteredProvider.hasProfile(QUALITY_480P)).isFalse()
+        assertThat(sizeFilteredProvider.getAll(QUALITY_480P)).isNull()
+    }
+
+    @Test
+    fun qualityHighLow_shouldMapToCorrectProfiles() {
+        assertThat(sizeFilteredProvider.hasProfile(QUALITY_HIGH)).isTrue()
+        assertThat(sizeFilteredProvider.getAll(QUALITY_HIGH)).isSameInstanceAs(PROFILES_1080P)
+
+        assertThat(sizeFilteredProvider.hasProfile(QUALITY_LOW)).isTrue()
+        assertThat(sizeFilteredProvider.getAll(QUALITY_LOW)).isSameInstanceAs(PROFILES_720P)
+    }
+}
diff --git a/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/compat/workaround/SizeFilteredEncoderProfilesProvider.kt b/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/compat/workaround/SizeFilteredEncoderProfilesProvider.kt
new file mode 100644
index 0000000..1380215
--- /dev/null
+++ b/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/compat/workaround/SizeFilteredEncoderProfilesProvider.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.camera.camera2.internal.compat.workaround
+
+import android.media.CamcorderProfile.QUALITY_HIGH
+import android.media.CamcorderProfile.QUALITY_LOW
+import android.util.Size
+import androidx.camera.core.impl.EncoderProfilesProvider
+import androidx.camera.core.impl.EncoderProfilesProvider.QUALITY_HIGH_TO_LOW
+import androidx.camera.core.impl.EncoderProfilesProxy
+
+/**
+ * An [EncoderProfilesProvider] that filters profiles based on supported sizes.
+ *
+ * This class wraps another [EncoderProfilesProvider] and filters its output to only include
+ * profiles with resolutions that are supported by the camera, as indicated by the provided list of
+ * [supportedSizes].
+ */
+public class SizeFilteredEncoderProfilesProvider(
+    /** The original [EncoderProfilesProvider] to wrap. */
+    private val provider: EncoderProfilesProvider,
+
+    /** The list of supported sizes. */
+    private val supportedSizes: List<Size>
+) : EncoderProfilesProvider {
+
+    private val encoderProfilesCache = mutableMapOf<Int, EncoderProfilesProxy?>()
+
+    override fun hasProfile(quality: Int): Boolean {
+        return getAll(quality) != null
+    }
+
+    override fun getAll(quality: Int): EncoderProfilesProxy? {
+        if (!provider.hasProfile(quality)) {
+            return null
+        }
+
+        if (encoderProfilesCache.containsKey(quality)) {
+            return encoderProfilesCache[quality]
+        }
+
+        var profiles = provider.getAll(quality)
+        if (profiles != null && !isResolutionSupported(profiles)) {
+            profiles =
+                when (quality) {
+                    QUALITY_HIGH -> findFirstAvailableProfile(QUALITY_HIGH_TO_LOW)
+                    QUALITY_LOW -> findFirstAvailableProfile(QUALITY_HIGH_TO_LOW.reversed())
+                    else -> null
+                }
+        }
+
+        encoderProfilesCache[quality] = profiles
+        return profiles
+    }
+
+    /**
+     * Checks if the resolution of the given [EncoderProfilesProxy] is supported.
+     *
+     * @param profiles The [EncoderProfilesProxy] to check.
+     * @return `true` if the resolution is supported, `false` otherwise.
+     */
+    private fun isResolutionSupported(profiles: EncoderProfilesProxy): Boolean {
+        if (supportedSizes.isEmpty() || profiles.videoProfiles.isEmpty()) {
+            return false
+        }
+
+        // cts/CamcorderProfileTest.java ensures all video profiles have the same size so we just
+        // need to check the first video profile.
+        val videoProfile = profiles.videoProfiles[0]
+        return supportedSizes.contains(Size(videoProfile.width, videoProfile.height))
+    }
+
+    /**
+     * Finds the first available profile based on the given quality order.
+     *
+     * This method iterates through the provided [qualityOrder] and returns the first profile that
+     * is available.
+     *
+     * @param qualityOrder The order of qualities to search.
+     * @return The first available [EncoderProfilesProxy], or `null` if no suitable profile is
+     *   found.
+     */
+    private fun findFirstAvailableProfile(qualityOrder: List<Int>): EncoderProfilesProxy? {
+        for (quality in qualityOrder) {
+            getAll(quality)?.let {
+                return it
+            }
+        }
+        return null
+    }
+}
diff --git a/camera/camera-camera2/src/test/java/androidx/camera/camera2/internal/compat/workaround/SizeFilteredEncoderProfilesProviderTest.kt b/camera/camera-camera2/src/test/java/androidx/camera/camera2/internal/compat/workaround/SizeFilteredEncoderProfilesProviderTest.kt
new file mode 100644
index 0000000..7f7ae02
--- /dev/null
+++ b/camera/camera-camera2/src/test/java/androidx/camera/camera2/internal/compat/workaround/SizeFilteredEncoderProfilesProviderTest.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.camera.camera2.internal.compat.workaround
+
+import android.media.CamcorderProfile.QUALITY_1080P
+import android.media.CamcorderProfile.QUALITY_2160P
+import android.media.CamcorderProfile.QUALITY_480P
+import android.media.CamcorderProfile.QUALITY_720P
+import android.media.CamcorderProfile.QUALITY_HIGH
+import android.media.CamcorderProfile.QUALITY_LOW
+import android.os.Build
+import androidx.camera.testing.impl.EncoderProfilesUtil.PROFILES_1080P
+import androidx.camera.testing.impl.EncoderProfilesUtil.PROFILES_2160P
+import androidx.camera.testing.impl.EncoderProfilesUtil.PROFILES_480P
+import androidx.camera.testing.impl.EncoderProfilesUtil.PROFILES_720P
+import androidx.camera.testing.impl.EncoderProfilesUtil.RESOLUTION_1080P
+import androidx.camera.testing.impl.EncoderProfilesUtil.RESOLUTION_720P
+import androidx.camera.testing.impl.fakes.FakeEncoderProfilesProvider
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import org.robolectric.annotation.internal.DoNotInstrument
+
+@RunWith(RobolectricTestRunner::class)
+@DoNotInstrument
+@Config(minSdk = Build.VERSION_CODES.LOLLIPOP)
+class SizeFilteredEncoderProfilesProviderTest {
+
+    private val profilesProvider =
+        FakeEncoderProfilesProvider.Builder()
+            .add(QUALITY_HIGH, PROFILES_2160P)
+            .add(QUALITY_2160P, PROFILES_2160P)
+            .add(QUALITY_1080P, PROFILES_1080P)
+            .add(QUALITY_720P, PROFILES_720P)
+            .add(QUALITY_480P, PROFILES_480P)
+            .add(QUALITY_LOW, PROFILES_480P)
+            .build()
+
+    private val supportedSizes = listOf(RESOLUTION_1080P, RESOLUTION_720P)
+
+    private val sizeFilteredProvider =
+        SizeFilteredEncoderProfilesProvider(profilesProvider, supportedSizes)
+
+    @Test
+    fun quality_shouldBeFilteredBySupportedSizes() {
+        assertThat(sizeFilteredProvider.hasProfile(QUALITY_2160P)).isFalse()
+        assertThat(sizeFilteredProvider.getAll(QUALITY_2160P)).isNull()
+
+        assertThat(sizeFilteredProvider.hasProfile(QUALITY_2160P)).isFalse()
+        assertThat(sizeFilteredProvider.getAll(QUALITY_2160P)).isNull()
+        assertThat(sizeFilteredProvider.hasProfile(QUALITY_1080P)).isTrue()
+        assertThat(sizeFilteredProvider.getAll(QUALITY_1080P)).isSameInstanceAs(PROFILES_1080P)
+        assertThat(sizeFilteredProvider.hasProfile(QUALITY_720P)).isTrue()
+        assertThat(sizeFilteredProvider.getAll(QUALITY_720P)).isSameInstanceAs(PROFILES_720P)
+        assertThat(sizeFilteredProvider.hasProfile(QUALITY_480P)).isFalse()
+        assertThat(sizeFilteredProvider.getAll(QUALITY_480P)).isNull()
+    }
+
+    @Test
+    fun qualityHighLow_shouldMapToCorrectProfiles() {
+        assertThat(sizeFilteredProvider.hasProfile(QUALITY_HIGH)).isTrue()
+        assertThat(sizeFilteredProvider.getAll(QUALITY_HIGH)).isSameInstanceAs(PROFILES_1080P)
+
+        assertThat(sizeFilteredProvider.hasProfile(QUALITY_LOW)).isTrue()
+        assertThat(sizeFilteredProvider.getAll(QUALITY_LOW)).isSameInstanceAs(PROFILES_720P)
+    }
+}
diff --git a/camera/integration-tests/coretestapp/src/main/java/androidx/camera/integration/core/CameraXActivity.java b/camera/integration-tests/coretestapp/src/main/java/androidx/camera/integration/core/CameraXActivity.java
index 0dc50be..46ef6e5 100644
--- a/camera/integration-tests/coretestapp/src/main/java/androidx/camera/integration/core/CameraXActivity.java
+++ b/camera/integration-tests/coretestapp/src/main/java/androidx/camera/integration/core/CameraXActivity.java
@@ -71,12 +71,12 @@
 import android.provider.MediaStore;
 import android.util.DisplayMetrics;
 import android.util.Log;
-import android.util.Pair;
 import android.util.Range;
 import android.util.Rational;
 import android.view.Display;
 import android.view.GestureDetector;
 import android.view.Menu;
+import android.view.MenuItem;
 import android.view.MotionEvent;
 import android.view.View;
 import android.view.ViewGroup;
@@ -130,9 +130,11 @@
 import androidx.camera.core.ViewPort;
 import androidx.camera.core.impl.CameraInfoInternal;
 import androidx.camera.core.impl.Quirks;
+import androidx.camera.core.impl.StreamSpec;
 import androidx.camera.core.impl.utils.AspectRatioUtil;
 import androidx.camera.core.impl.utils.executor.CameraXExecutors;
 import androidx.camera.lifecycle.ProcessCameraProvider;
+import androidx.camera.testing.impl.StreamSharingForceEnabledEffect;
 import androidx.camera.video.ExperimentalPersistentRecording;
 import androidx.camera.video.FileOutputOptions;
 import androidx.camera.video.MediaStoreOutputOptions;
@@ -166,10 +168,12 @@
 import java.util.ArrayList;
 import java.util.Calendar;
 import java.util.Collections;
+import java.util.HashMap;
 import java.util.HashSet;
 import java.util.Iterator;
 import java.util.List;
 import java.util.Locale;
+import java.util.Map;
 import java.util.Objects;
 import java.util.Set;
 import java.util.concurrent.ExecutorService;
@@ -192,7 +196,12 @@
     private static final String TAG = "CameraXActivity";
     private static final String[] REQUIRED_PERMISSIONS;
     private static final List<DynamicRangeUiData> DYNAMIC_RANGE_UI_DATA = new ArrayList<>();
-    private static final List<Pair<Range<Integer>, String>> FPS_OPTIONS = new ArrayList<>();
+
+    // StreamSpec.FRAME_RATE_RANGE_UNSPECIFIED is not public
+    @SuppressLint("RestrictedApiAndroidX")
+    private static final Range<Integer> FPS_UNSPECIFIED = StreamSpec.FRAME_RATE_RANGE_UNSPECIFIED;
+    private static final Map<Integer, Range<Integer>> ID_TO_FPS_RANGE_MAP = new HashMap<>();
+    private static final Map<Integer, Integer> ID_TO_ASPECT_RATIO_MAP = new HashMap<>();
 
     static {
         // From Android T, skips the permission check of WRITE_EXTERNAL_STORAGE since it won't be
@@ -243,10 +252,14 @@
         //  `CameraInfo.getSupportedFrameRateRanges()`, but we may want to try unsupported cases too
         //  sometimes for testing, so the unsupported ones still should be options (perhaps greyed
         //  out or struck-through).
-        FPS_OPTIONS.add(new Pair<>(new Range<>(0, 0), "Unspecified"));
-        FPS_OPTIONS.add(new Pair<>(new Range<>(15, 15), "15"));
-        FPS_OPTIONS.add(new Pair<>(new Range<>(30, 30), "30"));
-        FPS_OPTIONS.add(new Pair<>(new Range<>(60, 60), "60"));
+        ID_TO_FPS_RANGE_MAP.put(R.id.fps_unspecified, FPS_UNSPECIFIED);
+        ID_TO_FPS_RANGE_MAP.put(R.id.fps_15, new Range<>(15, 15));
+        ID_TO_FPS_RANGE_MAP.put(R.id.fps_30, new Range<>(30, 30));
+        ID_TO_FPS_RANGE_MAP.put(R.id.fps_60, new Range<>(60, 60));
+
+        ID_TO_ASPECT_RATIO_MAP.put(R.id.aspect_ratio_default, AspectRatio.RATIO_DEFAULT);
+        ID_TO_ASPECT_RATIO_MAP.put(R.id.aspect_ratio_4_3, AspectRatio.RATIO_4_3);
+        ID_TO_ASPECT_RATIO_MAP.put(R.id.aspect_ratio_16_9, AspectRatio.RATIO_16_9);
     }
 
     //Use this activity title when Camera Pipe configuration is used by core test app
@@ -364,7 +377,6 @@
     private Button mZoomIn2XToggle;
     private Button mZoomResetToggle;
     private Button mButtonImageOutputFormat;
-    private Button mButtonFps;
     private Toast mEvToast = null;
     private Toast mPSToast = null;
     private ToggleButton mPreviewStabilizationToggle;
@@ -381,7 +393,9 @@
     private final Set<DynamicRange> mSelectableDynamicRanges = new HashSet<>();
     private int mVideoMirrorMode = MIRROR_MODE_ON_FRONT_ONLY;
     private boolean mIsPreviewStabilizationOn = false;
-    private int mFpsMenuId = 0;
+    private Range<Integer> mFpsRange = FPS_UNSPECIFIED;
+    private boolean mForceEnableStreamSharing;
+    private boolean mDisableViewPort;
 
     SessionMediaUriSet mSessionImagesUriSet = new SessionMediaUriSet();
     SessionMediaUriSet mSessionVideosUriSet = new SessionMediaUriSet();
@@ -1288,7 +1302,6 @@
         mZoomSeekBar.setVisibility(View.GONE);
         mZoomRatioLabel.setVisibility(View.GONE);
         mTextView.setVisibility(View.GONE);
-        mButtonFps.setVisibility(View.GONE);
 
         if (testCase.equals(PREVIEW_TEST_CASE) || testCase.equals(SWITCH_TEST_CASE)) {
             mTorchButton.setVisibility(View.GONE);
@@ -1401,10 +1414,11 @@
         mPlusEV.setEnabled(isExposureCompensationSupported());
         mDecEV.setEnabled(isExposureCompensationSupported());
         mZoomIn2XToggle.setEnabled(is2XZoomSupported());
-        mButtonFps.setEnabled(mPreviewToggle.isChecked() || mVideoToggle.isChecked());
 
         // this function may make some view visible again, so need to update for E2E tests again
         updateAppUIForE2ETest();
+
+        invalidateOptionsMenu();
     }
 
     // Set or reset content description for e2e testing.
@@ -1605,42 +1619,6 @@
                 findViewById(R.id.video_mute),
                 (newState) -> updateDynamicRangeUiState()
         );
-        mButtonFps = findViewById(R.id.fps);
-        if (mFpsMenuId == 0) {
-            mButtonFps.setText("FPS\nUnsp.");
-        } else {
-            mButtonFps.setText("FPS\n" + FPS_OPTIONS.get(mFpsMenuId).second);
-        }
-        mButtonFps.setOnClickListener(view -> {
-            PopupMenu popup = new PopupMenu(this, view);
-            Menu menu = popup.getMenu();
-
-            for (int i = 0; i < FPS_OPTIONS.size(); i++) {
-                menu.add(0, i, Menu.NONE, FPS_OPTIONS.get(i).second);
-            }
-
-            menu.findItem(mFpsMenuId).setChecked(true);
-
-            // Make menu single checkable
-            menu.setGroupCheckable(0, true, true);
-
-            popup.setOnMenuItemClickListener(item -> {
-                int itemId = item.getItemId();
-                if (itemId != mFpsMenuId) {
-                    mFpsMenuId = itemId;
-                    if (mFpsMenuId == 0) {
-                        mButtonFps.setText("FPS\nUnsp.");
-                    } else {
-                        mButtonFps.setText("FPS\n" + FPS_OPTIONS.get(mFpsMenuId).second);
-                    }
-                    // FPS changed, rebind UseCases
-                    tryBindUseCases();
-                }
-                return true;
-            });
-
-            popup.show();
-        });
 
         setUpButtonEvents();
         setupViewFinderGestureControls();
@@ -1758,6 +1736,83 @@
         setupPermissions();
     }
 
+    @Override
+    public boolean onCreateOptionsMenu(Menu menu) {
+        getMenuInflater().inflate(R.menu.actionbar_menu, menu);
+        return true;
+    }
+
+    @Override
+    public boolean onPrepareOptionsMenu(Menu menu) {
+        updateMenuItems(menu);
+        return true;
+    }
+
+    private void updateMenuItems(Menu menu) {
+        menu.findItem(requireNonNull(getKeyByValue(ID_TO_FPS_RANGE_MAP, mFpsRange))).setChecked(
+                true);
+        menu.findItem(R.id.fps).setEnabled(mPreviewToggle.isChecked() || mVideoToggle.isChecked());
+
+        menu.findItem(requireNonNull(
+                getKeyByValue(ID_TO_ASPECT_RATIO_MAP, mTargetAspectRatio))).setChecked(true);
+
+        menu.findItem(R.id.stream_sharing).setChecked(mForceEnableStreamSharing);
+        // StreamSharing requires both Preview & VideoCapture use cases in core-test-app
+        // (since ImageCapture can't be added due to lack of effect)
+        menu.findItem(R.id.stream_sharing).setEnabled(
+                mPreviewToggle.isChecked() && mVideoToggle.isChecked());
+
+        menu.findItem(R.id.view_port).setChecked(mDisableViewPort);
+    }
+
+    private static <T, E> T getKeyByValue(Map<T, E> map, E value) {
+        for (Map.Entry<T, E> entry : map.entrySet()) {
+            if (Objects.equals(value, entry.getValue())) {
+                return entry.getKey();
+            }
+        }
+        return null; // No key found for the given value
+    }
+
+    @Override
+    public boolean onOptionsItemSelected(@NonNull MenuItem item) {
+        // Handle item selection.
+        Log.d(TAG, "onOptionsItemSelected: item = " + item);
+
+        int groupId = item.getGroupId();
+        int itemId = item.getItemId();
+
+        if (groupId == R.id.fps_group) {
+            if (ID_TO_FPS_RANGE_MAP.containsKey(itemId)) {
+                mFpsRange = ID_TO_FPS_RANGE_MAP.get(itemId);
+            } else {
+                Log.e(TAG, "Unknown item " + item.getTitle());
+                return super.onOptionsItemSelected(item);
+            }
+        } else if (groupId == R.id.aspect_ratio_group) {
+            if (ID_TO_ASPECT_RATIO_MAP.containsKey(itemId)) {
+                mTargetAspectRatio = requireNonNull(ID_TO_ASPECT_RATIO_MAP.get(itemId));
+            } else {
+                Log.e(TAG, "Unknown item " + item.getTitle());
+                return super.onOptionsItemSelected(item);
+            }
+        } else if (itemId == R.id.stream_sharing) {
+            mForceEnableStreamSharing = !mForceEnableStreamSharing;
+        } else if (itemId == R.id.view_port) {
+            mDisableViewPort = !mDisableViewPort;
+        } else {
+            Log.d(TAG, "Not handling item " + item.getTitle());
+            return super.onOptionsItemSelected(item);
+        }
+
+        item.setChecked(!item.isChecked());
+
+        // Some configuration option may be changed, rebind UseCases
+        tryBindUseCases();
+
+        return super.onOptionsItemSelected(item);
+    }
+
     /**
      * Writes text data to a file in public external directory for reading during tests.
      */
@@ -1960,7 +2015,7 @@
                     .setPreviewStabilizationEnabled(mIsPreviewStabilizationOn)
                     .setDynamicRange(
                             mVideoToggle.isChecked() ? DynamicRange.UNSPECIFIED : mDynamicRange)
-                    .setTargetFrameRate(FPS_OPTIONS.get(mFpsMenuId).first)
+                    .setTargetFrameRate(mFpsRange)
                     .build();
             resetViewIdlingResource();
             // Use the listener of the future to make sure the Preview setup the new surface.
@@ -1987,6 +2042,7 @@
         if (mAnalysisToggle.isChecked()) {
             ImageAnalysis imageAnalysis = new ImageAnalysis.Builder()
                     .setTargetName("ImageAnalysis")
+                    .setTargetAspectRatio(mTargetAspectRatio)
                     .build();
             useCases.add(imageAnalysis);
             // Make the analysis idling resource non-idle, until the required frames received.
@@ -2005,11 +2061,11 @@
                 if (mVideoQuality != QUALITY_AUTO) {
                     builder.setQualitySelector(QualitySelector.from(mVideoQuality));
                 }
-                mRecorder = builder.build();
+                mRecorder = builder.setAspectRatio(mTargetAspectRatio).build();
                 mVideoCapture = new VideoCapture.Builder<>(mRecorder)
                         .setMirrorMode(mVideoMirrorMode)
                         .setDynamicRange(mDynamicRange)
-                        .setTargetFrameRate(FPS_OPTIONS.get(mFpsMenuId).first)
+                        .setTargetFrameRate(mFpsRange)
                         .build();
             }
             useCases.add(mVideoCapture);
@@ -2122,15 +2178,29 @@
      * Binds use cases to the current lifecycle.
      */
     private Camera bindToLifecycleSafely(List<UseCase> useCases) {
-        ViewPort viewPort = new ViewPort.Builder(new Rational(mViewFinder.getWidth(),
-                mViewFinder.getHeight()),
-                mViewFinder.getDisplay().getRotation())
-                .setScaleType(ViewPort.FILL_CENTER).build();
-        UseCaseGroup.Builder useCaseGroupBuilder = new UseCaseGroup.Builder().setViewPort(
-                viewPort);
+        Log.d(TAG, "bindToLifecycleSafely: mDisableViewPort = " + mDisableViewPort
+                + ", mForceEnableStreamSharing = " + mForceEnableStreamSharing);
+
+        UseCaseGroup.Builder useCaseGroupBuilder = new UseCaseGroup.Builder();
         for (UseCase useCase : useCases) {
             useCaseGroupBuilder.addUseCase(useCase);
         }
+
+        if (!mDisableViewPort) {
+            ViewPort viewPort = new ViewPort.Builder(new Rational(mViewFinder.getWidth(),
+                    mViewFinder.getHeight()),
+                    mViewFinder.getDisplay().getRotation())
+                    .setScaleType(ViewPort.FILL_CENTER).build();
+            useCaseGroupBuilder.setViewPort(viewPort);
+        }
+
+        // Force-enable stream sharing
+        if (mForceEnableStreamSharing) {
+            @SuppressLint("RestrictedApiAndroidX")
+            StreamSharingForceEnabledEffect effect = new StreamSharingForceEnabledEffect();
+            useCaseGroupBuilder.addEffect(effect);
+        }
+
         mCamera = mCameraProvider.bindToLifecycle(this, mCurrentCameraSelector,
                 useCaseGroupBuilder.build());
         setupZoomSeeker();
diff --git a/camera/integration-tests/coretestapp/src/main/res/layout/activity_camera_xmain.xml b/camera/integration-tests/coretestapp/src/main/res/layout/activity_camera_xmain.xml
index bc7e3db..c65182e 100644
--- a/camera/integration-tests/coretestapp/src/main/res/layout/activity_camera_xmain.xml
+++ b/camera/integration-tests/coretestapp/src/main/res/layout/activity_camera_xmain.xml
@@ -437,20 +437,6 @@
         app:layout_constraintStart_toEndOf="@+id/seekBar"
         app:layout_constraintTop_toTopOf="@+id/seekBar" />
 
-    <Button
-        android:id="@+id/fps"
-        android:layout_width="46dp"
-        android:layout_height="wrap_content"
-        android:layout_marginStart="5dp"
-        android:layout_marginTop="1dp"
-        android:background="@android:drawable/btn_default"
-        android:text="FPS"
-        android:textSize="7dp"
-        android:translationZ="1dp"
-        app:layout_constraintTop_toBottomOf="@id/preview_stabilization"
-        app:layout_constraintLeft_toLeftOf="parent"
-        />
-
     <androidx.camera.view.ScreenFlashView
         android:id="@+id/screen_flash_view"
         android:layout_width="match_parent"
diff --git a/camera/integration-tests/coretestapp/src/main/res/menu/actionbar_menu.xml b/camera/integration-tests/coretestapp/src/main/res/menu/actionbar_menu.xml
new file mode 100644
index 0000000..6e2c5fb
--- /dev/null
+++ b/camera/integration-tests/coretestapp/src/main/res/menu/actionbar_menu.xml
@@ -0,0 +1,82 @@
+<?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.
+  -->
+
+<menu xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto">
+    <item
+        android:id="@+id/fps"
+        android:title="@string/fps"
+        app:showAsAction="never">
+        <menu>
+            <group
+                android:id="@+id/fps_group"
+                android:checkableBehavior="single">
+                <item
+                    android:id="@+id/fps_unspecified"
+                    android:checked="true"
+                    android:title="Unspecified"
+                    app:showAsAction="never" />
+                <item
+                    android:id="@+id/fps_15"
+                    android:title="15"
+                    app:showAsAction="never" />
+                <item
+                    android:id="@+id/fps_30"
+                    android:title="30"
+                    app:showAsAction="never" />
+                <item
+                    android:id="@+id/fps_60"
+                    android:title="60"
+                    app:showAsAction="never" />
+            </group>
+        </menu>
+    </item>
+    <item
+        android:id="@+id/aspect_ratio"
+        android:title="@string/aspect_ratio"
+        app:showAsAction="never">
+        <menu>
+            <group
+                android:id="@+id/aspect_ratio_group"
+                android:checkableBehavior="single">
+                <item
+                    android:id="@+id/aspect_ratio_default"
+                    android:checked="true"
+                    android:title="Default"
+                    app:showAsAction="never" />
+                <item
+                    android:id="@+id/aspect_ratio_4_3"
+                    android:title="4:3"
+                    app:showAsAction="never" />
+                <item
+                    android:id="@+id/aspect_ratio_16_9"
+                    android:title="16:9"
+                    app:showAsAction="never" />
+            </group>
+        </menu>
+    </item>
+    <item
+        android:id="@+id/stream_sharing"
+        android:checkable="true"
+        android:title="@string/force_stream_sharing"
+        app:showAsAction="never" />
+    <item
+        android:id="@+id/view_port"
+        android:checkable="true"
+        android:title="@string/disable_view_port"
+        app:showAsAction="never" />
+
+</menu>
diff --git a/camera/integration-tests/coretestapp/src/main/res/values/donottranslate-strings.xml b/camera/integration-tests/coretestapp/src/main/res/values/donottranslate-strings.xml
index 949bfd4..34828bd 100644
--- a/camera/integration-tests/coretestapp/src/main/res/values/donottranslate-strings.xml
+++ b/camera/integration-tests/coretestapp/src/main/res/values/donottranslate-strings.xml
@@ -47,5 +47,9 @@
     <string name="toggle_video_dyn_rng_hdr_dolby_vision_10">Dlby\n10bit</string>
     <string name="toggle_video_dyn_rng_hdr_dolby_vision_8">Dlby\n8bit</string>
     <string name="toggle_video_dyn_rng_unknown">\?</string>
+    <string name="fps">FPS</string>
+    <string name="aspect_ratio">Aspect Ratio</string>
+    <string name="force_stream_sharing">Force Stream Sharing</string>
+    <string name="disable_view_port">Disable View Port</string>
 
 </resources>
\ No newline at end of file
diff --git a/car/app/app-samples/showcase/common/src/main/java/androidx/car/app/sample/showcase/common/screens/templatelayouts/listtemplates/SecondaryActionsAndDecorationDemoScreen.java b/car/app/app-samples/showcase/common/src/main/java/androidx/car/app/sample/showcase/common/screens/templatelayouts/listtemplates/SecondaryActionsAndDecorationDemoScreen.java
index 87d420b..5d57c7c 100644
--- a/car/app/app-samples/showcase/common/src/main/java/androidx/car/app/sample/showcase/common/screens/templatelayouts/listtemplates/SecondaryActionsAndDecorationDemoScreen.java
+++ b/car/app/app-samples/showcase/common/src/main/java/androidx/car/app/sample/showcase/common/screens/templatelayouts/listtemplates/SecondaryActionsAndDecorationDemoScreen.java
@@ -58,6 +58,11 @@
                 12,
                 action));
 
+        listBuilder.addItem(buildRowForTemplate(
+                R.string.secondary_actions_decoration_test_title_long,
+                9,
+                action));
+
         return new ListTemplate.Builder()
                 .setSingleList(listBuilder.build())
                 .setHeader(new Header.Builder()
diff --git a/car/app/app-samples/showcase/common/src/main/res/values/strings.xml b/car/app/app-samples/showcase/common/src/main/res/values/strings.xml
index f8c1adf3..5ea9f40 100644
--- a/car/app/app-samples/showcase/common/src/main/res/values/strings.xml
+++ b/car/app/app-samples/showcase/common/src/main/res/values/strings.xml
@@ -408,6 +408,7 @@
   <string name="secondary_actions_test_subtitle">Only the secondary action can be selected</string>
   <string name="decoration_test_title">Decoration Test</string>
   <string name="secondary_actions_decoration_test_title">Secondary Actions and Decoration</string>
+  <string name="secondary_actions_decoration_test_title_long">Row with Secondary Actions and Decoration with a really long title</string>
   <string name="secondary_actions_decoration_test_subtitle">The row can also be selected</string>
   <string name="secondary_action_toast">Secondary Action is selected</string>
   <string name="row_primary_action_toast">Row primary action is selected</string>
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 f4967da..c02221fe 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
@@ -77,6 +77,7 @@
 import androidx.compose.ui.graphics.compositeOver
 import androidx.compose.ui.layout.ContentScale
 import androidx.compose.ui.layout.ScaleFactor
+import androidx.compose.ui.layout.SubcomposeLayout
 import androidx.compose.ui.layout.approachLayout
 import androidx.compose.ui.layout.onGloballyPositioned
 import androidx.compose.ui.layout.onPlaced
@@ -116,6 +117,7 @@
 @LargeTest
 class SharedTransitionTest {
     val rule = createComposeRule()
+
     // Detect leaks BEFORE and AFTER compose rule work
     @get:Rule
     val ruleChain: RuleChain = RuleChain.outerRule(DetectLeaksAfterTestSuccess()).around(rule)
@@ -2862,6 +2864,38 @@
         // Transition into a Red box
         clickAndAssertColorDuringTransition(Color.Red)
     }
+
+    @Test
+    fun foundMatchedElementButNeverMeasured() {
+        var target by mutableStateOf(true)
+        rule.setContent {
+            SharedTransitionLayout {
+                AnimatedContent(target) {
+                    SubcomposeLayout {
+                        subcompose(0) {
+                            Box(
+                                Modifier.sharedBounds(
+                                        rememberSharedContentState("test"),
+                                        animatedVisibilityScope = this@AnimatedContent
+                                    )
+                                    .size(200.dp)
+                                    .background(Color.Red)
+                            )
+                        }
+                        // Skip measure and return size
+                        layout(200, 200) {}
+                    }
+                    Box(Modifier.size(200.dp).background(Color.Black))
+                }
+            }
+        }
+
+        rule.waitForIdle()
+        rule.mainClock.autoAdvance = false
+        target = !target
+        rule.mainClock.advanceTimeByFrame()
+        rule.waitForIdle()
+    }
 }
 
 private fun assertEquals(a: IntSize, b: IntSize, delta: IntSize) {
diff --git a/compose/animation/animation/src/commonMain/kotlin/androidx/compose/animation/SharedContentNode.kt b/compose/animation/animation/src/commonMain/kotlin/androidx/compose/animation/SharedContentNode.kt
index 8d6b5e6..8022fd5 100644
--- a/compose/animation/animation/src/commonMain/kotlin/androidx/compose/animation/SharedContentNode.kt
+++ b/compose/animation/animation/src/commonMain/kotlin/androidx/compose/animation/SharedContentNode.kt
@@ -160,7 +160,7 @@
         }
     }
 
-    private fun MeasureScope.place(placeable: Placeable): MeasureResult {
+    private fun MeasureScope.approachPlace(placeable: Placeable): MeasureResult {
         if (!sharedElement.foundMatch) {
             // No match
             return layout(placeable.width, placeable.height) {
@@ -231,7 +231,7 @@
                 } ?: constraints
             }
         val placeable = measurable.measure(resolvedConstraints)
-        return place(placeable)
+        return approachPlace(placeable)
     }
 
     private fun LayoutCoordinates.updateCurrentBounds() {
@@ -243,6 +243,7 @@
     }
 
     override fun ContentDrawScope.draw() {
+        state.firstFrameDrawn = true
         // Update clipPath
         state.clipPathInOverlay =
             state.overlayClip.getClipPath(
diff --git a/compose/animation/animation/src/commonMain/kotlin/androidx/compose/animation/SharedElement.kt b/compose/animation/animation/src/commonMain/kotlin/androidx/compose/animation/SharedElement.kt
index afe96a9..36de972 100644
--- a/compose/animation/animation/src/commonMain/kotlin/androidx/compose/animation/SharedElement.kt
+++ b/compose/animation/animation/src/commonMain/kotlin/androidx/compose/animation/SharedElement.kt
@@ -170,6 +170,7 @@
     zIndex: Float
 ) : LayerRenderer, RememberObserver {
 
+    internal var firstFrameDrawn: Boolean = false
     override var zIndex: Float by mutableFloatStateOf(zIndex)
 
     var renderInOverlayDuringTransition: Boolean by mutableStateOf(renderInOverlayDuringTransition)
@@ -184,7 +185,10 @@
 
     override fun drawInOverlay(drawScope: DrawScope) {
         val layer = layer ?: return
-        if (shouldRenderInOverlay) {
+        // It is important to check that the first frame is drawn. In some cases shared content may
+        // be composed, but never measured, placed or drawn. In those cases, we will not have
+        // valid content to draw, therefore we need to skip drawing in overlay.
+        if (firstFrameDrawn && shouldRenderInOverlay) {
             with(drawScope) {
                 requireNotNull(sharedElement.currentBounds) { "Error: current bounds not set yet." }
                 val (x, y) = sharedElement.currentBounds?.topLeft!!
diff --git a/compose/foundation/foundation/api/1.8.0-beta01.txt b/compose/foundation/foundation/api/1.8.0-beta01.txt
index a5dc1dc..4ea18b7 100644
--- a/compose/foundation/foundation/api/1.8.0-beta01.txt
+++ b/compose/foundation/foundation/api/1.8.0-beta01.txt
@@ -1695,21 +1695,9 @@
     field public static final androidx.compose.foundation.text.AutoSizeDefaults INSTANCE;
   }
 
-  @kotlin.jvm.JvmInline public final value class AutofillHighlight {
-    ctor public AutofillHighlight(long autofillHighlightColor);
-    method public long getAutofillHighlightColor();
-    property public final long autofillHighlightColor;
-    field public static final androidx.compose.foundation.text.AutofillHighlight.Companion Companion;
-  }
-
-  public static final class AutofillHighlight.Companion {
-    method public long getDefault();
-    property public final long Default;
-  }
-
   public final class AutofillHighlightKt {
-    method public static androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.foundation.text.AutofillHighlight> getLocalAutofillHighlight();
-    property public static final androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.foundation.text.AutofillHighlight> LocalAutofillHighlight;
+    method public static androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.ui.graphics.Color> getLocalAutofillHighlightColor();
+    property public static final androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.ui.graphics.Color> LocalAutofillHighlightColor;
   }
 
   public final class BasicSecureTextFieldKt {
diff --git a/compose/foundation/foundation/api/current.ignore b/compose/foundation/foundation/api/current.ignore
index 0bfc96d..ac64bb7 100644
--- a/compose/foundation/foundation/api/current.ignore
+++ b/compose/foundation/foundation/api/current.ignore
@@ -1,7 +1,11 @@
 // Baseline format: 1.0
-AddedMethod: androidx.compose.foundation.OverscrollKt#withoutVisualEffect(androidx.compose.foundation.OverscrollEffect):
-    Added method androidx.compose.foundation.OverscrollKt.withoutVisualEffect(androidx.compose.foundation.OverscrollEffect)
+AddedMethod: androidx.compose.foundation.text.AutofillHighlightKt#getLocalAutofillHighlightColor():
+    Added method androidx.compose.foundation.text.AutofillHighlightKt.getLocalAutofillHighlightColor()
 
 
-RemovedMethod: androidx.compose.foundation.OverscrollKt#withoutDrawing(androidx.compose.foundation.OverscrollEffect):
-    Removed method androidx.compose.foundation.OverscrollKt.withoutDrawing(androidx.compose.foundation.OverscrollEffect)
+RemovedClass: androidx.compose.foundation.text.AutofillHighlight_androidKt:
+    Removed class androidx.compose.foundation.text.AutofillHighlight_androidKt
+
+
+RemovedMethod: androidx.compose.foundation.text.AutofillHighlightKt#getLocalAutofillHighlight():
+    Removed method androidx.compose.foundation.text.AutofillHighlightKt.getLocalAutofillHighlight()
diff --git a/compose/foundation/foundation/api/current.txt b/compose/foundation/foundation/api/current.txt
index a5dc1dc..4ea18b7 100644
--- a/compose/foundation/foundation/api/current.txt
+++ b/compose/foundation/foundation/api/current.txt
@@ -1695,21 +1695,9 @@
     field public static final androidx.compose.foundation.text.AutoSizeDefaults INSTANCE;
   }
 
-  @kotlin.jvm.JvmInline public final value class AutofillHighlight {
-    ctor public AutofillHighlight(long autofillHighlightColor);
-    method public long getAutofillHighlightColor();
-    property public final long autofillHighlightColor;
-    field public static final androidx.compose.foundation.text.AutofillHighlight.Companion Companion;
-  }
-
-  public static final class AutofillHighlight.Companion {
-    method public long getDefault();
-    property public final long Default;
-  }
-
   public final class AutofillHighlightKt {
-    method public static androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.foundation.text.AutofillHighlight> getLocalAutofillHighlight();
-    property public static final androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.foundation.text.AutofillHighlight> LocalAutofillHighlight;
+    method public static androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.ui.graphics.Color> getLocalAutofillHighlightColor();
+    property public static final androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.ui.graphics.Color> LocalAutofillHighlightColor;
   }
 
   public final class BasicSecureTextFieldKt {
diff --git a/compose/foundation/foundation/api/restricted_1.8.0-beta01.txt b/compose/foundation/foundation/api/restricted_1.8.0-beta01.txt
index 1b03797..b5e271b 100644
--- a/compose/foundation/foundation/api/restricted_1.8.0-beta01.txt
+++ b/compose/foundation/foundation/api/restricted_1.8.0-beta01.txt
@@ -1697,21 +1697,9 @@
     field public static final androidx.compose.foundation.text.AutoSizeDefaults INSTANCE;
   }
 
-  @kotlin.jvm.JvmInline public final value class AutofillHighlight {
-    ctor public AutofillHighlight(long autofillHighlightColor);
-    method public long getAutofillHighlightColor();
-    property public final long autofillHighlightColor;
-    field public static final androidx.compose.foundation.text.AutofillHighlight.Companion Companion;
-  }
-
-  public static final class AutofillHighlight.Companion {
-    method public long getDefault();
-    property public final long Default;
-  }
-
   public final class AutofillHighlightKt {
-    method public static androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.foundation.text.AutofillHighlight> getLocalAutofillHighlight();
-    property public static final androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.foundation.text.AutofillHighlight> LocalAutofillHighlight;
+    method public static androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.ui.graphics.Color> getLocalAutofillHighlightColor();
+    property public static final androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.ui.graphics.Color> LocalAutofillHighlightColor;
   }
 
   public final class BasicSecureTextFieldKt {
diff --git a/compose/foundation/foundation/api/restricted_current.ignore b/compose/foundation/foundation/api/restricted_current.ignore
index 0bfc96d..ac64bb7 100644
--- a/compose/foundation/foundation/api/restricted_current.ignore
+++ b/compose/foundation/foundation/api/restricted_current.ignore
@@ -1,7 +1,11 @@
 // Baseline format: 1.0
-AddedMethod: androidx.compose.foundation.OverscrollKt#withoutVisualEffect(androidx.compose.foundation.OverscrollEffect):
-    Added method androidx.compose.foundation.OverscrollKt.withoutVisualEffect(androidx.compose.foundation.OverscrollEffect)
+AddedMethod: androidx.compose.foundation.text.AutofillHighlightKt#getLocalAutofillHighlightColor():
+    Added method androidx.compose.foundation.text.AutofillHighlightKt.getLocalAutofillHighlightColor()
 
 
-RemovedMethod: androidx.compose.foundation.OverscrollKt#withoutDrawing(androidx.compose.foundation.OverscrollEffect):
-    Removed method androidx.compose.foundation.OverscrollKt.withoutDrawing(androidx.compose.foundation.OverscrollEffect)
+RemovedClass: androidx.compose.foundation.text.AutofillHighlight_androidKt:
+    Removed class androidx.compose.foundation.text.AutofillHighlight_androidKt
+
+
+RemovedMethod: androidx.compose.foundation.text.AutofillHighlightKt#getLocalAutofillHighlight():
+    Removed method androidx.compose.foundation.text.AutofillHighlightKt.getLocalAutofillHighlight()
diff --git a/compose/foundation/foundation/api/restricted_current.txt b/compose/foundation/foundation/api/restricted_current.txt
index 1b03797..b5e271b 100644
--- a/compose/foundation/foundation/api/restricted_current.txt
+++ b/compose/foundation/foundation/api/restricted_current.txt
@@ -1697,21 +1697,9 @@
     field public static final androidx.compose.foundation.text.AutoSizeDefaults INSTANCE;
   }
 
-  @kotlin.jvm.JvmInline public final value class AutofillHighlight {
-    ctor public AutofillHighlight(long autofillHighlightColor);
-    method public long getAutofillHighlightColor();
-    property public final long autofillHighlightColor;
-    field public static final androidx.compose.foundation.text.AutofillHighlight.Companion Companion;
-  }
-
-  public static final class AutofillHighlight.Companion {
-    method public long getDefault();
-    property public final long Default;
-  }
-
   public final class AutofillHighlightKt {
-    method public static androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.foundation.text.AutofillHighlight> getLocalAutofillHighlight();
-    property public static final androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.foundation.text.AutofillHighlight> LocalAutofillHighlight;
+    method public static androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.ui.graphics.Color> getLocalAutofillHighlightColor();
+    property public static final androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.ui.graphics.Color> LocalAutofillHighlightColor;
   }
 
   public final class BasicSecureTextFieldKt {
diff --git a/compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/grid/LazyGridPrefetcherTest.kt b/compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/grid/LazyGridPrefetcherTest.kt
index d9870be..eaddcce 100644
--- a/compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/grid/LazyGridPrefetcherTest.kt
+++ b/compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/grid/LazyGridPrefetcherTest.kt
@@ -104,7 +104,6 @@
         rule.runOnIdle { runBlocking { state.scrollBy(5f) } }
 
         waitForPrefetch()
-        waitForPrefetch()
 
         rule.onNodeWithTag("4").assertExists()
         rule.onNodeWithTag("5").assertExists()
@@ -118,7 +117,6 @@
         rule.runOnIdle { runBlocking { state.scrollBy(-5f) } }
 
         waitForPrefetch()
-        waitForPrefetch()
 
         rule.onNodeWithTag("2").assertExists()
         rule.onNodeWithTag("3").assertExists()
@@ -132,7 +130,6 @@
         rule.runOnIdle { runBlocking { state.scrollBy(5f) } }
 
         waitForPrefetch()
-        waitForPrefetch()
 
         rule.onNodeWithTag("6").assertExists()
         rule.onNodeWithTag("7").assertExists()
@@ -146,7 +143,6 @@
         }
 
         waitForPrefetch()
-        waitForPrefetch()
 
         rule.onNodeWithTag("0").assertExists()
         rule.onNodeWithTag("1").assertExists()
diff --git a/compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/layout/TestPrefetchScheduler.kt b/compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/layout/TestPrefetchScheduler.kt
index c490175..1916efc 100644
--- a/compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/layout/TestPrefetchScheduler.kt
+++ b/compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/layout/TestPrefetchScheduler.kt
@@ -28,8 +28,11 @@
     }
 
     fun executeActiveRequests() {
-        activeRequests.forEach { with(it) { scope.execute() } }
-        activeRequests.clear()
+        while (activeRequests.isNotEmpty()) {
+            val request = activeRequests[0]
+            val hasMoreWorkToDo = with(request) { scope.execute() }
+            if (!hasMoreWorkToDo) activeRequests.removeAt(0)
+        }
     }
 
     private val scope =
diff --git a/compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridPrefetcherTest.kt b/compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridPrefetcherTest.kt
index 192ec6a..77687db 100644
--- a/compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridPrefetcherTest.kt
+++ b/compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridPrefetcherTest.kt
@@ -448,7 +448,6 @@
         }
 
         waitForPrefetch()
-        waitForPrefetch()
 
         // ┌─┬─┐
         // │2├─┤
diff --git a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/AutofillHighlight.android.kt b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/AutofillHighlight.android.kt
index 14c0130..29cb6c1 100644
--- a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/AutofillHighlight.android.kt
+++ b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/AutofillHighlight.android.kt
@@ -19,20 +19,8 @@
 import androidx.compose.ui.graphics.Color
 
 /**
- * Represents the colors used for text selection by text and text field components.
- *
- * See [LocalAutofillHighlight] to provide new values for this throughout the hierarchy.
- *
- * @property autofillHighlightColor the color used to draw the background behind autofilled
- *   elements.
+ * Returns the color used to indicate Autofill has been performed on fillable components on Android.
  */
-@JvmInline
-actual value class AutofillHighlight actual constructor(actual val autofillHighlightColor: Color) {
-    actual companion object {
-        /** Default color used is framework's "autofilled_highlight" color. */
-        private val DefaultAutofillColor = Color(0x4dffeb3b)
-
-        /** Default instance of [AutofillHighlight]. */
-        actual val Default = AutofillHighlight(DefaultAutofillColor)
-    }
+internal actual fun autofillHighlightColor(): Color {
+    return Color(0x4dffeb3b)
 }
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/AutofillHighlight.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/AutofillHighlight.kt
index 7672491..47afc00c 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/AutofillHighlight.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/AutofillHighlight.kt
@@ -18,23 +18,15 @@
 
 import androidx.compose.runtime.compositionLocalOf
 import androidx.compose.ui.graphics.Color
-import kotlin.jvm.JvmInline
 
 /**
- * Represents the colors used for text selection by text and text field components.
+ * Returns the color used to indicate Autofill has been performed on fillable components.
  *
- * See [LocalAutofillHighlight] to provide new values for this throughout the hierarchy.
- *
- * @property autofillHighlightColor the color used to draw the background behind autofilled
- *   elements.
+ * See [LocalAutofillHighlightColor] to provide new values for this throughout the hierarchy.
  */
-@JvmInline
-expect value class AutofillHighlight(val autofillHighlightColor: Color) {
-    companion object {
-        /** Default instance of [AutofillHighlight]. */
-        val Default: AutofillHighlight
-    }
-}
+internal expect fun autofillHighlightColor(): Color
 
-/** CompositionLocal used to change the [AutofillHighlight] used by components in the hierarchy. */
-val LocalAutofillHighlight = compositionLocalOf { AutofillHighlight.Default }
+/**
+ * CompositionLocal used to change the [autofillHighlightColor] used by components in the hierarchy.
+ */
+val LocalAutofillHighlightColor = compositionLocalOf { autofillHighlightColor() }
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/CoreTextField.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/CoreTextField.kt
index 2f2be03..509d360 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/CoreTextField.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/CoreTextField.kt
@@ -673,11 +673,11 @@
             }
         }
 
-    val autofillHighlight = LocalAutofillHighlight.current
+    val autofillHighlightColor = LocalAutofillHighlightColor.current
     val drawDecorationModifier =
         Modifier.drawBehind {
             if (state.autofillHighlightOn || state.justAutofilled) {
-                drawRect(color = autofillHighlight.autofillHighlightColor)
+                drawRect(color = autofillHighlightColor)
             }
         }
 
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/internal/TextFieldDecoratorModifier.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/internal/TextFieldDecoratorModifier.kt
index af7ade2..2f590be 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/internal/TextFieldDecoratorModifier.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/internal/TextFieldDecoratorModifier.kt
@@ -29,7 +29,7 @@
 import androidx.compose.foundation.text.Handle
 import androidx.compose.foundation.text.KeyboardActionScope
 import androidx.compose.foundation.text.KeyboardOptions
-import androidx.compose.foundation.text.LocalAutofillHighlight
+import androidx.compose.foundation.text.LocalAutofillHighlightColor
 import androidx.compose.foundation.text.input.InputTransformation
 import androidx.compose.foundation.text.input.KeyboardActionHandler
 import androidx.compose.foundation.text.input.internal.selection.TextFieldSelectionState
@@ -386,7 +386,7 @@
         // Autofill highlight is drawn on top of the content — this way the coloring appears over
         // any Material background applied.
         if (autofillHighlightOn) {
-            drawRect(color = currentValueOf(LocalAutofillHighlight).autofillHighlightColor)
+            drawRect(color = currentValueOf(LocalAutofillHighlightColor))
         }
     }
 
diff --git a/compose/foundation/foundation/src/commonStubsMain/kotlin/androidx/compose/foundation/text/AutofillHighlight.commonStubs.kt b/compose/foundation/foundation/src/commonStubsMain/kotlin/androidx/compose/foundation/text/AutofillHighlight.commonStubs.kt
index b423b1f..178f0ec 100644
--- a/compose/foundation/foundation/src/commonStubsMain/kotlin/androidx/compose/foundation/text/AutofillHighlight.commonStubs.kt
+++ b/compose/foundation/foundation/src/commonStubsMain/kotlin/androidx/compose/foundation/text/AutofillHighlight.commonStubs.kt
@@ -16,22 +16,8 @@
 
 package androidx.compose.foundation.text
 
+import androidx.compose.foundation.implementedInJetBrainsFork
 import androidx.compose.ui.graphics.Color
-import kotlin.jvm.JvmInline
 
-/**
- * Represents the colors used for text selection by text and text field components.
- *
- * See [LocalAutofillHighlight] to provide new values for this throughout the hierarchy.
- *
- * @property autofillHighlightColor the color used to draw the background behind autofilled
- *   elements.
- */
-@JvmInline
-actual value class AutofillHighlight actual constructor(actual val autofillHighlightColor: Color) {
-    actual companion object {
-        /** Default instance of [AutofillHighlight]. */
-        actual val Default: AutofillHighlight
-            get() = TODO("Not yet implemented")
-    }
-}
+/** Returns the color used to indicate Autofill has been performed on fillable components. */
+internal actual fun autofillHighlightColor(): Color = implementedInJetBrainsFork()
diff --git a/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/ColorUtilTest.kt b/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/ColorUtilTest.kt
index c308447..ea61501 100644
--- a/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/ColorUtilTest.kt
+++ b/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/ColorUtilTest.kt
@@ -20,9 +20,10 @@
 import androidx.compose.material3.internal.colorUtil.CamUtils.lstarFromInt
 import androidx.compose.material3.internal.colorUtil.CamUtils.yFromLstar
 import androidx.compose.material3.internal.colorUtil.Frame
+import androidx.compose.material3.tokens.ColorLightTokens
 import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.toArgb
 import androidx.test.ext.junit.runners.AndroidJUnit4
-import androidx.test.filters.LargeTest
 import androidx.test.filters.SmallTest
 import com.google.common.truth.Truth.assertThat
 import org.junit.Assert.assertEquals
@@ -330,10 +331,31 @@
         assertEquals(0.789f, vc.flRoot, 0.001f)
     }
 
-    @LargeTest
     @Test
     fun testHctReflexivity() {
-        for (i in 0..0x00ffffff) {
+        val baseline =
+            listOf(
+                SURFACE,
+                SURFACE_CONTAINER,
+                ON_SURFACE,
+                PRIMARY,
+                ON_PRIMARY,
+                SECONDARY,
+                ON_SECONDARY,
+                TERTIARY,
+                ON_TERTIARY,
+                PRIMARY_CONTAINER,
+                ON_PRIMARY_CONTAINER,
+                SECONDARY_CONTAINER,
+                ON_SECONDARY_CONTAINER,
+                TERTIARY_CONTAINER,
+                ON_TERTIARY_CONTAINER,
+                ERROR,
+                ON_ERROR,
+                ERROR_CONTAINER,
+                ON_ERROR_CONTAINER,
+            )
+        for (i in baseline) {
             val color = -0x1000000 or i
             val hct: Cam = Cam.fromInt(color)
             val reconstructedFromHct: Int = Cam.getInt(hct.hue, hct.chroma, lstarFromInt(color))
@@ -357,6 +379,26 @@
         const val RED: Int = -0x10000
         const val GREEN: Int = -0xff0100
         const val BLUE: Int = -0xffff01
+
+        val SURFACE: Int = ColorLightTokens.Surface.toArgb()
+        val SURFACE_CONTAINER: Int = ColorLightTokens.SurfaceContainer.toArgb()
+        val ON_SURFACE: Int = ColorLightTokens.OnSurface.toArgb()
+        val PRIMARY: Int = ColorLightTokens.Primary.toArgb()
+        val ON_PRIMARY: Int = ColorLightTokens.OnPrimary.toArgb()
+        val SECONDARY: Int = ColorLightTokens.Secondary.toArgb()
+        val ON_SECONDARY: Int = ColorLightTokens.OnSecondary.toArgb()
+        val TERTIARY: Int = ColorLightTokens.Tertiary.toArgb()
+        val ON_TERTIARY: Int = ColorLightTokens.OnTertiary.toArgb()
+        val PRIMARY_CONTAINER: Int = ColorLightTokens.PrimaryContainer.toArgb()
+        val ON_PRIMARY_CONTAINER: Int = ColorLightTokens.OnPrimaryContainer.toArgb()
+        val SECONDARY_CONTAINER: Int = ColorLightTokens.SecondaryContainer.toArgb()
+        val ON_SECONDARY_CONTAINER: Int = ColorLightTokens.OnSecondaryContainer.toArgb()
+        val TERTIARY_CONTAINER: Int = ColorLightTokens.TertiaryContainer.toArgb()
+        val ON_TERTIARY_CONTAINER: Int = ColorLightTokens.OnTertiaryContainer.toArgb()
+        val ERROR: Int = ColorLightTokens.Error.toArgb()
+        val ON_ERROR: Int = ColorLightTokens.OnError.toArgb()
+        val ERROR_CONTAINER: Int = ColorLightTokens.ErrorContainer.toArgb()
+        val ON_ERROR_CONTAINER: Int = ColorLightTokens.OnErrorContainer.toArgb()
     }
 
     private fun assertColorWithinTolerance(expected: Color, actual: Color, tolerance: Float = 1f) {
diff --git a/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/DateInputTest.kt b/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/DateInputTest.kt
index 08375be..14f5347 100644
--- a/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/DateInputTest.kt
+++ b/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/DateInputTest.kt
@@ -130,7 +130,7 @@
                 delayCompleted = true
             }
         }
-        rule.waitUntil("Waiting for focus", 3_000L) { delayCompleted }
+        rule.waitUntil("Waiting for focus", 5_000L) { delayCompleted }
         rule.onNodeWithText("05/11/2010").assertIsFocused()
     }
 
@@ -155,7 +155,7 @@
                 delayCompleted = true
             }
         }
-        rule.waitUntil("Waiting for delay completion", 3_000L) { delayCompleted }
+        rule.waitUntil("Waiting for delay completion", 5_000L) { delayCompleted }
         rule.onNodeWithText("05/11/2010").assertIsNotFocused()
     }
 
diff --git a/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/DateRangeInputTest.kt b/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/DateRangeInputTest.kt
index b6665d9..5085715 100644
--- a/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/DateRangeInputTest.kt
+++ b/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/DateRangeInputTest.kt
@@ -147,7 +147,7 @@
                 delayCompleted = true
             }
         }
-        rule.waitUntil("Waiting for focus", 3_000L) { delayCompleted }
+        rule.waitUntil("Waiting for focus", 5_000L) { delayCompleted }
         rule.onNodeWithText("05/11/2010").assertIsFocused()
         rule.onNodeWithText("10/20/2020").assertIsNotFocused()
     }
@@ -177,7 +177,7 @@
                 delayCompleted = true
             }
         }
-        rule.waitUntil("Waiting for delay completion", 3_000L) { delayCompleted }
+        rule.waitUntil("Waiting for delay completion", 5_000L) { delayCompleted }
         rule.onNodeWithText("05/11/2010").assertIsNotFocused()
         rule.onNodeWithText("10/20/2020").assertIsNotFocused()
     }
diff --git a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/ModalBottomSheet.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/ModalBottomSheet.kt
index 995fe3f..a37aae1 100644
--- a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/ModalBottomSheet.kt
+++ b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/ModalBottomSheet.kt
@@ -277,62 +277,11 @@
 
     Surface(
         modifier =
-            modifier
+            Modifier.bottomSheetNestedScroll(sheetGesturesEnabled, sheetState, settleToDismiss)
+                .bottomSheetDraggableAnchors(sheetGesturesEnabled, sheetState, settleToDismiss)
                 .align(Alignment.TopCenter)
                 .widthIn(max = sheetMaxWidth)
                 .fillMaxWidth()
-                .then(
-                    if (sheetGesturesEnabled)
-                        Modifier.nestedScroll(
-                            remember(sheetState) {
-                                ConsumeSwipeWithinBottomSheetBoundsNestedScrollConnection(
-                                    sheetState = sheetState,
-                                    orientation = Orientation.Vertical,
-                                    onFling = settleToDismiss
-                                )
-                            }
-                        )
-                    else Modifier
-                )
-                .draggableAnchors(sheetState.anchoredDraggableState, Orientation.Vertical) {
-                    sheetSize,
-                    constraints ->
-                    val fullHeight = constraints.maxHeight.toFloat()
-                    val newAnchors = DraggableAnchors {
-                        Hidden at fullHeight
-                        if (
-                            sheetSize.height > (fullHeight / 2) && !sheetState.skipPartiallyExpanded
-                        ) {
-                            PartiallyExpanded at fullHeight / 2f
-                        }
-                        if (sheetSize.height != 0) {
-                            Expanded at max(0f, fullHeight - sheetSize.height)
-                        }
-                    }
-                    val newTarget =
-                        when (sheetState.anchoredDraggableState.targetValue) {
-                            Hidden -> Hidden
-                            PartiallyExpanded -> {
-                                val hasPartiallyExpandedState =
-                                    newAnchors.hasAnchorFor(PartiallyExpanded)
-                                val newTarget =
-                                    if (hasPartiallyExpandedState) PartiallyExpanded
-                                    else if (newAnchors.hasAnchorFor(Expanded)) Expanded else Hidden
-                                newTarget
-                            }
-                            Expanded -> {
-                                if (newAnchors.hasAnchorFor(Expanded)) Expanded else Hidden
-                            }
-                        }
-                    return@draggableAnchors newAnchors to newTarget
-                }
-                .draggable(
-                    state = sheetState.anchoredDraggableState.draggableState,
-                    orientation = Orientation.Vertical,
-                    enabled = sheetGesturesEnabled && sheetState.isVisible,
-                    startDragImmediately = sheetState.anchoredDraggableState.isAnimationRunning,
-                    onDragStopped = { settleToDismiss(it) }
-                )
                 .semantics {
                     paneTitle = bottomSheetPaneTitle
                     traversalIndex = 0f
@@ -352,7 +301,8 @@
                 // min anchor. This is done to avoid showing a gap when the sheet opens and bounces
                 // when it's applied with a bouncy motion. Note that the content inside the Surface
                 // is scaled back down to maintain its aspect ratio (see below).
-                .verticalScaleUp(sheetState),
+                .verticalScaleUp(sheetState)
+                .then(modifier),
         shape = shape,
         color = containerColor,
         contentColor = contentColor,
@@ -536,6 +486,80 @@
     }
 }
 
+/**
+ * Provides custom bottom sheet [nestedScroll] behavior between the sheet's [draggable] modifier and
+ * the sheets scrollable content.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun Modifier.bottomSheetNestedScroll(
+    sheetGesturesEnabled: Boolean,
+    sheetState: SheetState,
+    settleToDismiss: (velocity: Float) -> Unit,
+): Modifier {
+    return if (!sheetGesturesEnabled) {
+        this
+    } else {
+        this.nestedScroll(
+            remember(sheetState) {
+                ConsumeSwipeWithinBottomSheetBoundsNestedScrollConnection(
+                    sheetState = sheetState,
+                    orientation = Orientation.Vertical,
+                    onFling = settleToDismiss
+                )
+            }
+        )
+    }
+}
+
+/**
+ * Provides the bottom sheet's anchor points on the screen and [draggable] behavior between them.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun Modifier.bottomSheetDraggableAnchors(
+    sheetGesturesEnabled: Boolean,
+    sheetState: SheetState,
+    settleToDismiss: (velocity: Float) -> Unit
+): Modifier {
+    return this.draggableAnchors(sheetState.anchoredDraggableState, Orientation.Vertical) {
+            sheetSize,
+            constraints ->
+            val fullHeight = constraints.maxHeight.toFloat()
+            val newAnchors = DraggableAnchors {
+                Hidden at fullHeight
+                if (sheetSize.height > (fullHeight / 2) && !sheetState.skipPartiallyExpanded) {
+                    PartiallyExpanded at fullHeight / 2f
+                }
+                if (sheetSize.height != 0) {
+                    Expanded at max(0f, fullHeight - sheetSize.height)
+                }
+            }
+            val newTarget =
+                when (sheetState.anchoredDraggableState.targetValue) {
+                    Hidden -> Hidden
+                    PartiallyExpanded -> {
+                        val hasPartiallyExpandedState = newAnchors.hasAnchorFor(PartiallyExpanded)
+                        val newTarget =
+                            if (hasPartiallyExpandedState) PartiallyExpanded
+                            else if (newAnchors.hasAnchorFor(Expanded)) Expanded else Hidden
+                        newTarget
+                    }
+                    Expanded -> {
+                        if (newAnchors.hasAnchorFor(Expanded)) Expanded else Hidden
+                    }
+                }
+            return@draggableAnchors newAnchors to newTarget
+        }
+        .draggable(
+            state = sheetState.anchoredDraggableState.draggableState,
+            orientation = Orientation.Vertical,
+            enabled = sheetGesturesEnabled && sheetState.isVisible,
+            startDragImmediately = sheetState.anchoredDraggableState.isAnimationRunning,
+            onDragStopped = { settleToDismiss(it) }
+        )
+}
+
 @OptIn(ExperimentalMaterial3Api::class)
 @Composable
 internal expect fun ModalBottomSheetDialog(
diff --git a/compose/runtime/runtime/api/1.8.0-beta01.txt b/compose/runtime/runtime/api/1.8.0-beta01.txt
index 8842558..c20e335 100644
--- a/compose/runtime/runtime/api/1.8.0-beta01.txt
+++ b/compose/runtime/runtime/api/1.8.0-beta01.txt
@@ -974,22 +974,8 @@
   }
 
   public final class SnapshotId_jvmKt {
-    method public static inline operator int compareTo(long, int other);
-    method public static inline operator int compareTo(long, long other);
-    method public static inline operator long div(long, int other);
-    method public static inline operator long minus(long, int other);
-    method public static inline operator long minus(long, long other);
-    method public static inline operator long plus(long, int other);
-    method public static inline operator long times(long, int other);
     method public static inline int toInt(long);
-    property public static final long SnapshotIdInvalidValue;
-    property public static final long SnapshotIdMax;
-    property public static final int SnapshotIdSize;
-    property public static final long SnapshotIdZero;
-    field public static final long SnapshotIdInvalidValue = -1L; // 0xffffffffffffffffL
-    field public static final long SnapshotIdMax = 9223372036854775807L; // 0x7fffffffffffffffL
-    field public static final int SnapshotIdSize = 64; // 0x40
-    field public static final long SnapshotIdZero = 0L; // 0x0L
+    method public static inline long toLong(long);
   }
 
   public final class SnapshotKt {
diff --git a/compose/runtime/runtime/api/current.ignore b/compose/runtime/runtime/api/current.ignore
index 75715a1..c57a7e7 100644
--- a/compose/runtime/runtime/api/current.ignore
+++ b/compose/runtime/runtime/api/current.ignore
@@ -1,11 +1,29 @@
 // Baseline format: 1.0
-AddedAbstractMethod: androidx.compose.runtime.ControlledComposition#getAndSetShouldPauseCallback(androidx.compose.runtime.ShouldPauseCallback):
-    Added method androidx.compose.runtime.ControlledComposition.getAndSetShouldPauseCallback(androidx.compose.runtime.ShouldPauseCallback)
+AddedMethod: androidx.compose.runtime.snapshots.SnapshotId_jvmKt#toLong(long):
+    Added method androidx.compose.runtime.snapshots.SnapshotId_jvmKt.toLong(long)
 
 
-BecameUnchecked: androidx.compose.runtime.Composer#compoundKeyHash:
-    Removed property Composer.compoundKeyHash from compatibility checked API surface
-BecameUnchecked: androidx.compose.runtime.Composer#recomposeScope:
-    Removed property Composer.recomposeScope from compatibility checked API surface
-BecameUnchecked: androidx.compose.runtime.internal.LiveLiteralKt#isLiveLiteralsEnabled:
-    Removed property LiveLiteralKt.isLiveLiteralsEnabled from compatibility checked API surface
+RemovedField: androidx.compose.runtime.snapshots.SnapshotId_jvmKt#SnapshotIdInvalidValue:
+    Removed field androidx.compose.runtime.snapshots.SnapshotId_jvmKt.SnapshotIdInvalidValue
+RemovedField: androidx.compose.runtime.snapshots.SnapshotId_jvmKt#SnapshotIdMax:
+    Removed field androidx.compose.runtime.snapshots.SnapshotId_jvmKt.SnapshotIdMax
+RemovedField: androidx.compose.runtime.snapshots.SnapshotId_jvmKt#SnapshotIdSize:
+    Removed field androidx.compose.runtime.snapshots.SnapshotId_jvmKt.SnapshotIdSize
+RemovedField: androidx.compose.runtime.snapshots.SnapshotId_jvmKt#SnapshotIdZero:
+    Removed field androidx.compose.runtime.snapshots.SnapshotId_jvmKt.SnapshotIdZero
+
+
+RemovedMethod: androidx.compose.runtime.snapshots.SnapshotId_jvmKt#compareTo(long, int):
+    Removed method androidx.compose.runtime.snapshots.SnapshotId_jvmKt.compareTo(long,int)
+RemovedMethod: androidx.compose.runtime.snapshots.SnapshotId_jvmKt#compareTo(long, long):
+    Removed method androidx.compose.runtime.snapshots.SnapshotId_jvmKt.compareTo(long,long)
+RemovedMethod: androidx.compose.runtime.snapshots.SnapshotId_jvmKt#div(long, int):
+    Removed method androidx.compose.runtime.snapshots.SnapshotId_jvmKt.div(long,int)
+RemovedMethod: androidx.compose.runtime.snapshots.SnapshotId_jvmKt#minus(long, int):
+    Removed method androidx.compose.runtime.snapshots.SnapshotId_jvmKt.minus(long,int)
+RemovedMethod: androidx.compose.runtime.snapshots.SnapshotId_jvmKt#minus(long, long):
+    Removed method androidx.compose.runtime.snapshots.SnapshotId_jvmKt.minus(long,long)
+RemovedMethod: androidx.compose.runtime.snapshots.SnapshotId_jvmKt#plus(long, int):
+    Removed method androidx.compose.runtime.snapshots.SnapshotId_jvmKt.plus(long,int)
+RemovedMethod: androidx.compose.runtime.snapshots.SnapshotId_jvmKt#times(long, int):
+    Removed method androidx.compose.runtime.snapshots.SnapshotId_jvmKt.times(long,int)
diff --git a/compose/runtime/runtime/api/current.txt b/compose/runtime/runtime/api/current.txt
index 8842558..c20e335 100644
--- a/compose/runtime/runtime/api/current.txt
+++ b/compose/runtime/runtime/api/current.txt
@@ -974,22 +974,8 @@
   }
 
   public final class SnapshotId_jvmKt {
-    method public static inline operator int compareTo(long, int other);
-    method public static inline operator int compareTo(long, long other);
-    method public static inline operator long div(long, int other);
-    method public static inline operator long minus(long, int other);
-    method public static inline operator long minus(long, long other);
-    method public static inline operator long plus(long, int other);
-    method public static inline operator long times(long, int other);
     method public static inline int toInt(long);
-    property public static final long SnapshotIdInvalidValue;
-    property public static final long SnapshotIdMax;
-    property public static final int SnapshotIdSize;
-    property public static final long SnapshotIdZero;
-    field public static final long SnapshotIdInvalidValue = -1L; // 0xffffffffffffffffL
-    field public static final long SnapshotIdMax = 9223372036854775807L; // 0x7fffffffffffffffL
-    field public static final int SnapshotIdSize = 64; // 0x40
-    field public static final long SnapshotIdZero = 0L; // 0x0L
+    method public static inline long toLong(long);
   }
 
   public final class SnapshotKt {
diff --git a/compose/runtime/runtime/api/restricted_1.8.0-beta01.txt b/compose/runtime/runtime/api/restricted_1.8.0-beta01.txt
index 45e66b0..a5104eee 100644
--- a/compose/runtime/runtime/api/restricted_1.8.0-beta01.txt
+++ b/compose/runtime/runtime/api/restricted_1.8.0-beta01.txt
@@ -1037,22 +1037,8 @@
   }
 
   public final class SnapshotId_jvmKt {
-    method public static inline operator int compareTo(long, int other);
-    method public static inline operator int compareTo(long, long other);
-    method public static inline operator long div(long, int other);
-    method public static inline operator long minus(long, int other);
-    method public static inline operator long minus(long, long other);
-    method public static inline operator long plus(long, int other);
-    method public static inline operator long times(long, int other);
     method public static inline int toInt(long);
-    property public static final long SnapshotIdInvalidValue;
-    property public static final long SnapshotIdMax;
-    property public static final int SnapshotIdSize;
-    property public static final long SnapshotIdZero;
-    field public static final long SnapshotIdInvalidValue = -1L; // 0xffffffffffffffffL
-    field public static final long SnapshotIdMax = 9223372036854775807L; // 0x7fffffffffffffffL
-    field public static final int SnapshotIdSize = 64; // 0x40
-    field public static final long SnapshotIdZero = 0L; // 0x0L
+    method public static inline long toLong(long);
   }
 
   public final class SnapshotKt {
diff --git a/compose/runtime/runtime/api/restricted_current.ignore b/compose/runtime/runtime/api/restricted_current.ignore
index 75715a1..c57a7e7 100644
--- a/compose/runtime/runtime/api/restricted_current.ignore
+++ b/compose/runtime/runtime/api/restricted_current.ignore
@@ -1,11 +1,29 @@
 // Baseline format: 1.0
-AddedAbstractMethod: androidx.compose.runtime.ControlledComposition#getAndSetShouldPauseCallback(androidx.compose.runtime.ShouldPauseCallback):
-    Added method androidx.compose.runtime.ControlledComposition.getAndSetShouldPauseCallback(androidx.compose.runtime.ShouldPauseCallback)
+AddedMethod: androidx.compose.runtime.snapshots.SnapshotId_jvmKt#toLong(long):
+    Added method androidx.compose.runtime.snapshots.SnapshotId_jvmKt.toLong(long)
 
 
-BecameUnchecked: androidx.compose.runtime.Composer#compoundKeyHash:
-    Removed property Composer.compoundKeyHash from compatibility checked API surface
-BecameUnchecked: androidx.compose.runtime.Composer#recomposeScope:
-    Removed property Composer.recomposeScope from compatibility checked API surface
-BecameUnchecked: androidx.compose.runtime.internal.LiveLiteralKt#isLiveLiteralsEnabled:
-    Removed property LiveLiteralKt.isLiveLiteralsEnabled from compatibility checked API surface
+RemovedField: androidx.compose.runtime.snapshots.SnapshotId_jvmKt#SnapshotIdInvalidValue:
+    Removed field androidx.compose.runtime.snapshots.SnapshotId_jvmKt.SnapshotIdInvalidValue
+RemovedField: androidx.compose.runtime.snapshots.SnapshotId_jvmKt#SnapshotIdMax:
+    Removed field androidx.compose.runtime.snapshots.SnapshotId_jvmKt.SnapshotIdMax
+RemovedField: androidx.compose.runtime.snapshots.SnapshotId_jvmKt#SnapshotIdSize:
+    Removed field androidx.compose.runtime.snapshots.SnapshotId_jvmKt.SnapshotIdSize
+RemovedField: androidx.compose.runtime.snapshots.SnapshotId_jvmKt#SnapshotIdZero:
+    Removed field androidx.compose.runtime.snapshots.SnapshotId_jvmKt.SnapshotIdZero
+
+
+RemovedMethod: androidx.compose.runtime.snapshots.SnapshotId_jvmKt#compareTo(long, int):
+    Removed method androidx.compose.runtime.snapshots.SnapshotId_jvmKt.compareTo(long,int)
+RemovedMethod: androidx.compose.runtime.snapshots.SnapshotId_jvmKt#compareTo(long, long):
+    Removed method androidx.compose.runtime.snapshots.SnapshotId_jvmKt.compareTo(long,long)
+RemovedMethod: androidx.compose.runtime.snapshots.SnapshotId_jvmKt#div(long, int):
+    Removed method androidx.compose.runtime.snapshots.SnapshotId_jvmKt.div(long,int)
+RemovedMethod: androidx.compose.runtime.snapshots.SnapshotId_jvmKt#minus(long, int):
+    Removed method androidx.compose.runtime.snapshots.SnapshotId_jvmKt.minus(long,int)
+RemovedMethod: androidx.compose.runtime.snapshots.SnapshotId_jvmKt#minus(long, long):
+    Removed method androidx.compose.runtime.snapshots.SnapshotId_jvmKt.minus(long,long)
+RemovedMethod: androidx.compose.runtime.snapshots.SnapshotId_jvmKt#plus(long, int):
+    Removed method androidx.compose.runtime.snapshots.SnapshotId_jvmKt.plus(long,int)
+RemovedMethod: androidx.compose.runtime.snapshots.SnapshotId_jvmKt#times(long, int):
+    Removed method androidx.compose.runtime.snapshots.SnapshotId_jvmKt.times(long,int)
diff --git a/compose/runtime/runtime/api/restricted_current.txt b/compose/runtime/runtime/api/restricted_current.txt
index 45e66b0..a5104eee 100644
--- a/compose/runtime/runtime/api/restricted_current.txt
+++ b/compose/runtime/runtime/api/restricted_current.txt
@@ -1037,22 +1037,8 @@
   }
 
   public final class SnapshotId_jvmKt {
-    method public static inline operator int compareTo(long, int other);
-    method public static inline operator int compareTo(long, long other);
-    method public static inline operator long div(long, int other);
-    method public static inline operator long minus(long, int other);
-    method public static inline operator long minus(long, long other);
-    method public static inline operator long plus(long, int other);
-    method public static inline operator long times(long, int other);
     method public static inline int toInt(long);
-    property public static final long SnapshotIdInvalidValue;
-    property public static final long SnapshotIdMax;
-    property public static final int SnapshotIdSize;
-    property public static final long SnapshotIdZero;
-    field public static final long SnapshotIdInvalidValue = -1L; // 0xffffffffffffffffL
-    field public static final long SnapshotIdMax = 9223372036854775807L; // 0x7fffffffffffffffL
-    field public static final int SnapshotIdSize = 64; // 0x40
-    field public static final long SnapshotIdZero = 0L; // 0x0L
+    method public static inline long toLong(long);
   }
 
   public final class SnapshotKt {
diff --git a/compose/runtime/runtime/compose-runtime-benchmark/src/androidTest/java/androidx/compose/runtime/benchmark/SnapshotStateObserverBenchmark.kt b/compose/runtime/runtime/compose-runtime-benchmark/src/androidTest/java/androidx/compose/runtime/benchmark/SnapshotStateObserverBenchmark.kt
index e75b154..4b6d834 100644
--- a/compose/runtime/runtime/compose-runtime-benchmark/src/androidTest/java/androidx/compose/runtime/benchmark/SnapshotStateObserverBenchmark.kt
+++ b/compose/runtime/runtime/compose-runtime-benchmark/src/androidTest/java/androidx/compose/runtime/benchmark/SnapshotStateObserverBenchmark.kt
@@ -19,7 +19,7 @@
 import android.os.Build
 import android.os.Handler
 import android.os.Looper
-import androidx.benchmark.junit4.measureRepeated
+import androidx.benchmark.junit4.measureRepeatedOnMainThread
 import androidx.compose.runtime.MutableState
 import androidx.compose.runtime.derivedStateOf
 import androidx.compose.runtime.mutableStateOf
@@ -77,59 +77,51 @@
     @Test
     fun modelObservation() {
         assumeTrue(Build.VERSION.SDK_INT != 29)
-        runOnUiThread {
-            benchmarkRule.measureRepeated {
-                runWithTimingDisabled {
-                    nodes.forEach { node -> stateObserver.clear(node) }
-                    random = Random(0)
-                }
-                setupObservations()
+        benchmarkRule.measureRepeatedOnMainThread {
+            runWithTimingDisabled {
+                nodes.forEach { node -> stateObserver.clear(node) }
+                random = Random(0)
             }
+            setupObservations()
         }
     }
 
     @Test
     fun nestedModelObservation() {
         assumeTrue(Build.VERSION.SDK_INT != 29)
-        runOnUiThread {
-            val list = mutableListOf<Any>()
-            repeat(10) { list += nodes[random.nextInt(ScopeCount)] }
-            benchmarkRule.measureRepeated {
-                runWithTimingDisabled {
-                    random = Random(0)
-                    nodes.forEach { node -> stateObserver.clear(node) }
-                }
-                stateObserver.observeReads(nodes[0], doNothing) {
-                    list.forEach { node -> observeForNode(node) }
-                }
+        val list = mutableListOf<Any>()
+        repeat(10) { list += nodes[random.nextInt(ScopeCount)] }
+        benchmarkRule.measureRepeatedOnMainThread {
+            runWithTimingDisabled {
+                random = Random(0)
+                nodes.forEach { node -> stateObserver.clear(node) }
+            }
+            stateObserver.observeReads(nodes[0], doNothing) {
+                list.forEach { node -> observeForNode(node) }
             }
         }
     }
 
     @Test
     fun derivedStateObservation() {
+        val node = Any()
+        val states = models.take(3)
+        val derivedState = derivedStateOf { states[0].value + states[1].value + states[2].value }
         runOnUiThread {
-            val node = Any()
-            val states = models.take(3)
-            val derivedState = derivedStateOf {
-                states[0].value + states[1].value + states[2].value
+            stateObserver.observeReads(node, doNothing) {
+                // read derived state a few times
+                repeat(10) { derivedState.value }
             }
-
+        }
+        benchmarkRule.measureRepeatedOnMainThread {
             stateObserver.observeReads(node, doNothing) {
                 // read derived state a few times
                 repeat(10) { derivedState.value }
             }
 
-            benchmarkRule.measureRepeated {
-                stateObserver.observeReads(node, doNothing) {
-                    // read derived state a few times
-                    repeat(10) { derivedState.value }
-                }
-
-                runWithTimingDisabled {
-                    states.forEach { it.value += 1 }
-                    Snapshot.sendApplyNotifications()
-                }
+            runWithTimingDisabled {
+                states.forEach { it.value += 1 }
+                Snapshot.sendApplyNotifications()
             }
         }
     }
@@ -137,71 +129,60 @@
     @Test
     fun deeplyNestedModelObservations() {
         assumeTrue(Build.VERSION.SDK_INT != 29)
-        runOnUiThread {
-            val list = mutableListOf<Any>()
-            repeat(100) { list += nodes[random.nextInt(ScopeCount)] }
+        val list = mutableListOf<Any>()
+        repeat(100) { list += nodes[random.nextInt(ScopeCount)] }
 
-            fun observeRecursive(index: Int) {
-                if (index == 100) return
-                val node = list[index]
-                stateObserver.observeReads(node, doNothing) {
-                    observeForNode(node)
-                    observeRecursive(index + 1)
-                }
+        fun observeRecursive(index: Int) {
+            if (index == 100) return
+            val node = list[index]
+            stateObserver.observeReads(node, doNothing) {
+                observeForNode(node)
+                observeRecursive(index + 1)
             }
+        }
 
-            benchmarkRule.measureRepeated {
-                runWithTimingDisabled {
-                    random = Random(0)
-                    nodes.forEach { node -> stateObserver.clear(node) }
-                }
-                observeRecursive(0)
+        benchmarkRule.measureRepeatedOnMainThread {
+            runWithTimingDisabled {
+                random = Random(0)
+                nodes.forEach { node -> stateObserver.clear(node) }
             }
+            observeRecursive(0)
         }
     }
 
     @Test
     fun modelClear() {
         assumeTrue(Build.VERSION.SDK_INT != 29)
-        runOnUiThread {
-            val nodeSet = hashSetOf<Any>()
-            nodeSet.addAll(nodes)
-
-            benchmarkRule.measureRepeated {
-                stateObserver.clearIf { node -> node in nodeSet }
-                random = Random(0)
-                runWithTimingDisabled { setupObservations() }
-            }
+        val nodeSet = hashSetOf<Any>()
+        nodeSet.addAll(nodes)
+        benchmarkRule.measureRepeatedOnMainThread {
+            stateObserver.clearIf { node -> node in nodeSet }
+            random = Random(0)
+            runWithTimingDisabled { setupObservations() }
         }
     }
 
     @Test
     fun modelIncrementalClear() {
         assumeTrue(Build.VERSION.SDK_INT != 29)
-        runOnUiThread {
-            benchmarkRule.measureRepeated {
-                for (i in 0 until nodes.size) {
-                    stateObserver.clearIf { node -> (node as Int) < i }
-                }
-                runWithTimingDisabled { setupObservations() }
-            }
+        benchmarkRule.measureRepeatedOnMainThread {
+            repeat(nodes.size) { i -> stateObserver.clearIf { node -> (node as Int) < i } }
+            runWithTimingDisabled { setupObservations() }
         }
     }
 
     @Test
     fun notifyChanges() {
         assumeTrue(Build.VERSION.SDK_INT != 29)
-        runOnUiThread {
-            val states = mutableSetOf<Int>()
-            repeat(50) { states += random.nextInt(StateCount) }
-            val snapshot: Snapshot = Snapshot.current
-            benchmarkRule.measureRepeated {
-                random = Random(0)
-                stateObserver.notifyChanges(states, snapshot)
-                runWithTimingDisabled {
-                    stateObserver.clear()
-                    setupObservations()
-                }
+        val states = mutableSetOf<Int>()
+        repeat(50) { states += random.nextInt(StateCount) }
+        val snapshot: Snapshot = Snapshot.current
+        benchmarkRule.measureRepeatedOnMainThread {
+            random = Random(0)
+            stateObserver.notifyChanges(states, snapshot)
+            runWithTimingDisabled {
+                stateObserver.clear()
+                setupObservations()
             }
         }
     }
diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Composer.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Composer.kt
index c8fc0af..abd9693 100644
--- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Composer.kt
+++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Composer.kt
@@ -17,6 +17,7 @@
 @file:OptIn(
     InternalComposeApi::class,
 )
+@file:Suppress("NOTHING_TO_INLINE", "KotlinRedundantDiagnosticSuppress")
 
 package androidx.compose.runtime
 
@@ -4106,7 +4107,6 @@
         }
     }
 
-    @Suppress("NOTHING_TO_INLINE")
     private inline fun updateCompoundKeyWhenWeEnterGroup(
         groupKey: Int,
         rGroupIndex: Int,
@@ -4121,12 +4121,10 @@
         else updateCompoundKeyWhenWeEnterGroupKeyHash(dataKey.hashCode(), 0)
     }
 
-    @Suppress("NOTHING_TO_INLINE")
     private inline fun updateCompoundKeyWhenWeEnterGroupKeyHash(keyHash: Int, rGroupIndex: Int) {
         compoundKeyHash = (((compoundKeyHash rol 3) xor keyHash) rol 3) xor rGroupIndex
     }
 
-    @Suppress("NOTHING_TO_INLINE")
     private inline fun updateCompoundKeyWhenWeExitGroup(
         groupKey: Int,
         rGroupIndex: Int,
@@ -4141,7 +4139,6 @@
         else updateCompoundKeyWhenWeExitGroupKeyHash(dataKey.hashCode(), 0)
     }
 
-    @Suppress("NOTHING_TO_INLINE")
     private inline fun updateCompoundKeyWhenWeExitGroupKeyHash(groupKey: Int, rGroupIndex: Int) {
         compoundKeyHash = (((compoundKeyHash xor rGroupIndex) ror 3) xor groupKey.hashCode()) ror 3
     }
@@ -4645,7 +4642,9 @@
     }
 }
 
-internal fun runtimeCheck(value: Boolean) = runtimeCheck(value) { "Check failed" }
+internal inline fun debugRuntimeCheck(value: Boolean) = debugRuntimeCheck(value) { "Check failed" }
+
+internal inline fun runtimeCheck(value: Boolean) = runtimeCheck(value) { "Check failed" }
 
 internal fun composeRuntimeError(message: String): Nothing {
     throw ComposeRuntimeError(
diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/SlotTable.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/SlotTable.kt
index 01080e7..cdfa183 100644
--- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/SlotTable.kt
+++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/SlotTable.kt
@@ -3429,21 +3429,27 @@
         }
 }
 
+private val EmptyLongArray = LongArray(0)
+
 internal class BitVector {
     private var first: Long = 0
     private var second: Long = 0
-    private var others: LongArray? = null
+    private var others: LongArray = EmptyLongArray
 
     val size
-        get() = others.let { if (it != null) (it.size + 2) * 64 else 128 }
+        get() = (others.size + 2) * 64
 
     operator fun get(index: Int): Boolean {
-        if (index < 0 || index >= size) error("Index $index out of bound")
         if (index < 64) return first and (1L shl index) != 0L
         if (index < 128) return second and (1L shl (index - 64)) != 0L
-        val others = others ?: return false
+
+        val others = others
+        val size = others.size
+        if (size == 0) return false
+
         val address = (index / 64) - 2
-        if (address >= others.size) return false
+        if (address >= size) return false
+
         val bit = index % 64
         return (others[address] and (1L shl bit)) != 0L
     }
@@ -3451,48 +3457,101 @@
     operator fun set(index: Int, value: Boolean) {
         if (index < 64) {
             val mask = 1L shl index
-            first = if (value) first or mask else first and mask.inv()
+            first = (first and mask.inv()) or (value.toBit().toLong() shl index)
             return
         }
+
         if (index < 128) {
             val mask = 1L shl (index - 64)
-            second = if (value) second or mask else second and mask.inv()
+            second = (second and mask.inv()) or (value.toBit().toLong() shl index)
             return
         }
+
         val address = (index / 64) - 2
-        val mask = 1L shl (index % 64)
-        var others =
-            others
-                ?: run {
-                    val others = LongArray(address + 1)
-                    this.others = others
-                    others
-                }
+        val newIndex = index % 64
+        val mask = 1L shl newIndex
+        var others = others
         if (address >= others.size) {
             others = others.copyOf(address + 1)
             this.others = others
         }
+
         val bits = others[address]
-        others[address] = if (value) bits or mask else bits and mask.inv()
+        others[address] = (bits and mask.inv()) or (value.toBit().toLong() shl newIndex)
     }
 
-    fun nextSet(index: Int): Int {
-        val size = size
-        for (bit in index until size) {
-            if (this[bit]) return bit
+    fun nextSet(index: Int) = nextBit(index) { it }
+
+    fun nextClear(index: Int) = nextBit(index) { it.inv() }
+
+    /**
+     * Returns the index of the next bit in this bit vector, starting at index. The [valueSelector]
+     * lets the caller modify the value before finding its first bit set.
+     */
+    @Suppress("NAME_SHADOWING")
+    private inline fun nextBit(index: Int, valueSelector: (Long) -> Long): Int {
+        if (index < 64) {
+            // We shift right (unsigned) then back left to drop the first "index"
+            // bits. This will set them all to 0, thus guaranteeing that the search
+            // performed by [firstBitSet] will start at index
+            val bit = (valueSelector(first) ushr index shl index).firstBitSet
+            if (bit < 64) return bit
         }
+
+        if (index < 128) {
+            val index = index - 64
+            val bit = (valueSelector(second) ushr index shl index).firstBitSet
+            if (bit < 64) return 64 + bit
+        }
+
+        val index = max(index, 128)
+        val start = (index / 64) - 2
+        val others = others
+
+        for (i in start until others.size) {
+            var value = valueSelector(others[i])
+            // For the first element, the start index may be in the middle of the
+            // 128 bit word, so we apply the same shift trick as for [first] and
+            // [second] to start at the right spot in the bit field.
+            if (i == start) {
+                val shift = index % 64
+                value = value ushr shift shl shift
+            }
+            val bit = value.firstBitSet
+            if (bit < 64) return 128 + i * 64 + bit
+        }
+
         return Int.MAX_VALUE
     }
 
-    fun nextClear(index: Int): Int {
-        val size = size
-        for (bit in index until size) {
-            if (!this[bit]) return bit
-        }
-        return Int.MAX_VALUE
-    }
-
+    @Suppress("NAME_SHADOWING")
     fun setRange(start: Int, end: Int) {
+        var start = start
+
+        // If the range is valid we will use ~0L as our mask to create strings of 1s below,
+        // otherwise we use 0 so we don't set any bits. We could return when start >= end
+        // but this won't be a common case, so skip the branch
+        val bits = if (start < end) -1L else 0L
+
+        // Set the bits to 0 if we don't need to set any bit in the first word
+        var selector = bits * (start < 64).toBit()
+        // Take our selector (either all 0s or all 1s), perform an unsigned shift to the
+        // right to create a new word with "clampedEnd - start" bits, then shift it back
+        // left to where the range begins. This lets us set up to 64 bits at a time without
+        // doing an expensive loop that calls set()
+        val firstValue = (selector ushr (64 - (min(64, end) - start))) shl start
+        first = first or firstValue
+        // If we need to set bits in the second word, clamp our start otherwise return now
+        if (end > 64) start = max(start, 64) else return
+
+        // Set the bits to 0 if we don't need to set any bit in the second word
+        selector = bits * (start < 128).toBit()
+        // See firstValue above
+        val secondValue = (selector ushr (128 - (min(128, end) - start))) shl start
+        second = second or secondValue
+        // If we need to set bits in the remainder array, clamp our start otherwise return now
+        if (end > 128) start = max(start, 128) else return
+
         for (bit in start until end) this[bit] = true
     }
 
@@ -3510,6 +3569,9 @@
     }
 }
 
+private val Long.firstBitSet
+    inline get() = this.countTrailingZeroBits()
+
 private class SourceInformationGroupIterator(
     val table: SlotTable,
     val parent: Int,
@@ -3664,7 +3726,7 @@
 
 private fun IntArray.updateNodeCount(address: Int, value: Int) {
     @Suppress("ConvertTwoComparisonsToRangeCheck")
-    runtimeCheck(value >= 0 && value < NodeCount_Mask)
+    debugRuntimeCheck(value >= 0 && value < NodeCount_Mask)
     this[address * Group_Fields_Size + GroupInfo_Offset] =
         (this[address * Group_Fields_Size + GroupInfo_Offset] and NodeCount_Mask.inv()) or value
 }
@@ -3687,7 +3749,7 @@
 private fun IntArray.groupSize(address: Int) = this[address * Group_Fields_Size + Size_Offset]
 
 private fun IntArray.updateGroupSize(address: Int, value: Int) {
-    runtimeCheck(value >= 0)
+    debugRuntimeCheck(value >= 0)
     this[address * Group_Fields_Size + Size_Offset] = value
 }
 
@@ -3828,7 +3890,7 @@
 
     // Remove a de-duplicated value from the heap
     fun takeMax(): Int {
-        runtimeCheck(list.size > 0) { "Set is empty" }
+        debugRuntimeCheck(list.size > 0) { "Set is empty" }
         val value = list[0]
 
         // Skip duplicates. It is not time efficient to remove duplicates from the list while
diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.kt
index 2d2c9f6..584556d 100644
--- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.kt
+++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.kt
@@ -18,30 +18,40 @@
 
 package androidx.compose.runtime.snapshots
 
+/**
+ * The type of [Snapshot.snapshotId]. On most platforms this is a [Long] but may be a different type
+ * if the platform target does not support [Long] efficiently (such as JavaScript).
+ */
 expect class SnapshotId
 
-expect val SnapshotIdZero: SnapshotId
-expect val SnapshotIdMax: SnapshotId
-expect val SnapshotIdInvalidValue: SnapshotId
+internal expect val SnapshotIdZero: SnapshotId
+internal expect val SnapshotIdMax: SnapshotId
+internal expect val SnapshotIdInvalidValue: SnapshotId
 
-expect val SnapshotIdSize: Int
+internal expect val SnapshotIdSize: Int
 
-expect operator fun SnapshotId.compareTo(other: SnapshotId): Int
+internal expect operator fun SnapshotId.compareTo(other: SnapshotId): Int
 
-expect operator fun SnapshotId.compareTo(other: Int): Int
+internal expect operator fun SnapshotId.compareTo(other: Int): Int
 
-expect operator fun SnapshotId.plus(other: Int): SnapshotId
+internal expect operator fun SnapshotId.plus(other: Int): SnapshotId
 
-expect operator fun SnapshotId.minus(other: SnapshotId): SnapshotId
+internal expect operator fun SnapshotId.minus(other: SnapshotId): SnapshotId
 
-expect operator fun SnapshotId.minus(other: Int): SnapshotId
+internal expect operator fun SnapshotId.minus(other: Int): SnapshotId
 
-expect operator fun SnapshotId.div(other: Int): SnapshotId
+internal expect operator fun SnapshotId.div(other: Int): SnapshotId
 
-expect operator fun SnapshotId.times(other: Int): SnapshotId
+internal expect operator fun SnapshotId.times(other: Int): SnapshotId
 
 expect fun SnapshotId.toInt(): Int
 
+expect fun SnapshotId.toLong(): Long
+
+/**
+ * An array of [SnapshotId]. On most platforms this is an array of [Long] but may be a different
+ * type if the platform target does not support [Long] efficiently (such as JavaScript).
+ */
 expect class SnapshotIdArray
 
 internal expect fun snapshotIdArrayWithCapacity(capacity: Int): SnapshotIdArray
diff --git a/compose/runtime/runtime/src/jvmMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.jvm.kt b/compose/runtime/runtime/src/jvmMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.jvm.kt
index 4ba29a4..21a4643 100644
--- a/compose/runtime/runtime/src/jvmMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.jvm.kt
+++ b/compose/runtime/runtime/src/jvmMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.jvm.kt
@@ -22,27 +22,31 @@
 
 actual typealias SnapshotId = Long
 
-actual const val SnapshotIdZero: SnapshotId = 0L
-actual const val SnapshotIdMax: SnapshotId = Long.MAX_VALUE
-actual const val SnapshotIdSize: Int = Long.SIZE_BITS
-actual const val SnapshotIdInvalidValue: SnapshotId = -1
+internal actual const val SnapshotIdZero: SnapshotId = 0L
+internal actual const val SnapshotIdMax: SnapshotId = Long.MAX_VALUE
+internal actual const val SnapshotIdSize: Int = Long.SIZE_BITS
+internal actual const val SnapshotIdInvalidValue: SnapshotId = -1
 
-actual inline operator fun SnapshotId.compareTo(other: SnapshotId): Int = this.compareTo(other)
+internal actual inline operator fun SnapshotId.compareTo(other: SnapshotId): Int =
+    this.compareTo(other)
 
-actual inline operator fun SnapshotId.compareTo(other: Int): Int = this.compareTo(other.toLong())
+internal actual inline operator fun SnapshotId.compareTo(other: Int): Int =
+    this.compareTo(other.toLong())
 
-actual inline operator fun SnapshotId.plus(other: Int): SnapshotId = this + other.toLong()
+internal actual inline operator fun SnapshotId.plus(other: Int): SnapshotId = this + other.toLong()
 
-actual inline operator fun SnapshotId.minus(other: SnapshotId): SnapshotId = this - other
+internal actual inline operator fun SnapshotId.minus(other: SnapshotId): SnapshotId = this - other
 
-actual inline operator fun SnapshotId.minus(other: Int): SnapshotId = this - other.toLong()
+internal actual inline operator fun SnapshotId.minus(other: Int): SnapshotId = this - other.toLong()
 
-actual inline operator fun SnapshotId.div(other: Int): SnapshotId = this / other.toLong()
+internal actual inline operator fun SnapshotId.div(other: Int): SnapshotId = this / other.toLong()
 
-actual inline operator fun SnapshotId.times(other: Int): SnapshotId = this * other.toLong()
+internal actual inline operator fun SnapshotId.times(other: Int): SnapshotId = this * other.toLong()
 
 actual inline fun SnapshotId.toInt(): Int = this.toInt()
 
+actual inline fun SnapshotId.toLong(): Long = this
+
 actual typealias SnapshotIdArray = LongArray
 
 internal actual fun snapshotIdArrayWithCapacity(capacity: Int): SnapshotIdArray =
diff --git a/compose/runtime/runtime/src/linuxx64StubsMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.linuxx64Stubs.kt b/compose/runtime/runtime/src/linuxx64StubsMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.linuxx64Stubs.kt
index e87f933..c09eb4f 100644
--- a/compose/runtime/runtime/src/linuxx64StubsMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.linuxx64Stubs.kt
+++ b/compose/runtime/runtime/src/linuxx64StubsMain/kotlin/androidx/compose/runtime/snapshots/SnapshotId.linuxx64Stubs.kt
@@ -22,27 +22,31 @@
 
 actual typealias SnapshotId = Long
 
-actual const val SnapshotIdZero: SnapshotId = 0L
-actual const val SnapshotIdMax: SnapshotId = Long.MAX_VALUE
-actual const val SnapshotIdSize: Int = Long.SIZE_BITS
-actual const val SnapshotIdInvalidValue: SnapshotId = -1
+internal actual const val SnapshotIdZero: SnapshotId = 0L
+internal actual const val SnapshotIdMax: SnapshotId = Long.MAX_VALUE
+internal actual const val SnapshotIdSize: Int = Long.SIZE_BITS
+internal actual const val SnapshotIdInvalidValue: SnapshotId = -1
 
-actual operator fun SnapshotId.compareTo(other: SnapshotId): Int = implementedInJetBrainsFork()
+internal actual operator fun SnapshotId.compareTo(other: SnapshotId): Int =
+    implementedInJetBrainsFork()
 
-actual operator fun SnapshotId.compareTo(other: Int): Int = implementedInJetBrainsFork()
+internal actual operator fun SnapshotId.compareTo(other: Int): Int = implementedInJetBrainsFork()
 
-actual operator fun SnapshotId.plus(other: Int): SnapshotId = implementedInJetBrainsFork()
+internal actual operator fun SnapshotId.plus(other: Int): SnapshotId = implementedInJetBrainsFork()
 
-actual operator fun SnapshotId.minus(other: SnapshotId): SnapshotId = implementedInJetBrainsFork()
+internal actual operator fun SnapshotId.minus(other: SnapshotId): SnapshotId =
+    implementedInJetBrainsFork()
 
-actual operator fun SnapshotId.minus(other: Int): SnapshotId = implementedInJetBrainsFork()
+internal actual operator fun SnapshotId.minus(other: Int): SnapshotId = implementedInJetBrainsFork()
 
-actual operator fun SnapshotId.div(other: Int): SnapshotId = implementedInJetBrainsFork()
+internal actual operator fun SnapshotId.div(other: Int): SnapshotId = implementedInJetBrainsFork()
 
-actual operator fun SnapshotId.times(other: Int): SnapshotId = implementedInJetBrainsFork()
+internal actual operator fun SnapshotId.times(other: Int): SnapshotId = implementedInJetBrainsFork()
 
 actual fun SnapshotId.toInt(): Int = implementedInJetBrainsFork()
 
+actual fun SnapshotId.toLong(): Long = implementedInJetBrainsFork()
+
 actual typealias SnapshotIdArray = LongArray
 
 internal actual fun snapshotIdArrayWithCapacity(capacity: Int): SnapshotIdArray =
diff --git a/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/collection/BitVectorTests.kt b/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/collection/BitVectorTests.kt
index 9531177..019265d 100644
--- a/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/collection/BitVectorTests.kt
+++ b/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/collection/BitVectorTests.kt
@@ -50,10 +50,36 @@
 
     @Test
     fun canSetARange() {
-        val vector = BitVector()
-        vector.setRange(2, 30)
-        for (bit in 0 until vector.size) {
-            assertEquals(bit in 2 until 30, vector[bit])
+        val ranges =
+            listOf(
+                // Empty or inverted ranges
+                0 to 0,
+                66 to 66,
+                130 to 130,
+                4 to 2,
+                66 to 60,
+                130 to 128,
+                // 1 item ranges
+                5 to 6,
+                71 to 72,
+                132 to 132,
+                // Larger ranges that fit in a single word
+                2 to 30,
+                70 to 83,
+                130 to 150,
+                // Larger ranges that cross word boundaries
+                60 to 80,
+                120 to 140,
+                60 to 140
+            )
+
+        for (range in ranges) {
+            val vector = BitVector()
+            val (start, end) = range
+            vector.setRange(start, end)
+            for (bit in 0 until vector.size) {
+                assertEquals(bit in start until end, vector[bit])
+            }
         }
     }
 
@@ -62,27 +88,87 @@
         val vector = BitVector()
         vector.setRange(2, 5)
         vector.setRange(10, 12)
+        vector.setRange(80, 82)
+        vector.setRange(130, 132)
+        vector.setRange(260, 262)
+        vector.setRange(1030, 1032)
+
         val received = mutableListOf<Int>()
         var current = vector.nextSet(0)
         while (current < vector.size) {
             received.add(current)
             current = vector.nextSet(current + 1)
         }
-        assertEquals(listOf(2, 3, 4, 10, 11), received)
+
+        assertEquals(listOf(2, 3, 4, 10, 11, 80, 81, 130, 131, 260, 261, 1030, 1031), received)
     }
 
     @Test
     fun canFindTheNextClearBit() {
-        val max = 15
         val vector = BitVector()
         vector.setRange(2, 5)
         vector.setRange(10, 12)
+
+        var max = 15
         val received = mutableListOf<Int>()
         var current = vector.nextClear(0)
         while (current < max) {
             received.add(current)
             current = vector.nextClear(current + 1)
         }
+
         assertEquals(listOf(0, 1, 5, 6, 7, 8, 9, 12, 13, 14), received)
+
+        received.clear()
+        val vector2 = BitVector()
+        vector2.setRange(70, 72)
+
+        max = 74
+        current = vector2.nextClear(64)
+        while (current < max) {
+            received.add(current)
+            current = vector2.nextClear(current + 1)
+        }
+
+        assertEquals(listOf(64, 65, 66, 67, 68, 69, 72, 73), received)
+
+        received.clear()
+        val vector3 = BitVector()
+        vector3.setRange(128, 130)
+
+        max = 132
+        current = vector3.nextClear(126)
+        while (current < max) {
+            received.add(current)
+            current = vector3.nextClear(current + 1)
+        }
+
+        assertEquals(listOf(126, 127, 130, 131), received)
+
+        received.clear()
+        val vector4 = BitVector()
+        vector4.setRange(0, 256)
+
+        max = 260
+        current = vector4.nextClear(0)
+        while (current < max) {
+            received.add(current)
+            current = vector4.nextClear(current + 1)
+        }
+
+        assertTrue(received.isEmpty())
+
+        received.clear()
+        val vector5 = BitVector()
+        vector5.setRange(384, 512)
+
+        max = 260
+        current = vector5.nextClear(256)
+        while (current < max) {
+            received.add(current)
+            current = vector5.nextClear(current + 1)
+        }
+
+        assertEquals(listOf(256, 257, 258, 259), received)
     }
 }
diff --git a/compose/ui/ui/benchmark/src/androidTest/java/androidx/compose/ui/benchmark/LayoutNodeModifierBenchmark.kt b/compose/ui/ui/benchmark/src/androidTest/java/androidx/compose/ui/benchmark/LayoutNodeModifierBenchmark.kt
index 9f92539..e6e9eb3 100644
--- a/compose/ui/ui/benchmark/src/androidTest/java/androidx/compose/ui/benchmark/LayoutNodeModifierBenchmark.kt
+++ b/compose/ui/ui/benchmark/src/androidTest/java/androidx/compose/ui/benchmark/LayoutNodeModifierBenchmark.kt
@@ -21,7 +21,7 @@
 import androidx.activity.ComponentActivity
 import androidx.activity.compose.setContent
 import androidx.benchmark.junit4.BenchmarkRule
-import androidx.benchmark.junit4.measureRepeated
+import androidx.benchmark.junit4.measureRepeatedOnMainThread
 import androidx.compose.foundation.gestures.detectTapGestures
 import androidx.compose.foundation.layout.padding
 import androidx.compose.ui.Modifier
@@ -59,9 +59,9 @@
 
     @get:Rule val rule = SimpleAndroidBenchmarkRule()
 
-    var modifiers = emptyList<Modifier>()
-    var combinedModifier: Modifier = Modifier
-    lateinit var testModifierUpdater: TestModifierUpdater
+    private var modifiers = emptyList<Modifier>()
+    private var combinedModifier: Modifier = Modifier
+    private lateinit var testModifierUpdater: TestModifierUpdater
 
     @Before
     fun setup() {
@@ -92,24 +92,19 @@
 
     @Test
     fun setAndClearModifiers() {
-        rule.activityTestRule.runOnUiThread {
-            rule.benchmarkRule.measureRepeated {
-                testModifierUpdater.updateModifier(combinedModifier)
-                testModifierUpdater.updateModifier(Modifier)
-            }
+        rule.benchmarkRule.measureRepeatedOnMainThread {
+            testModifierUpdater.updateModifier(combinedModifier)
+            testModifierUpdater.updateModifier(Modifier)
         }
     }
 
     @Test
     fun smallModifierChange() {
-        rule.activityTestRule.runOnUiThread {
-            val altModifier = Modifier.padding(10.dp).then(combinedModifier)
+        val altModifier = Modifier.padding(10.dp).then(combinedModifier)
+        rule.activityTestRule.runOnUiThread { testModifierUpdater.updateModifier(altModifier) }
+        rule.benchmarkRule.measureRepeatedOnMainThread {
+            testModifierUpdater.updateModifier(combinedModifier)
             testModifierUpdater.updateModifier(altModifier)
-
-            rule.benchmarkRule.measureRepeated {
-                testModifierUpdater.updateModifier(combinedModifier)
-                testModifierUpdater.updateModifier(altModifier)
-            }
         }
     }
 
@@ -133,15 +128,13 @@
             }
         }
 
-        rule.activityTestRule.runOnUiThread {
-            rule.benchmarkRule.measureRepeated {
-                testModifierUpdater.updateModifier(combinedModifier)
-                testModifierUpdater.updateModifier(altModifier)
-            }
+        rule.benchmarkRule.measureRepeatedOnMainThread {
+            testModifierUpdater.updateModifier(combinedModifier)
+            testModifierUpdater.updateModifier(altModifier)
         }
     }
 
-    class SimpleAndroidBenchmarkRule() : TestRule {
+    class SimpleAndroidBenchmarkRule : TestRule {
         @Suppress("DEPRECATION")
         val activityTestRule = androidx.test.rule.ActivityTestRule(ComponentActivity::class.java)
 
diff --git a/compose/ui/ui/benchmark/src/androidTest/java/androidx/compose/ui/benchmark/LifecycleAwareWindowRecomposerBenchmark.kt b/compose/ui/ui/benchmark/src/androidTest/java/androidx/compose/ui/benchmark/LifecycleAwareWindowRecomposerBenchmark.kt
index 0da80e7..96bfb2b 100644
--- a/compose/ui/ui/benchmark/src/androidTest/java/androidx/compose/ui/benchmark/LifecycleAwareWindowRecomposerBenchmark.kt
+++ b/compose/ui/ui/benchmark/src/androidTest/java/androidx/compose/ui/benchmark/LifecycleAwareWindowRecomposerBenchmark.kt
@@ -20,11 +20,10 @@
 import android.view.ViewGroup
 import androidx.activity.ComponentActivity
 import androidx.benchmark.junit4.BenchmarkRule
-import androidx.benchmark.junit4.measureRepeated
+import androidx.benchmark.junit4.measureRepeatedOnMainThread
 import androidx.compose.ui.platform.createLifecycleAwareWindowRecomposer
 import androidx.lifecycle.Lifecycle
 import androidx.lifecycle.testing.TestLifecycleOwner
-import androidx.test.annotation.UiThreadTest
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.LargeTest
 import org.junit.Rule
@@ -42,21 +41,20 @@
     @get:Rule val rule = CombinedActivityBenchmarkRule()
 
     @Test
-    @UiThreadTest
     fun createRecomposer() {
-        val rootView = rule.activityTestRule.activity.window.decorView.rootView
+        var rootView: View? = null
+        rule.activityTestRule.runOnUiThread {
+            rootView = rule.activityTestRule.activity.window.decorView.rootView
+        }
         val lifecycleOwner = TestLifecycleOwner(Lifecycle.State.CREATED)
-        var view: View? = null
-        rule.benchmarkRule.measureRepeated {
+        rule.benchmarkRule.measureRepeatedOnMainThread {
+            var view: View? = null
             runWithTimingDisabled {
                 view = View(rule.activityTestRule.activity)
                 (rootView as ViewGroup).addView(view)
             }
             view!!.createLifecycleAwareWindowRecomposer(lifecycle = lifecycleOwner.lifecycle)
-            runWithTimingDisabled {
-                (rootView as ViewGroup).removeAllViews()
-                view = null
-            }
+            runWithTimingDisabled { (rootView as ViewGroup).removeAllViews() }
         }
     }
 
diff --git a/compose/ui/ui/benchmark/src/androidTest/java/androidx/compose/ui/benchmark/autofill/AndroidAutofillBenchmark.kt b/compose/ui/ui/benchmark/src/androidTest/java/androidx/compose/ui/benchmark/autofill/AndroidAutofillBenchmark.kt
index 964171d..465fc6b 100644
--- a/compose/ui/ui/benchmark/src/androidTest/java/androidx/compose/ui/benchmark/autofill/AndroidAutofillBenchmark.kt
+++ b/compose/ui/ui/benchmark/src/androidTest/java/androidx/compose/ui/benchmark/autofill/AndroidAutofillBenchmark.kt
@@ -20,7 +20,7 @@
 import android.view.View
 import android.view.autofill.AutofillValue
 import androidx.benchmark.junit4.BenchmarkRule
-import androidx.benchmark.junit4.measureRepeated
+import androidx.benchmark.junit4.measureRepeatedOnMainThread
 import androidx.compose.ui.autofill.AutofillNode
 import androidx.compose.ui.autofill.AutofillTree
 import androidx.compose.ui.autofill.AutofillType
@@ -28,7 +28,6 @@
 import androidx.compose.ui.platform.LocalAutofillTree
 import androidx.compose.ui.platform.LocalView
 import androidx.compose.ui.test.junit4.createComposeRule
-import androidx.test.annotation.UiThreadTest
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.LargeTest
 import androidx.test.filters.SdkSuppress
@@ -57,24 +56,26 @@
     }
 
     @Test
-    @UiThreadTest
     @SdkSuppress(minSdkVersion = 26)
     fun provideAutofillVirtualStructure_performAutofill() {
-
-        // Arrange.
-        val autofillNode =
-            AutofillNode(
-                onFill = {},
-                autofillTypes = listOf(AutofillType.PersonFullName),
-                boundingBox = Rect(0f, 0f, 0f, 0f)
-            )
         val autofillValues =
-            SparseArray<AutofillValue>().apply {
-                append(autofillNode.id, AutofillValue.forText("Name"))
+            composeTestRule.runOnUiThread {
+                // Arrange.
+                val autofillNode =
+                    AutofillNode(
+                        onFill = {},
+                        autofillTypes = listOf(AutofillType.PersonFullName),
+                        boundingBox = Rect(0f, 0f, 0f, 0f)
+                    )
+
+                autofillTree += autofillNode
+
+                SparseArray<AutofillValue>().apply {
+                    append(autofillNode.id, AutofillValue.forText("Name"))
+                }
             }
-        autofillTree += autofillNode
 
         // Assess.
-        benchmarkRule.measureRepeated { composeView.autofill(autofillValues) }
+        benchmarkRule.measureRepeatedOnMainThread { composeView.autofill(autofillValues) }
     }
 }
diff --git a/compose/ui/ui/integration-tests/ui-demos/build.gradle b/compose/ui/ui/integration-tests/ui-demos/build.gradle
index 5dba54b..0f9cc3a 100644
--- a/compose/ui/ui/integration-tests/ui-demos/build.gradle
+++ b/compose/ui/ui/integration-tests/ui-demos/build.gradle
@@ -23,6 +23,7 @@
     implementation(project(":compose:integration-tests:demos:common"))
     implementation(project(":compose:ui:ui:ui-samples"))
     implementation(project(":compose:material:material"))
+    implementation(project(":compose:material3:material3"))
     implementation("androidx.compose.material:material-icons-core:1.6.7")
     implementation(project(":navigation:navigation-compose"))
     implementation(project(":compose:runtime:runtime"))
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 39f577e..3492d49 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
@@ -27,6 +27,7 @@
 import androidx.compose.ui.demos.accessibility.LinearProgressIndicatorDemo
 import androidx.compose.ui.demos.accessibility.NestedContainersFalseDemo
 import androidx.compose.ui.demos.accessibility.NestedContainersTrueDemo
+import androidx.compose.ui.demos.accessibility.SampleScrollingTooltipScreen
 import androidx.compose.ui.demos.accessibility.ScaffoldSampleDemo
 import androidx.compose.ui.demos.accessibility.ScaffoldSampleScrollDemo
 import androidx.compose.ui.demos.accessibility.ScrollingColumnDemo
@@ -299,7 +300,8 @@
             ComposableDemo("Nested Containers—True") { NestedContainersTrueDemo() },
             ComposableDemo("Nested Containers—False") { NestedContainersFalseDemo() },
             ComposableDemo("Linear Progress Indicator") { LinearProgressIndicatorDemo() },
-            ComposableDemo("Dual LTR and RTL Scene") { SimpleRtlLayoutDemo() }
+            ComposableDemo("Dual LTR and RTL Scene") { SimpleRtlLayoutDemo() },
+            ComposableDemo("Scrolling Tooltip scene") { SampleScrollingTooltipScreen() }
         )
     )
 
diff --git a/compose/ui/ui/integration-tests/ui-demos/src/main/java/androidx/compose/ui/demos/accessibility/ScrollingUIDemo.kt b/compose/ui/ui/integration-tests/ui-demos/src/main/java/androidx/compose/ui/demos/accessibility/ScrollingUIDemo.kt
new file mode 100644
index 0000000..e14dd24
--- /dev/null
+++ b/compose/ui/ui/integration-tests/ui-demos/src/main/java/androidx/compose/ui/demos/accessibility/ScrollingUIDemo.kt
@@ -0,0 +1,103 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR 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.demos.accessibility
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Menu
+import androidx.compose.material.icons.filled.Search
+import androidx.compose.material.icons.filled.Settings
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.PlainTooltip
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TooltipBox
+import androidx.compose.material3.TooltipDefaults.rememberTooltipPositionProvider
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.rememberTooltipState
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+
+@Preview
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SampleScrollingTooltipScreen() {
+    Scaffold(
+        topBar = {
+            TopAppBar(
+                title = { Text("Sample Screen") },
+                navigationIcon = {
+                    TooltipBox(
+                        positionProvider = rememberTooltipPositionProvider(),
+                        tooltip = { PlainTooltip { Text(text = "Navigation icon") } },
+                        state = rememberTooltipState()
+                    ) {
+                        IconButton(onClick = {}) {
+                            Icon(
+                                imageVector = Icons.Default.Menu,
+                                contentDescription = "Navigation icon"
+                            )
+                        }
+                    }
+                },
+                actions = {
+                    TooltipBox(
+                        positionProvider = rememberTooltipPositionProvider(),
+                        tooltip = { PlainTooltip { Text(text = "Search") } },
+                        state = rememberTooltipState()
+                    ) {
+                        IconButton(onClick = {}) {
+                            Icon(
+                                imageVector = Icons.Default.Search,
+                                contentDescription = "Search icon"
+                            )
+                        }
+                    }
+                    TooltipBox(
+                        positionProvider = rememberTooltipPositionProvider(),
+                        tooltip = { PlainTooltip { Text(text = "Settings") } },
+                        state = rememberTooltipState()
+                    ) {
+                        IconButton(onClick = {}) {
+                            Icon(
+                                imageVector = Icons.Default.Settings,
+                                contentDescription = "Settings icon"
+                            )
+                        }
+                    }
+                }
+            )
+        }
+    ) { paddingValues ->
+        LazyColumn(
+            modifier = Modifier.padding(paddingValues).fillMaxSize().padding(16.dp),
+            verticalArrangement = Arrangement.spacedBy(8.dp)
+        ) {
+            items(40) { index ->
+                Text(text = "Item ${index + 1}", style = MaterialTheme.typography.bodyLarge)
+            }
+        }
+    }
+}
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 3de5c0b..939dfd0 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
@@ -21,7 +21,6 @@
 import android.view.ViewStructure
 import android.view.autofill.AutofillValue
 import androidx.autofill.HintConstants.AUTOFILL_HINT_PERSON_NAME
-import androidx.compose.ui.ComposeUiFlags.isSemanticAutofillEnabled
 import androidx.compose.ui.ExperimentalComposeUiApi
 import androidx.compose.ui.geometry.Rect
 import androidx.compose.ui.platform.LocalAutofill
@@ -77,8 +76,6 @@
     @SdkSuppress(minSdkVersion = 26)
     @Test
     fun onProvideAutofillVirtualStructure_populatesViewStructure() {
-        if (isSemanticAutofillEnabled) return
-
         // Arrange.
         val viewStructure: ViewStructure = FakeViewStructure()
         val autofillNode =
@@ -113,8 +110,6 @@
     @SdkSuppress(minSdkVersion = 26)
     @Test
     fun autofill_triggersOnFill() {
-        if (isSemanticAutofillEnabled) return
-
         // Arrange.
         val expectedValue = "PersonName"
         var autofilledValue = ""
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 b6aa107..75bc772 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
@@ -22,9 +22,8 @@
 import android.view.autofill.AutofillValue
 import androidx.annotation.RequiresApi
 import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.text.AutofillHighlight
 import androidx.compose.foundation.text.BasicTextField
-import androidx.compose.foundation.text.LocalAutofillHighlight
+import androidx.compose.foundation.text.LocalAutofillHighlightColor
 import androidx.compose.material.OutlinedTextField
 import androidx.compose.material.Text
 import androidx.compose.material.TextField
@@ -67,7 +66,7 @@
     @get:Rule val rule = createAndroidComposeRule<TestActivity>()
     private lateinit var androidComposeView: AndroidComposeView
     private lateinit var composeView: View
-    private var autofillHighlight: AutofillHighlight? = null
+    private var autofillHighlight: Color? = null
 
     // ============================================================================================
     // Tests to verify legacy TextField populating and filling.
@@ -194,9 +193,7 @@
         val customHighlightColor = Color.Red
 
         rule.setContentWithAutofillEnabled {
-            CompositionLocalProvider(
-                LocalAutofillHighlight provides AutofillHighlight(customHighlightColor)
-            ) {
+            CompositionLocalProvider(LocalAutofillHighlightColor provides customHighlightColor) {
                 Column {
                     TextField(
                         value = usernameInput,
@@ -245,7 +242,7 @@
             isSemanticAutofillEnabled = true
 
             composeView = LocalView.current
-            autofillHighlight = LocalAutofillHighlight.current
+            autofillHighlight = LocalAutofillHighlightColor.current
             LaunchedEffect(Unit) {
                 // Make sure the delay between batches of events is set to zero.
                 (composeView as RootForTest).setAccessibilityEventBatchIntervalMillis(0L)
diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/pointer/HitPathTrackerTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/pointer/HitPathTrackerTest.kt
index d2d8f53..b72e759 100644
--- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/pointer/HitPathTrackerTest.kt
+++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/pointer/HitPathTrackerTest.kt
@@ -20,7 +20,6 @@
 
 import android.view.MotionEvent.ACTION_HOVER_ENTER
 import android.view.MotionEvent.ACTION_HOVER_EXIT
-import androidx.collection.IntObjectMap
 import androidx.compose.ui.InternalComposeUiApi
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.autofill.Autofill
@@ -62,7 +61,6 @@
 import androidx.compose.ui.platform.TextToolbar
 import androidx.compose.ui.platform.ViewConfiguration
 import androidx.compose.ui.platform.WindowInfo
-import androidx.compose.ui.semantics.SemanticsOwner
 import androidx.compose.ui.spatial.RectManager
 import androidx.compose.ui.text.font.Font
 import androidx.compose.ui.text.font.FontFamily
@@ -3417,9 +3415,6 @@
     override val focusOwner: FocusOwner
         get() = TODO("Not yet implemented")
 
-    override val semanticsOwner: SemanticsOwner
-        get() = TODO("Not yet implemented")
-
     override val windowInfo: WindowInfo
         get() = TODO("Not yet implemented")
 
@@ -3565,12 +3560,8 @@
     }
 
     override var measureIteration: Long = 0
-
     override val viewConfiguration: ViewConfiguration
         get() = TODO("Not yet implemented")
 
-    override val layoutNodes: IntObjectMap<LayoutNode>
-        get() = TODO("Not yet implemented")
-
     override val sharedDrawScope = LayoutNodeDrawScope()
 }
diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/pointer/PointerInputEventProcessorTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/pointer/PointerInputEventProcessorTest.kt
index cda5678..df6e720 100644
--- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/pointer/PointerInputEventProcessorTest.kt
+++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/pointer/PointerInputEventProcessorTest.kt
@@ -21,8 +21,6 @@
 import android.view.InputDevice
 import android.view.KeyEvent as AndroidKeyEvent
 import android.view.MotionEvent
-import androidx.collection.IntObjectMap
-import androidx.collection.intObjectMapOf
 import androidx.compose.ui.InternalComposeUiApi
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.autofill.Autofill
@@ -58,8 +56,6 @@
 import androidx.compose.ui.platform.TextToolbar
 import androidx.compose.ui.platform.ViewConfiguration
 import androidx.compose.ui.platform.WindowInfo
-import androidx.compose.ui.semantics.EmptySemanticsModifier
-import androidx.compose.ui.semantics.SemanticsOwner
 import androidx.compose.ui.spatial.RectManager
 import androidx.compose.ui.text.font.Font
 import androidx.compose.ui.text.font.FontFamily
@@ -2850,9 +2846,6 @@
     override val rootForTest: RootForTest
         get() = TODO("Not yet implemented")
 
-    override val layoutNodes: IntObjectMap<LayoutNode>
-        get() = TODO("Not yet implemented")
-
     override val hapticFeedBack: HapticFeedback
         get() = TODO("Not yet implemented")
 
@@ -2912,9 +2905,6 @@
     override val focusOwner: FocusOwner
         get() = TODO("Not yet implemented")
 
-    override val semanticsOwner: SemanticsOwner =
-        SemanticsOwner(root, EmptySemanticsModifier(), intObjectMapOf())
-
     override val windowInfo: WindowInfo
         get() = TODO("Not yet implemented")
 
diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/layout/Helpers.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/layout/Helpers.kt
index 8295452..ca6ec66 100644
--- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/layout/Helpers.kt
+++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/layout/Helpers.kt
@@ -18,8 +18,6 @@
 
 package androidx.compose.ui.layout
 
-import androidx.collection.IntObjectMap
-import androidx.collection.intObjectMapOf
 import androidx.compose.ui.ExperimentalComposeUiApi
 import androidx.compose.ui.InternalComposeUiApi
 import androidx.compose.ui.autofill.Autofill
@@ -54,8 +52,6 @@
 import androidx.compose.ui.platform.TextToolbar
 import androidx.compose.ui.platform.ViewConfiguration
 import androidx.compose.ui.platform.WindowInfo
-import androidx.compose.ui.semantics.EmptySemanticsModifier
-import androidx.compose.ui.semantics.SemanticsOwner
 import androidx.compose.ui.spatial.RectManager
 import androidx.compose.ui.text.font.Font
 import androidx.compose.ui.text.font.FontFamily
@@ -165,12 +161,7 @@
 
     override fun onDetach(node: LayoutNode) {}
 
-    override val root: LayoutNode = LayoutNode()
-
-    override val semanticsOwner: SemanticsOwner =
-        SemanticsOwner(root, EmptySemanticsModifier(), intObjectMapOf())
-
-    override val layoutNodes: IntObjectMap<LayoutNode>
+    override val root: LayoutNode
         get() = TODO("Not yet implemented")
 
     override val sharedDrawScope: LayoutNodeDrawScope
diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/node/LayoutNodeMappingTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/node/LayoutNodeMappingTest.kt
deleted file mode 100644
index 77e187c..0000000
--- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/node/LayoutNodeMappingTest.kt
+++ /dev/null
@@ -1,163 +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.node
-
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.lazy.LazyItemScope
-import androidx.compose.foundation.lazy.LazyListState
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.Row
-import androidx.compose.ui.platform.LocalView
-import androidx.compose.ui.test.junit4.ComposeContentTestRule
-import androidx.compose.ui.test.junit4.createComposeRule
-import androidx.compose.ui.unit.dp
-import androidx.test.ext.junit.runners.AndroidJUnit4
-import androidx.test.filters.MediumTest
-import kotlin.test.assertEquals
-import org.junit.Rule
-import org.junit.Test
-import org.junit.runner.RunWith
-
-@MediumTest
-@RunWith(AndroidJUnit4::class)
-class LayoutNodeMappingTest {
-    @get:Rule val rule = createComposeRule()
-
-    private lateinit var owner: Owner
-
-    @Test
-    fun basicTest() {
-        rule.setTestContent { Column { repeat(5) { Box(Modifier.size(10.dp)) } } }
-        rule.waitForIdle()
-
-        assertMappingConsistency(7)
-    }
-
-    @Test
-    fun countAfterRemovingContent() {
-        var showAll by mutableStateOf(true)
-
-        rule.setTestContent {
-            Column {
-                repeat(5) { Box(Modifier.size(10.dp)) }
-                if (showAll) {
-                    repeat(5) { Box(Modifier.size(10.dp)) }
-                }
-            }
-        }
-        rule.waitForIdle()
-        assertMappingConsistency(12)
-
-        // Remove content, the mapping should reflect the change
-        showAll = false
-        rule.waitForIdle()
-        assertMappingConsistency(7)
-    }
-
-    /**
-     * Test case for when several LayoutNodes are re-used.
-     *
-     * We test that the mapping size after removing the LazyList is consistent.
-     */
-    @Test
-    fun cleanupCount_afterScrollingLazyList() =
-        with(rule.density) {
-            val rootSizePx = 600f
-            val viewPortCount = 4
-            val pages = 3
-
-            val listItemHeight = rootSizePx / viewPortCount
-
-            var showAll by mutableStateOf(true)
-
-            @Composable
-            fun LazyItemScope.MyItem() {
-                Row(Modifier.fillParentMaxWidth().height(listItemHeight.toDp())) {
-                    repeat(3) {
-                        Box(Modifier.size((rootSizePx / 3f).toDp(), listItemHeight.toDp()))
-                    }
-                }
-            }
-
-            val lazyListState = LazyListState()
-
-            rule.setTestContent {
-                Box {
-                    if (showAll) {
-                        LazyColumn(
-                            state = lazyListState,
-                            modifier = Modifier.size(rootSizePx.toDp())
-                        ) {
-                            items(viewPortCount * pages) { MyItem() }
-                        }
-                    }
-                }
-            }
-
-            // Reproducible set of calls that re-use content on the screen
-            var itemToScroll = pages * viewPortCount - viewPortCount
-            // Scroll to last visible set of items
-            rule.runOnIdle { lazyListState.requestScrollToItem(itemToScroll) }
-
-            // Scroll two items back
-            repeat(2) { rule.runOnIdle { lazyListState.requestScrollToItem(--itemToScroll) } }
-
-            // Scroll two items forward
-            repeat(2) { rule.runOnIdle { lazyListState.requestScrollToItem(++itemToScroll) } }
-            rule.waitForIdle()
-
-            // Note that not every registered LayoutNode is an 'active' LayoutNode
-            assertMappingConsistency(43)
-
-            // Remove LazyList, and assert that the mapping is still representative of what's on
-            // screen
-            showAll = false
-            rule.waitForIdle()
-            assertMappingConsistency(2)
-        }
-
-    /** Sets the test [owner] property. */
-    private fun ComposeContentTestRule.setTestContent(content: @Composable () -> Unit) {
-        setContent {
-            owner = LocalView.current as Owner
-            content()
-        }
-    }
-
-    /**
-     * Asserts the `id to LayoutNode` map has the [expectedSize] and that the keys match their
-     * corresponding [LayoutNode.semanticsId] value.
-     */
-    private fun assertMappingConsistency(expectedSize: Int) {
-        assertEquals(
-            expected = expectedSize,
-            actual = owner.layoutNodes.size,
-            message = "Unexpected Map size"
-        )
-        owner.layoutNodes.forEach { key, value ->
-            assertEquals(key, value.semanticsId, "Non-matching keys")
-        }
-    }
-}
diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/node/NodeChainTester.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/node/NodeChainTester.kt
index 67bc5d7..5488402 100644
--- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/node/NodeChainTester.kt
+++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/node/NodeChainTester.kt
@@ -18,8 +18,6 @@
 
 package androidx.compose.ui.node
 
-import androidx.collection.IntObjectMap
-import androidx.collection.intObjectMapOf
 import androidx.compose.ui.InternalComposeUiApi
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.autofill.Autofill
@@ -49,8 +47,6 @@
 import androidx.compose.ui.platform.ViewConfiguration
 import androidx.compose.ui.platform.WindowInfo
 import androidx.compose.ui.platform.invertTo
-import androidx.compose.ui.semantics.EmptySemanticsModifier
-import androidx.compose.ui.semantics.SemanticsOwner
 import androidx.compose.ui.spatial.RectManager
 import androidx.compose.ui.text.font.Font
 import androidx.compose.ui.text.font.FontFamily
@@ -388,9 +384,6 @@
     override val density: Density
         get() = Density(1f)
 
-    override val layoutNodes: IntObjectMap<LayoutNode>
-        get() = TODO("Not yet implemented")
-
     override val layoutDirection: LayoutDirection
         get() = LayoutDirection.Ltr
 
@@ -427,9 +420,6 @@
     override val focusOwner: FocusOwner
         get() = TODO("Not yet implemented")
 
-    override val semanticsOwner: SemanticsOwner =
-        SemanticsOwner(root, EmptySemanticsModifier(), intObjectMapOf())
-
     override val windowInfo: WindowInfo
         get() = TODO("Not yet implemented")
 
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
deleted file mode 100644
index 2792c5d..0000000
--- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/semantics/SemanticsInfoTest.kt
+++ /dev/null
@@ -1,276 +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.semantics
-
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.lazy.LazyListState
-import androidx.compose.foundation.lazy.LazyRow
-import androidx.compose.foundation.lazy.rememberLazyListState
-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.platform.testTag
-import androidx.compose.ui.semantics.SemanticsProperties.TestTag
-import androidx.compose.ui.test.junit4.ComposeContentTestRule
-import androidx.compose.ui.test.junit4.createComposeRule
-import androidx.compose.ui.test.onNodeWithTag
-import androidx.compose.ui.unit.dp
-import androidx.test.ext.junit.runners.AndroidJUnit4
-import androidx.test.filters.MediumTest
-import com.google.common.truth.Correspondence
-import com.google.common.truth.Truth.assertThat
-import kotlin.test.Test
-import org.junit.Rule
-import org.junit.runner.RunWith
-
-@MediumTest
-@RunWith(AndroidJUnit4::class)
-class SemanticsInfoTest {
-
-    @get:Rule val rule = createComposeRule()
-
-    lateinit var semanticsOwner: SemanticsOwner
-
-    @Test
-    fun contentWithNoSemantics() {
-        // Arrange.
-        rule.setTestContent { Box {} }
-        rule.waitForIdle()
-
-        // Act.
-        val rootSemantics = semanticsOwner.rootInfo
-
-        // Assert.
-        assertThat(rootSemantics).isNotNull()
-        assertThat(rootSemantics.parentInfo).isNull()
-        assertThat(rootSemantics.childrenInfo.size).isEqualTo(1)
-
-        // Assert extension Functions.
-        assertThat(rootSemantics.findSemanticsParent()).isNull()
-        assertThat(rootSemantics.findMergingSemanticsParent()).isNull()
-        assertThat(rootSemantics.findSemanticsChildren()).isEmpty()
-    }
-
-    @Test
-    fun singleSemanticsModifier() {
-        // Arrange.
-        rule.setTestContent { Box(Modifier.semantics { this.testTag = "testTag" }) }
-        rule.waitForIdle()
-
-        // Act.
-        val rootSemantics = semanticsOwner.rootInfo
-        val semantics = rule.getSemanticsInfoForTag("testTag")!!
-
-        // Assert.
-        assertThat(rootSemantics.parentInfo).isNull()
-        assertThat(rootSemantics.childrenInfo.asMutableList()).containsExactly(semantics)
-
-        assertThat(semantics.parentInfo).isEqualTo(rootSemantics)
-        assertThat(semantics.childrenInfo.size).isEqualTo(0)
-
-        // Assert extension Functions.
-        assertThat(rootSemantics.findSemanticsParent()).isNull()
-        assertThat(rootSemantics.findMergingSemanticsParent()).isNull()
-        assertThat(rootSemantics.findSemanticsChildren().map { it.semanticsConfiguration })
-            .comparingElementsUsing(SemanticsConfigurationComparator)
-            .containsExactly(SemanticsConfiguration().apply { testTag = "testTag" })
-
-        assertThat(semantics.findSemanticsParent()).isEqualTo(rootSemantics)
-        assertThat(semantics.findMergingSemanticsParent()).isNull()
-        assertThat(semantics.findSemanticsChildren()).isEmpty()
-    }
-
-    @Test
-    fun twoSemanticsModifiers() {
-        // Arrange.
-        rule.setTestContent {
-            Box(Modifier.semantics { this.testTag = "item1" })
-            Box(Modifier.semantics { this.testTag = "item2" })
-        }
-        rule.waitForIdle()
-
-        // Act.
-        val rootSemantics: SemanticsInfo = semanticsOwner.rootInfo
-        val semantics1 = rule.getSemanticsInfoForTag("item1")
-        val semantics2 = rule.getSemanticsInfoForTag("item2")
-
-        // Assert.
-        assertThat(rootSemantics.parentInfo).isNull()
-        assertThat(rootSemantics.childrenInfo.map { it.semanticsConfiguration }.toList())
-            .comparingElementsUsing(SemanticsConfigurationComparator)
-            .containsExactly(
-                SemanticsConfiguration().apply { testTag = "item1" },
-                SemanticsConfiguration().apply { testTag = "item2" }
-            )
-            .inOrder()
-
-        assertThat(rootSemantics.findSemanticsChildren().map { it.semanticsConfiguration })
-            .comparingElementsUsing(SemanticsConfigurationComparator)
-            .containsExactly(
-                SemanticsConfiguration().apply { testTag = "item1" },
-                SemanticsConfiguration().apply { testTag = "item2" }
-            )
-            .inOrder()
-
-        checkNotNull(semantics1)
-        assertThat(semantics1.parentInfo).isEqualTo(rootSemantics)
-        assertThat(semantics1.childrenInfo.size).isEqualTo(0)
-
-        checkNotNull(semantics2)
-        assertThat(semantics2.parentInfo).isEqualTo(rootSemantics)
-        assertThat(semantics2.childrenInfo.size).isEqualTo(0)
-
-        // Assert extension Functions.
-        assertThat(rootSemantics.findSemanticsParent()).isNull()
-        assertThat(rootSemantics.findMergingSemanticsParent()).isNull()
-        assertThat(rootSemantics.findSemanticsChildren().map { it.semanticsConfiguration })
-            .comparingElementsUsing(SemanticsConfigurationComparator)
-            .containsExactly(
-                SemanticsConfiguration().apply { testTag = "item1" },
-                SemanticsConfiguration().apply { testTag = "item2" }
-            )
-            .inOrder()
-
-        assertThat(semantics1.findSemanticsParent()).isEqualTo(rootSemantics)
-        assertThat(semantics1.findMergingSemanticsParent()).isNull()
-        assertThat(semantics1.findSemanticsChildren()).isEmpty()
-
-        assertThat(semantics2.findSemanticsParent()).isEqualTo(rootSemantics)
-        assertThat(semantics2.findMergingSemanticsParent()).isNull()
-        assertThat(semantics2.findSemanticsChildren()).isEmpty()
-    }
-
-    // TODO(ralu): Split this into multiple tests.
-    @Test
-    fun nodeDeepInHierarchy() {
-        // Arrange.
-        rule.setTestContent {
-            Column(Modifier.semantics(mergeDescendants = true) { testTag = "outerColumn" }) {
-                Row(Modifier.semantics { testTag = "outerRow" }) {
-                    Column(Modifier.semantics(mergeDescendants = true) { testTag = "column" }) {
-                        Row(Modifier.semantics { testTag = "row" }) {
-                            Column {
-                                Box(Modifier.semantics { testTag = "box" })
-                                Row(
-                                    Modifier.semantics {}
-                                        .semantics { testTag = "testTarget" }
-                                        .semantics { testTag = "extra modifier2" }
-                                ) {
-                                    Box { Box(Modifier.semantics { testTag = "child1" }) }
-                                    Box(Modifier.semantics { testTag = "child2" }) {
-                                        Box(Modifier.semantics { testTag = "grandChild" })
-                                    }
-                                    Box {}
-                                    Row {
-                                        Box {}
-                                        Box {}
-                                    }
-                                    Box { Box(Modifier.semantics { testTag = "child3" }) }
-                                }
-                            }
-                        }
-                    }
-                }
-            }
-        }
-        rule.waitForIdle()
-        val row = rule.getSemanticsInfoForTag(tag = "row", useUnmergedTree = true)
-        val column = rule.getSemanticsInfoForTag("column")
-
-        // Act.
-        val testTarget = rule.getSemanticsInfoForTag(tag = "testTarget", useUnmergedTree = true)
-
-        // Assert.
-        checkNotNull(testTarget)
-        assertThat(testTarget.parentInfo).isNotEqualTo(row)
-        assertThat(testTarget.findSemanticsParent()).isEqualTo(row)
-        assertThat(testTarget.findMergingSemanticsParent()).isEqualTo(column)
-        assertThat(testTarget.childrenInfo.size).isEqualTo(5)
-        assertThat(testTarget.findSemanticsChildren().map { it.semanticsConfiguration })
-            .comparingElementsUsing(SemanticsConfigurationComparator)
-            .containsExactly(
-                SemanticsConfiguration().apply { testTag = "child1" },
-                SemanticsConfiguration().apply { testTag = "child2" },
-                SemanticsConfiguration().apply { testTag = "child3" }
-            )
-            .inOrder()
-        assertThat(testTarget.semanticsConfiguration?.getOrNull(TestTag)).isEqualTo("testTarget")
-    }
-
-    @Test
-    fun readingSemanticsConfigurationOfDeactivatedNode() {
-        // Arrange.
-        lateinit var lazyListState: LazyListState
-        lateinit var rootForTest: RootForTest
-        rule.setContent {
-            rootForTest = LocalView.current as RootForTest
-            lazyListState = rememberLazyListState()
-            LazyRow(state = lazyListState, modifier = Modifier.size(10.dp)) {
-                items(2) { index -> Box(Modifier.size(10.dp).testTag("$index")) }
-            }
-        }
-        val semanticsId = rule.onNodeWithTag("0").semanticsId()
-        val semanticsInfo = checkNotNull(rootForTest.semanticsOwner[semanticsId])
-
-        // Act.
-        rule.runOnIdle { lazyListState.requestScrollToItem(1) }
-        val semanticsConfiguration = rule.runOnIdle { semanticsInfo.semanticsConfiguration }
-
-        // Assert.
-        rule.runOnIdle {
-            assertThat(semanticsInfo.isDeactivated).isTrue()
-            assertThat(semanticsConfiguration).isNull()
-        }
-    }
-
-    private fun ComposeContentTestRule.setTestContent(composable: @Composable () -> Unit) {
-        setContent {
-            semanticsOwner = (LocalView.current as RootForTest).semanticsOwner
-            composable()
-        }
-    }
-
-    /** 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
-    ): SemanticsInfo? {
-        return semanticsOwner[onNodeWithTag(tag, useUnmergedTree).semanticsId()]
-    }
-
-    companion object {
-        private val SemanticsConfigurationComparator =
-            Correspondence.from<SemanticsConfiguration, SemanticsConfiguration>(
-                { actual, expected ->
-                    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
deleted file mode 100644
index 77965fe..0000000
--- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/semantics/SemanticsListenerTest.kt
+++ /dev/null
@@ -1,556 +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.semantics
-
-import androidx.compose.foundation.border
-import androidx.compose.foundation.focusable
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.BoxScope
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.size
-import androidx.compose.material.Text
-import androidx.compose.material.TextField
-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.ComposeUiFlags
-import androidx.compose.ui.ExperimentalComposeUiApi
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.focus.isExactly
-import androidx.compose.ui.focus.onFocusChanged
-import androidx.compose.ui.graphics.Color.Companion.Black
-import androidx.compose.ui.graphics.Color.Companion.Red
-import androidx.compose.ui.node.RootForTest
-import androidx.compose.ui.node.SemanticsModifierNode
-import androidx.compose.ui.node.invalidateSemantics
-import androidx.compose.ui.platform.LocalView
-import androidx.compose.ui.platform.testTag
-import androidx.compose.ui.test.SemanticsNodeInteraction
-import androidx.compose.ui.test.junit4.ComposeContentTestRule
-import androidx.compose.ui.test.junit4.createComposeRule
-import androidx.compose.ui.test.onNodeWithTag
-import androidx.compose.ui.test.requestFocus
-import androidx.compose.ui.text.AnnotatedString
-import androidx.compose.ui.unit.dp
-import androidx.compose.ui.util.fastJoinToString
-import androidx.test.filters.MediumTest
-import com.google.common.truth.Truth.assertThat
-import kotlin.test.Test
-import org.junit.Before
-import org.junit.Rule
-import org.junit.runner.RunWith
-import org.junit.runners.Parameterized
-
-@MediumTest
-@RunWith(Parameterized::class)
-class SemanticsListenerTest(private val isSemanticAutofillEnabled: Boolean) {
-
-    @get:Rule val rule = createComposeRule()
-
-    private lateinit var semanticsOwner: SemanticsOwner
-
-    companion object {
-        @JvmStatic
-        @Parameterized.Parameters(name = "isSemanticAutofillEnabled = {0}")
-        fun initParameters() = listOf(false, true)
-    }
-
-    @Before
-    fun setup() {
-        @OptIn(ExperimentalComposeUiApi::class)
-        ComposeUiFlags.isSemanticAutofillEnabled = isSemanticAutofillEnabled
-    }
-
-    // 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
-    fun initialComposition_doesNotTriggerListeners() {
-        // Arrange.
-        val events = mutableListOf<Event<String>>()
-        rule.setTestContent(
-            onSemanticsChange = { info, prev ->
-                events.add(Event(info.semanticsId, prev?.Text, info.semanticsConfiguration?.Text))
-            }
-        ) {
-            Text(text = "text")
-        }
-
-        // Assert.
-        rule.runOnIdle { assertThat(events).isEmpty() }
-    }
-
-    @Test
-    fun addingNonSemanticsModifier() {
-        // Arrange.
-        val events = mutableListOf<Event<String>>()
-        var addModifier by mutableStateOf(false)
-        val text = AnnotatedString("text")
-        rule.setTestContent(
-            onSemanticsChange = { info, prev ->
-                events.add(Event(info.semanticsId, prev?.Text, info.semanticsConfiguration?.Text))
-            }
-        ) {
-            Box(
-                modifier =
-                    Modifier.then(if (addModifier) Modifier.size(1000.dp) else Modifier)
-                        .semantics { this.text = text }
-                        .testTag("item")
-            )
-        }
-
-        // Act.
-        rule.runOnIdle { addModifier = true }
-
-        // Assert.
-        rule.runOnIdle { assertThat(events).isEmpty() }
-    }
-
-    @Test
-    fun removingNonSemanticsModifier() {
-        // Arrange.
-        val events = mutableListOf<Event<String>>()
-        var removeModifier by mutableStateOf(false)
-        val text = AnnotatedString("text")
-        rule.setTestContent(
-            onSemanticsChange = { info, prev ->
-                events.add(Event(info.semanticsId, prev?.Text, info.semanticsConfiguration?.Text))
-            }
-        ) {
-            Box(
-                modifier =
-                    Modifier.then(if (removeModifier) Modifier else Modifier.size(1000.dp))
-                        .semantics { this.text = text }
-                        .testTag("item")
-            )
-        }
-
-        // Act.
-        rule.runOnIdle { removeModifier = true }
-
-        // Assert.
-        rule.runOnIdle { assertThat(events).isEmpty() }
-    }
-
-    @Test
-    fun addingSemanticsModifier() {
-        // Arrange.
-        val events = mutableListOf<Event<String>>()
-        var addModifier by mutableStateOf(false)
-        val text = AnnotatedString("text")
-        rule.setTestContent(
-            onSemanticsChange = { info, prev ->
-                events.add(Event(info.semanticsId, prev?.Text, info.semanticsConfiguration?.Text))
-            }
-        ) {
-            Box(
-                modifier =
-                    Modifier.size(100.dp)
-                        .then(
-                            if (addModifier) Modifier.semantics { this.text = text } else Modifier
-                        )
-                        .testTag("item")
-            )
-        }
-
-        // Act.
-        rule.runOnIdle { addModifier = true }
-
-        // Assert.
-        val semanticsId = rule.onNodeWithTag("item").semanticsId
-        rule.runOnIdle {
-            if (isSemanticAutofillEnabled) {
-                assertThat(events)
-                    .isExactly(Event(semanticsId, prevSemantics = null, newSemantics = "text"))
-            } else {
-                assertThat(events).isEmpty()
-            }
-        }
-    }
-
-    @Test
-    fun removingSemanticsModifier() {
-        // Arrange.
-        val events = mutableListOf<Event<String>>()
-        var removeModifier by mutableStateOf(false)
-        val text = AnnotatedString("text")
-        rule.setTestContent(
-            onSemanticsChange = { info, prev ->
-                events.add(Event(info.semanticsId, prev?.Text, info.semanticsConfiguration?.Text))
-            }
-        ) {
-            Box(
-                modifier =
-                    Modifier.size(1000.dp)
-                        .then(
-                            if (removeModifier) Modifier
-                            else Modifier.semantics { this.text = text }
-                        )
-                        .testTag("item")
-            )
-        }
-
-        // Act.
-        rule.runOnIdle { removeModifier = true }
-
-        // Assert.
-        val semanticsId = rule.onNodeWithTag("item").semanticsId
-        rule.runOnIdle {
-            if (isSemanticAutofillEnabled) {
-                assertThat(events)
-                    .isExactly(Event(semanticsId, prevSemantics = "text", newSemantics = null))
-            } else {
-                assertThat(events).isEmpty()
-            }
-        }
-    }
-
-    @Test
-    fun changingMutableSemanticsProperty() {
-        // Arrange.
-        val events = mutableListOf<Event<String>>()
-        var text by mutableStateOf(AnnotatedString("text1"))
-        rule.setTestContent(
-            onSemanticsChange = { info, prev ->
-                events.add(Event(info.semanticsId, prev?.Text, info.semanticsConfiguration?.Text))
-            }
-        ) {
-            Box(modifier = Modifier.semantics { this.text = text }.testTag("item"))
-        }
-
-        // Act.
-        rule.runOnIdle { text = AnnotatedString("text2") }
-
-        // Assert.
-        val semanticsId = rule.onNodeWithTag("item").semanticsId
-        rule.runOnIdle {
-            if (isSemanticAutofillEnabled) {
-                assertThat(events)
-                    .isExactly(Event(semanticsId, prevSemantics = "text1", newSemantics = "text2"))
-            } else {
-                assertThat(events).isEmpty()
-            }
-        }
-    }
-
-    @Test
-    fun changingMutableSemanticsProperty_alongWithRecomposition() {
-        // Arrange.
-        val events = mutableListOf<Event<String>>()
-        var text by mutableStateOf(AnnotatedString("text1"))
-        rule.setTestContent(
-            onSemanticsChange = { info, prev ->
-                events.add(Event(info.semanticsId, prev?.Text, info.semanticsConfiguration?.Text))
-            }
-        ) {
-            Box(
-                modifier =
-                    Modifier.border(2.dp, if (text.text == "text1") Red else Black)
-                        .semantics { this.text = text }
-                        .testTag("item")
-            )
-        }
-
-        // Act.
-        rule.runOnIdle { text = AnnotatedString("text2") }
-
-        // Assert.
-        val semanticsId = rule.onNodeWithTag("item").semanticsId
-        rule.runOnIdle {
-            if (isSemanticAutofillEnabled) {
-                assertThat(events)
-                    .isExactly(Event(semanticsId, prevSemantics = "text1", newSemantics = "text2"))
-            } else {
-                assertThat(events).isEmpty()
-            }
-        }
-    }
-
-    @Test
-    fun changingSemanticsProperty_andCallingInvalidateSemantics() {
-        // Arrange.
-        val events = mutableListOf<Event<String>>()
-        val modifierNode =
-            object : SemanticsModifierNode, Modifier.Node() {
-                override fun SemanticsPropertyReceiver.applySemantics() {}
-            }
-        var text = AnnotatedString("text1")
-        rule.setTestContent(
-            onSemanticsChange = { info, prev ->
-                events.add(Event(info.semanticsId, prev?.Text, info.semanticsConfiguration?.Text))
-            }
-        ) {
-            Box(
-                modifier =
-                    Modifier.elementFor(modifierNode).semantics { this.text = text }.testTag("item")
-            )
-        }
-
-        // Act.
-        rule.runOnIdle {
-            text = AnnotatedString("text2")
-            modifierNode.invalidateSemantics()
-        }
-
-        // Assert.
-        val semanticsId = rule.onNodeWithTag("item").semanticsId
-        rule.runOnIdle {
-            if (isSemanticAutofillEnabled) {
-                assertThat(events)
-                    .isExactly(Event(semanticsId, prevSemantics = "text1", newSemantics = "text2"))
-            } else {
-                assertThat(events).isEmpty()
-            }
-        }
-    }
-
-    @Test
-    fun textChange() {
-        // Arrange.
-        val events = mutableListOf<Event<String>>()
-        var text by mutableStateOf("text1")
-        rule.setTestContent(
-            onSemanticsChange = { info, prev ->
-                events.add(Event(info.semanticsId, prev?.Text, info.semanticsConfiguration?.Text))
-            }
-        ) {
-            Text(text = text, modifier = Modifier.testTag("item"))
-        }
-
-        // Act.
-        rule.runOnIdle { text = "text2" }
-
-        // Assert.
-        val semanticsId = rule.onNodeWithTag("item").semanticsId
-        rule.runOnIdle {
-            if (isSemanticAutofillEnabled) {
-                assertThat(events)
-                    .isExactly(Event(semanticsId, prevSemantics = "text1", newSemantics = "text2"))
-            } else {
-                assertThat(events).isEmpty()
-            }
-        }
-    }
-
-    @Test
-    fun multipleTextChanges() {
-        // Arrange.
-        val events = mutableListOf<Event<String>>()
-        var text by mutableStateOf("text1")
-        rule.setTestContent(
-            onSemanticsChange = { info, prev ->
-                events.add(Event(info.semanticsId, prev?.Text, info.semanticsConfiguration?.Text))
-            }
-        ) {
-            Text(text = text, modifier = Modifier.testTag("item"))
-        }
-
-        // Act.
-        rule.runOnIdle { text = "text2" }
-        rule.runOnIdle { text = "text3" }
-
-        // Assert.
-        val semanticsId = rule.onNodeWithTag("item").semanticsId
-        rule.runOnIdle {
-            if (isSemanticAutofillEnabled) {
-                assertThat(events)
-                    .isExactly(
-                        Event(semanticsId, prevSemantics = "text1", newSemantics = "text2"),
-                        Event(semanticsId, prevSemantics = "text2", newSemantics = "text3")
-                    )
-            } else {
-                assertThat(events).isEmpty()
-            }
-        }
-    }
-
-    @Test
-    fun EditTextChange() {
-        // Arrange.
-        val events = mutableListOf<Event<String>>()
-        var text by mutableStateOf("text1")
-        rule.setTestContent(
-            onSemanticsChange = { info, prev ->
-                events.add(
-                    Event(
-                        info.semanticsId,
-                        prev?.EditableText,
-                        info.semanticsConfiguration?.EditableText
-                    )
-                )
-            }
-        ) {
-            TextField(
-                value = text,
-                onValueChange = { text = it },
-                modifier = Modifier.testTag("item")
-            )
-        }
-
-        // Act.
-        rule.runOnIdle { text = "text2" }
-
-        // Assert.
-        val semanticsId = rule.onNodeWithTag("item").semanticsId
-        rule.runOnIdle {
-            if (isSemanticAutofillEnabled) {
-                assertThat(events)
-                    .isExactly(Event(semanticsId, prevSemantics = "text1", newSemantics = "text2"))
-            } else {
-                assertThat(events).isEmpty()
-            }
-        }
-    }
-
-    @Test
-    fun FocusChange_withNoRecomposition() {
-        // Arrange.
-        val events = mutableListOf<Event<Boolean>>()
-        rule.setTestContent(
-            onSemanticsChange = { info, prev ->
-                events.add(
-                    Event(
-                        info.semanticsId,
-                        prev?.getOrNull(SemanticsProperties.Focused),
-                        info.semanticsConfiguration?.getOrNull(SemanticsProperties.Focused)
-                    )
-                )
-            }
-        ) {
-            Column {
-                Box(Modifier.testTag("item1").size(100.dp).focusable())
-                Box(Modifier.testTag("item2").size(100.dp).focusable())
-            }
-        }
-        rule.onNodeWithTag("item1").requestFocus()
-        rule.runOnIdle { events.clear() }
-
-        // Act.
-        rule.onNodeWithTag("item2").requestFocus()
-
-        // Assert.
-        val item1 = rule.onNodeWithTag("item1").semanticsId
-        val item2 = rule.onNodeWithTag("item2").semanticsId
-        rule.runOnIdle {
-            if (isSemanticAutofillEnabled) {
-                assertThat(events)
-                    .isExactly(
-                        Event(item1, prevSemantics = true, newSemantics = false),
-                        Event(item2, prevSemantics = false, newSemantics = true)
-                    )
-            } else {
-                assertThat(events).isEmpty()
-            }
-        }
-    }
-
-    @Test
-    fun FocusChange_thatCausesRecomposition() {
-        // Arrange.
-        val events = mutableListOf<Event<Boolean>>()
-        rule.setTestContent(
-            onSemanticsChange = { info, prev ->
-                events.add(
-                    Event(
-                        info.semanticsId,
-                        prev?.getOrNull(SemanticsProperties.Focused),
-                        info.semanticsConfiguration?.getOrNull(SemanticsProperties.Focused)
-                    )
-                )
-            }
-        ) {
-            Column {
-                FocusableBox(Modifier.testTag("item1"))
-                FocusableBox(Modifier.testTag("item2"))
-            }
-        }
-        rule.onNodeWithTag("item1").requestFocus()
-        rule.runOnIdle { events.clear() }
-
-        // Act.
-        rule.onNodeWithTag("item2").requestFocus()
-
-        // Assert.
-        val item1 = rule.onNodeWithTag("item1").semanticsId
-        val item2 = rule.onNodeWithTag("item2").semanticsId
-        rule.runOnIdle {
-            if (isSemanticAutofillEnabled) {
-                assertThat(events)
-                    .isExactly(
-                        Event(item1, prevSemantics = true, newSemantics = false),
-                        Event(item2, prevSemantics = false, newSemantics = true)
-                    )
-            } else {
-                assertThat(events).isEmpty()
-            }
-        }
-    }
-
-    private val SemanticsConfiguration.Text
-        get() = getOrNull(SemanticsProperties.Text)?.fastJoinToString()
-
-    private val SemanticsConfiguration.EditableText
-        get() = getOrNull(SemanticsProperties.EditableText)?.toString()
-
-    private fun ComposeContentTestRule.setTestContent(
-        onSemanticsChange: (SemanticsInfo, SemanticsConfiguration?) -> Unit,
-        composable: @Composable () -> Unit
-    ) {
-        val semanticsListener =
-            object : SemanticsListener {
-                override fun onSemanticsChanged(
-                    semanticsInfo: SemanticsInfo,
-                    previousSemanticsConfiguration: SemanticsConfiguration?
-                ) {
-                    onSemanticsChange(semanticsInfo, previousSemanticsConfiguration)
-                }
-            }
-        setContent {
-            semanticsOwner = (LocalView.current as RootForTest).semanticsOwner
-            DisposableEffect(semanticsOwner) {
-                semanticsOwner.listeners.add(semanticsListener)
-                onDispose { semanticsOwner.listeners.remove(semanticsListener) }
-            }
-            composable()
-        }
-    }
-
-    data class Event<T>(val semanticsId: Int, val prevSemantics: T?, val newSemantics: T?)
-
-    // TODO(b/272068594): Add api to fetch the semantics id from SemanticsNodeInteraction directly.
-    private val SemanticsNodeInteraction.semanticsId: Int
-        get() = fetchSemanticsNode().id
-
-    @Composable
-    private fun FocusableBox(
-        modifier: Modifier = Modifier,
-        content: @Composable BoxScope.() -> Unit = {}
-    ) {
-        var borderColor by remember { mutableStateOf(Black) }
-        Box(
-            modifier =
-                modifier
-                    .size(100.dp)
-                    .onFocusChanged { borderColor = if (it.isFocused) Red else Black }
-                    .border(2.dp, borderColor)
-                    .focusable(),
-            content = content
-        )
-    }
-}
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
deleted file mode 100644
index 5147cbe..0000000
--- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/semantics/SemanticsModifierNodeTest.kt
+++ /dev/null
@@ -1,211 +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.semantics
-
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.lazy.LazyListState
-import androidx.compose.foundation.lazy.LazyRow
-import androidx.compose.foundation.lazy.rememberLazyListState
-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.node.SemanticsModifierNode
-import androidx.compose.ui.node.elementOf
-import androidx.compose.ui.node.invalidateSemantics
-import androidx.compose.ui.platform.LocalView
-import androidx.compose.ui.platform.testTag
-import androidx.compose.ui.test.junit4.createComposeRule
-import androidx.compose.ui.test.onNodeWithTag
-import androidx.compose.ui.text.AnnotatedString
-import androidx.compose.ui.unit.dp
-import androidx.test.filters.SmallTest
-import com.google.common.truth.Truth.assertThat
-import org.junit.Before
-import org.junit.Rule
-import org.junit.Test
-import org.junit.runner.RunWith
-import org.junit.runners.Parameterized
-
-@SmallTest
-@RunWith(Parameterized::class)
-class SemanticsModifierNodeTest(private val precomputedSemantics: Boolean) {
-    @get:Rule val rule = createComposeRule()
-
-    companion object {
-        @JvmStatic
-        @Parameterized.Parameters(name = "pre-computed semantics = {0}")
-        fun initParameters() = listOf(false, true)
-    }
-
-    @Before
-    fun setup() {
-        @OptIn(ExperimentalComposeUiApi::class)
-        ComposeUiFlags.isSemanticAutofillEnabled = precomputedSemantics
-    }
-
-    @Test
-    fun applySemantics_calledWhenSemanticsIsRead() {
-        // Arrange.
-        var applySemanticsInvoked = false
-        rule.setContent {
-            Box(
-                Modifier.elementOf(
-                    TestSemanticsModifier {
-                        testTag = "TestTag"
-                        applySemanticsInvoked = true
-                    }
-                )
-            )
-        }
-
-        // Act.
-        rule.onNodeWithTag("TestTag").fetchSemanticsNode()
-
-        // Assert.
-        rule.runOnIdle { assertThat(applySemanticsInvoked).isTrue() }
-    }
-
-    @Test
-    fun invalidateSemantics_applySemanticsIsCalled() {
-        // Arrange.
-        var applySemanticsInvoked: Boolean
-        val semanticsModifier = TestSemanticsModifier {
-            testTag = "TestTag"
-            applySemanticsInvoked = true
-        }
-        rule.setContent { Box(Modifier.elementOf(semanticsModifier)) }
-        applySemanticsInvoked = false
-
-        // Act.
-        rule.runOnIdle { semanticsModifier.invalidateSemantics() }
-
-        // Assert - Apply semantics is not called when we calculate semantics lazily.
-        if (precomputedSemantics) {
-            assertThat(applySemanticsInvoked).isTrue()
-        } else {
-            assertThat(applySemanticsInvoked).isFalse()
-        }
-    }
-
-    @Test
-    fun invalidateSemantics_applySemanticsNotCalledAgain_whenSemanticsConfigurationIsRead() {
-        // Arrange.
-        lateinit var rootForTest: RootForTest
-        var applySemanticsInvoked = false
-        var invocationCount = 0
-        val semanticsModifier = TestSemanticsModifier {
-            testTag = "TestTag"
-            text = AnnotatedString("Text ${invocationCount++}")
-            applySemanticsInvoked = true
-        }
-        rule.setContent {
-            rootForTest = LocalView.current as RootForTest
-            Box(Modifier.elementOf(semanticsModifier))
-        }
-        val semanticsId = rule.onNodeWithTag("TestTag").semanticsId()
-        rule.runOnIdle {
-            semanticsModifier.invalidateSemantics()
-            applySemanticsInvoked = false
-        }
-
-        // Act.
-        val semanticsInfo = checkNotNull(rootForTest.semanticsOwner[semanticsId])
-        val semanticsConfiguration = semanticsInfo.semanticsConfiguration
-
-        // Assert - Configuration recalculated when we calculate semantics lazily.
-        if (precomputedSemantics) {
-            assertThat(applySemanticsInvoked).isFalse()
-        } else {
-            assertThat(applySemanticsInvoked).isTrue()
-        }
-        assertThat(semanticsConfiguration?.text()).containsExactly("Text 2")
-    }
-
-    @Test
-    fun readingSemanticsConfigurationOfDeactivatedNode() {
-        // Arrange.
-        lateinit var lazyListState: LazyListState
-        lateinit var rootForTest: RootForTest
-        rule.setContent {
-            rootForTest = LocalView.current as RootForTest
-            lazyListState = rememberLazyListState()
-            LazyRow(state = lazyListState, modifier = Modifier.size(10.dp)) {
-                items(2) { index ->
-                    Box(Modifier.size(10.dp).testTag("$index").elementOf(TestSemanticsModifier {}))
-                }
-            }
-        }
-        val semanticsId = rule.onNodeWithTag("0").semanticsId()
-        val semanticsInfo = checkNotNull(rootForTest.semanticsOwner[semanticsId])
-
-        // Act.
-        rule.runOnIdle { lazyListState.requestScrollToItem(1) }
-        val semanticsConfiguration = rule.runOnIdle { semanticsInfo.semanticsConfiguration }
-
-        // Assert.
-        rule.runOnIdle {
-            assertThat(semanticsInfo.isDeactivated).isTrue()
-            assertThat(semanticsConfiguration).isNull()
-        }
-    }
-
-    @Test
-    fun readingSemanticsConfigurationOfDeactivatedNode_afterCallingInvalidate() {
-        // Arrange.
-        lateinit var lazyListState: LazyListState
-        lateinit var rootForTest: RootForTest
-        val semanticsModifierNodes = List(2) { TestSemanticsModifier {} }
-        rule.setContent {
-            rootForTest = LocalView.current as RootForTest
-            lazyListState = rememberLazyListState()
-            LazyRow(state = lazyListState, modifier = Modifier.size(10.dp)) {
-                items(2) { index ->
-                    Box(
-                        Modifier.size(10.dp)
-                            .testTag("$index")
-                            .elementOf(semanticsModifierNodes[index])
-                    )
-                }
-            }
-        }
-        val semanticsId = rule.onNodeWithTag("0").semanticsId()
-        val semanticsInfo = checkNotNull(rootForTest.semanticsOwner[semanticsId])
-
-        // Act.
-        rule.runOnIdle { lazyListState.requestScrollToItem(1) }
-        semanticsModifierNodes[0].invalidateSemantics()
-        val semanticsConfiguration = rule.runOnIdle { semanticsInfo.semanticsConfiguration }
-
-        // Assert.
-        rule.runOnIdle {
-            assertThat(semanticsInfo.isDeactivated).isTrue()
-            assertThat(semanticsConfiguration).isNull()
-        }
-    }
-
-    fun SemanticsConfiguration.text() = getOrNull(SemanticsProperties.Text)?.map { it.text }
-
-    class TestSemanticsModifier(
-        private val onApplySemantics: SemanticsPropertyReceiver.() -> Unit
-    ) : SemanticsModifierNode, Modifier.Node() {
-        override fun SemanticsPropertyReceiver.applySemantics() {
-            onApplySemantics.invoke(this)
-        }
-    }
-}
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 f1f0567..2546cb2 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
@@ -187,8 +187,7 @@
 
     private fun notifyAutofillValueChanged(semanticsId: Int, newAutofillValue: Any) {
         val currSemanticsNode = currentSemanticsNodes[semanticsId]?.semanticsNode
-        val currDataType =
-            currSemanticsNode?.unmergedConfig?.getOrNull(SemanticsContentDataType) ?: return
+        val currDataType = currSemanticsNode?.unmergedConfig?.getOrNull(SemanticsContentDataType)
 
         when (currDataType) {
             ContentDataType.Text ->
@@ -296,8 +295,6 @@
                     SemanticsContentDataType
                 )
         }
-    // 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)
 
     // Iterate through currentSemanticsNodes, finding autofill-related nodes
@@ -477,7 +474,7 @@
 }
 
 @RequiresApi(Build.VERSION_CODES.O)
-private class AutofillManagerWrapperImpl(val view: View) : AutofillManagerWrapper {
+private class AutofillManagerWrapperImpl(val view: AndroidComposeView) : AutofillManagerWrapper {
     override val autofillManager =
         view.context.getSystemService(PlatformAndroidManager::class.java)
             ?: error("Autofill service could not be located.")
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 6e59d0c..459b567 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
@@ -64,8 +64,6 @@
 import androidx.annotation.DoNotInline
 import androidx.annotation.RequiresApi
 import androidx.annotation.VisibleForTesting
-import androidx.collection.MutableIntObjectMap
-import androidx.collection.mutableIntObjectMapOf
 import androidx.compose.runtime.collection.mutableVectorOf
 import androidx.compose.runtime.derivedStateOf
 import androidx.compose.runtime.getValue
@@ -432,12 +430,9 @@
                     .then(dragAndDropManager.modifier)
         }
 
-    override val layoutNodes: MutableIntObjectMap<LayoutNode> = mutableIntObjectMapOf()
-
     override val rootForTest: RootForTest = this
 
-    override val semanticsOwner: SemanticsOwner =
-        SemanticsOwner(root, rootSemanticsNode, layoutNodes)
+    override val semanticsOwner: SemanticsOwner = SemanticsOwner(root, rootSemanticsNode)
     private val composeAccessibilityDelegate = AndroidComposeViewAccessibilityDelegateCompat(this)
     internal var contentCaptureManager =
         AndroidContentCaptureManager(
@@ -1031,12 +1026,9 @@
         composeAccessibilityDelegate.SendRecurringAccessibilityEventsIntervalMillis = intervalMillis
     }
 
-    override fun onAttach(node: LayoutNode) {
-        layoutNodes[node.semanticsId] = node
-    }
+    override fun onAttach(node: LayoutNode) {}
 
     override fun onDetach(node: LayoutNode) {
-        layoutNodes.remove(node.semanticsId)
         measureAndLayoutDelegate.onNodeDetached(node)
         requestClearInvalidObservations()
         @OptIn(ExperimentalComposeUiApi::class)
@@ -1616,12 +1608,6 @@
         }
     }
 
-    override fun onLayoutNodeIdChange(layoutNode: LayoutNode, oldSemanticsId: Int) {
-        // Keep the mapping up to date when the semanticsId changes
-        layoutNodes.remove(oldSemanticsId)
-        layoutNodes[layoutNode.semanticsId] = layoutNode
-    }
-
     override fun onInteropViewLayoutChange(view: InteropView) {
         isPendingInteropViewLayoutChangeDispatch = true
     }
diff --git a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/AndroidComposeViewAccessibilityDelegateCompat.android.kt b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/AndroidComposeViewAccessibilityDelegateCompat.android.kt
index af22fff..7036817 100644
--- a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/AndroidComposeViewAccessibilityDelegateCompat.android.kt
+++ b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/AndroidComposeViewAccessibilityDelegateCompat.android.kt
@@ -18,7 +18,6 @@
 
 import android.accessibilityservice.AccessibilityServiceInfo.FEEDBACK_ALL_MASK
 import android.content.Context
-import android.content.res.Resources
 import android.graphics.RectF
 import android.os.Build
 import android.os.Bundle
@@ -183,20 +182,6 @@
     }
 }
 
-private val semanticComparators: Array<Comparator<SemanticsNode>> =
-    Array(2) { index ->
-        val comparator =
-            when (index) {
-                0 -> RtlBoundsComparator
-                else -> LtrBoundsComparator
-            }
-        comparator
-            // then compare by layoutNode's zIndex and placement order
-            .thenBy(LayoutNode.ZComparator) { it.layoutNode }
-            // then compare by semanticsId to break the tie somehow
-            .thenBy { it.id }
-    }
-
 // Kotlin `sortWith` should just pull out the highest traversal indices, but keep everything
 // else in place. If the element does not have a `traversalIndex` then `0f` will be used.
 private val UnmergedConfigComparator: (SemanticsNode, SemanticsNode) -> Int = { a, b ->
@@ -385,7 +370,7 @@
                 currentSemanticsNodesInvalidated = false
                 field = view.semanticsOwner.getAllUncoveredSemanticsNodesToIntObjectMap()
                 if (isEnabled) {
-                    setTraversalValues(field, idToBeforeMap, idToAfterMap, view.context.resources)
+                    setTraversalValues()
                 }
             }
             return field
@@ -555,13 +540,226 @@
         )
     }
 
+    private val semanticComparators: Array<Comparator<SemanticsNode>> =
+        Array(2) { index ->
+            val comparator =
+                when (index) {
+                    0 -> RtlBoundsComparator
+                    else -> LtrBoundsComparator
+                }
+            comparator
+                // then compare by layoutNode's zIndex and placement order
+                .thenBy(LayoutNode.ZComparator) { it.layoutNode }
+                // then compare by semanticsId to break the tie somehow
+                .thenBy { it.id }
+        }
+
+    @Suppress("NOTHING_TO_INLINE")
+    private inline fun semanticComparator(layoutIsRtl: Boolean): Comparator<SemanticsNode> {
+        return semanticComparators[if (layoutIsRtl) 0 else 1]
+    }
+
+    // check to see if this entry overlaps with any groupings in rowGroupings
+    private fun placedEntryRowOverlaps(
+        rowGroupings: ArrayList<Pair<Rect, MutableList<SemanticsNode>>>,
+        node: SemanticsNode
+    ): Boolean {
+        // Conversion to long is needed in order to utilize `until`, which has no float ver
+        val entryTopCoord = node.boundsInWindow.top
+        val entryBottomCoord = node.boundsInWindow.bottom
+        val entryIsEmpty = entryTopCoord >= entryBottomCoord
+
+        for (currIndex in 0..rowGroupings.lastIndex) {
+            val currRect = rowGroupings[currIndex].first
+            val groupIsEmpty = currRect.top >= currRect.bottom
+            val groupOverlapsEntry =
+                !entryIsEmpty &&
+                    !groupIsEmpty &&
+                    max(entryTopCoord, currRect.top) < min(entryBottomCoord, currRect.bottom)
+
+            // If it overlaps with this row group, update cover and add node
+            if (groupOverlapsEntry) {
+                val newRect =
+                    currRect.intersect(0f, entryTopCoord, Float.POSITIVE_INFINITY, entryBottomCoord)
+                // Replace the cover rectangle, copying over the old list of nodes
+                rowGroupings[currIndex] = Pair(newRect, rowGroupings[currIndex].second)
+                // Add current node
+                rowGroupings[currIndex].second.add(node)
+                // We've found an overlapping group, return true
+                return true
+            }
+        }
+
+        // If we've made it here, then there are no groups our entry overlaps with
+        return false
+    }
+
+    /**
+     * Returns the results of geometry groupings, which is determined from 1) grouping nodes into
+     * distinct, non-overlapping rows based on their top/bottom coordinates, then 2) sorting nodes
+     * within each row with the semantics comparator.
+     *
+     * This method approaches traversal order with more nuance than an approach considering only
+     * just hierarchy or only just an individual node's bounds.
+     *
+     * If [containerChildrenMapping] exists, there are additional children to add, as well as the
+     * sorted parent itself
+     */
+    private fun sortByGeometryGroupings(
+        layoutIsRtl: Boolean,
+        parentListToSort: ArrayList<SemanticsNode>,
+        containerChildrenMapping: MutableIntObjectMap<MutableList<SemanticsNode>> =
+            mutableIntObjectMapOf()
+    ): MutableList<SemanticsNode> {
+        // RowGroupings list consists of pairs, first = a rectangle of the bounds of the row
+        // and second = the list of nodes in that row
+        val rowGroupings =
+            ArrayList<Pair<Rect, MutableList<SemanticsNode>>>(parentListToSort.size / 2)
+
+        for (entryIndex in 0..parentListToSort.lastIndex) {
+            val currEntry = parentListToSort[entryIndex]
+            // If this is the first entry, or vertical groups don't overlap
+            if (entryIndex == 0 || !placedEntryRowOverlaps(rowGroupings, currEntry)) {
+                val newRect = currEntry.boundsInWindow
+                rowGroupings.add(Pair(newRect, mutableListOf(currEntry)))
+            } // otherwise, we've already iterated through, found and placed it in a matching group
+        }
+
+        // Sort the rows from top to bottom
+        rowGroupings.sortWith(TopBottomBoundsComparator)
+
+        val returnList = ArrayList<SemanticsNode>()
+        val comparator = semanticComparator(layoutIsRtl)
+        rowGroupings.fastForEach { row ->
+            // Sort each individual row's parent nodes
+            row.second.sortWith(comparator)
+            returnList.addAll(row.second)
+        }
+
+        returnList.sortWith(UnmergedConfigComparator)
+
+        var i = 0
+        // Afterwards, go in and add the containers' children.
+        while (i <= returnList.lastIndex) {
+            val currNodeId = returnList[i].id
+            // If a parent node is a container, then add its children.
+            // Add all container's children after the container itself.
+            // Because we've already recursed on the containers children, the children should
+            // also be sorted by their traversal index
+            val containersChildrenList = containerChildrenMapping[currNodeId]
+            if (containersChildrenList != null) {
+                val containerIsScreenReaderFocusable = isScreenReaderFocusable(returnList[i])
+                if (!containerIsScreenReaderFocusable) {
+                    // Container is removed if it is not screenreader-focusable
+                    returnList.removeAt(i)
+                } else {
+                    // Increase counter if the container was not removed
+                    i += 1
+                }
+                // Add all the container's children and increase counter by the number of children
+                returnList.addAll(i, containersChildrenList)
+                i += containersChildrenList.size
+            } else {
+                // Advance to the next item
+                i += 1
+            }
+        }
+        return returnList
+    }
+
+    private fun geometryDepthFirstSearch(
+        currNode: SemanticsNode,
+        geometryList: ArrayList<SemanticsNode>,
+        containerMapToChildren: MutableIntObjectMap<MutableList<SemanticsNode>>
+    ) {
+        val currRTL = currNode.isRtl
+        // We only want to add children that are either traversalGroups or are
+        // screen reader focusable. The child must also be in the current pruned semantics tree.
+        val isTraversalGroup =
+            currNode.unmergedConfig.getOrElse(SemanticsProperties.IsTraversalGroup) { false }
+
+        if (
+            (isTraversalGroup || isScreenReaderFocusable(currNode)) &&
+                currentSemanticsNodes.containsKey(currNode.id)
+        ) {
+            geometryList.add(currNode)
+        }
+        if (isTraversalGroup) {
+            // Recurse and record the container's children, sorted
+            containerMapToChildren[currNode.id] =
+                subtreeSortedByGeometryGrouping(currRTL, currNode.children)
+        } else {
+            // Otherwise, continue adding children to the list that'll be sorted regardless of
+            // hierarchy
+            currNode.children.fastForEach { child ->
+                geometryDepthFirstSearch(child, geometryList, containerMapToChildren)
+            }
+        }
+    }
+
+    /**
+     * This function prepares a subtree for `sortByGeometryGroupings` by retrieving all
+     * non-container nodes and adding them to the list to be geometrically sorted. We recurse on
+     * containers (if they exist) and add their sorted children to an optional mapping. The list to
+     * be sorted and child mapping is passed into `sortByGeometryGroupings`.
+     */
+    private fun subtreeSortedByGeometryGrouping(
+        layoutIsRtl: Boolean,
+        listToSort: List<SemanticsNode>
+    ): MutableList<SemanticsNode> {
+        // This should be mapping of [containerID: listOfSortedChildren], only populated if there
+        // are container nodes in this level. If there are container nodes, `containerMapToChildren`
+        // would look like {containerId: [sortedChild, sortedChild], containerId: [sortedChild]}
+        val containerMapToChildren = mutableIntObjectMapOf<MutableList<SemanticsNode>>()
+        val geometryList = ArrayList<SemanticsNode>()
+
+        listToSort.fastForEach { node ->
+            geometryDepthFirstSearch(node, geometryList, containerMapToChildren)
+        }
+
+        return sortByGeometryGroupings(layoutIsRtl, geometryList, containerMapToChildren)
+    }
+
+    private fun setTraversalValues() {
+        idToBeforeMap.clear()
+        idToAfterMap.clear()
+
+        val hostSemanticsNode =
+            currentSemanticsNodes[AccessibilityNodeProviderCompat.HOST_VIEW_ID]?.semanticsNode!!
+        val hostLayoutIsRtl = hostSemanticsNode.isRtl
+
+        val semanticsOrderList =
+            subtreeSortedByGeometryGrouping(hostLayoutIsRtl, listOf(hostSemanticsNode))
+
+        // Iterate through our ordered list, and creating a mapping of current node to next node ID
+        // We'll later read through this and set traversal order with IdToBeforeMap
+        for (i in 1..semanticsOrderList.lastIndex) {
+            val prevId = semanticsOrderList[i - 1].id
+            val currId = semanticsOrderList[i].id
+            idToBeforeMap[prevId] = currId
+            idToAfterMap[currId] = prevId
+        }
+    }
+
+    private fun isScreenReaderFocusable(node: SemanticsNode): Boolean {
+        val nodeContentDescriptionOrNull =
+            node.unmergedConfig.getOrNull(SemanticsProperties.ContentDescription)?.firstOrNull()
+        val isSpeakingNode =
+            nodeContentDescriptionOrNull != null ||
+                getInfoText(node) != null ||
+                getInfoStateDescriptionOrNull(node) != null ||
+                getInfoIsCheckable(node)
+
+        return !node.isHidden &&
+            (node.unmergedConfig.isMergingSemanticsOfDescendants ||
+                node.isUnmergedLeafNode && isSpeakingNode)
+    }
+
     private fun populateAccessibilityNodeInfoProperties(
         virtualViewId: Int,
         info: AccessibilityNodeInfoCompat,
         semanticsNode: SemanticsNode
     ) {
-        val resources = view.context.resources
-
         // set classname
         info.className = ClassName
 
@@ -577,9 +775,9 @@
         role?.let {
             if (semanticsNode.isFake || semanticsNode.replacedChildren.isEmpty()) {
                 if (role == Role.Tab) {
-                    info.roleDescription = resources.getString(R.string.tab)
+                    info.roleDescription = view.context.resources.getString(R.string.tab)
                 } else if (role == Role.Switch) {
-                    info.roleDescription = resources.getString(R.string.switch_role)
+                    info.roleDescription = view.context.resources.getString(R.string.switch_role)
                 } else {
                     val className = role.toLegacyClassName()
                     // Images are often minor children of larger widgets, so we only want to
@@ -634,8 +832,8 @@
 
         setText(semanticsNode, info)
         setContentInvalid(semanticsNode, info)
-        info.stateDescription = getInfoStateDescriptionOrNull(semanticsNode, resources)
-        info.isCheckable = getInfoIsCheckable(semanticsNode)
+        setStateDescription(semanticsNode, info)
+        setIsCheckable(semanticsNode, info)
 
         val toggleState =
             semanticsNode.unmergedConfig.getOrNull(SemanticsProperties.ToggleableState)
@@ -1029,7 +1227,7 @@
             }
         }
 
-        info.isScreenReaderFocusable = isScreenReaderFocusable(semanticsNode, resources)
+        info.isScreenReaderFocusable = isScreenReaderFocusable(semanticsNode)
 
         // `beforeId` refers to the semanticsId that should be read before this `virtualViewId`.
         val beforeId = idToBeforeMap.getOrDefault(virtualViewId, -1)
@@ -1077,6 +1275,143 @@
         }
     }
 
+    private fun getInfoStateDescriptionOrNull(node: SemanticsNode): String? {
+        var stateDescription = node.unmergedConfig.getOrNull(SemanticsProperties.StateDescription)
+        val toggleState = node.unmergedConfig.getOrNull(SemanticsProperties.ToggleableState)
+        val role = node.unmergedConfig.getOrNull(SemanticsProperties.Role)
+
+        // Check toggle state and retrieve description accordingly
+        toggleState?.let {
+            when (it) {
+                ToggleableState.On -> {
+                    // Unfortunately, talkback has a bug of using "checked", so we set state
+                    // description here
+                    if (role == Role.Switch && stateDescription == null) {
+                        stateDescription = view.context.resources.getString(R.string.state_on)
+                    }
+                }
+                ToggleableState.Off -> {
+                    // Unfortunately, talkback has a bug of using "not checked", so we set state
+                    // description here
+                    if (role == Role.Switch && stateDescription == null) {
+                        stateDescription = view.context.resources.getString(R.string.state_off)
+                    }
+                }
+                ToggleableState.Indeterminate -> {
+                    if (stateDescription == null) {
+                        stateDescription = view.context.resources.getString(R.string.indeterminate)
+                    }
+                }
+            }
+        }
+
+        // Check Selected property and retrieve description accordingly
+        node.unmergedConfig.getOrNull(SemanticsProperties.Selected)?.let {
+            if (role != Role.Tab) {
+                if (stateDescription == null) {
+                    // If a radio entry (radio button + text) is selectable, it won't have the role
+                    // RadioButton, so if we use info.isCheckable/info.isChecked, talkback will say
+                    // "checked/not checked" instead "selected/note selected".
+                    stateDescription =
+                        if (it) {
+                            view.context.resources.getString(R.string.selected)
+                        } else {
+                            view.context.resources.getString(R.string.not_selected)
+                        }
+                }
+            }
+        }
+
+        // Check if a node has progress bar range info and retrieve description accordingly
+        val rangeInfo = node.unmergedConfig.getOrNull(SemanticsProperties.ProgressBarRangeInfo)
+        rangeInfo?.let {
+            // let's set state description here and use state description change events.
+            // otherwise, we need to send out type_view_selected event, as the old android
+            // versions do. But the support for type_view_selected event for progress bars
+            // maybe deprecated in talkback in the future.
+            if (rangeInfo !== ProgressBarRangeInfo.Indeterminate) {
+                if (stateDescription == null) {
+                    val valueRange = rangeInfo.range
+                    val progress =
+                        (if (valueRange.endInclusive - valueRange.start == 0f) 0f
+                            else
+                                (rangeInfo.current - valueRange.start) /
+                                    (valueRange.endInclusive - valueRange.start))
+                            .fastCoerceIn(0f, 1f)
+
+                    // We only display 0% or 100% when it is exactly 0% or 100%.
+                    val percent =
+                        when (progress) {
+                            0f -> 0
+                            1f -> 100
+                            else -> (progress * 100).fastRoundToInt().coerceIn(1, 99)
+                        }
+                    stateDescription =
+                        view.context.resources.getString(R.string.template_percent, percent)
+                }
+            } else if (stateDescription == null) {
+                stateDescription = view.context.resources.getString(R.string.in_progress)
+            }
+        }
+
+        if (node.unmergedConfig.contains(SemanticsProperties.EditableText)) {
+            stateDescription = createStateDescriptionForTextField(node)
+        }
+
+        return stateDescription
+    }
+
+    /**
+     * Empty text field should not be ignored by the TB so we set a state description. When there is
+     * a speakable child, like a label or a placeholder text, setting this state description is
+     * redundant
+     */
+    private fun createStateDescriptionForTextField(node: SemanticsNode): String? {
+        val mergedConfig = node.copyWithMergingEnabled().config
+        val mergedNodeIsUnspeakable =
+            mergedConfig.getOrNull(SemanticsProperties.ContentDescription).isNullOrEmpty() &&
+                mergedConfig.getOrNull(SemanticsProperties.Text).isNullOrEmpty() &&
+                mergedConfig.getOrNull(SemanticsProperties.EditableText).isNullOrEmpty()
+        return if (mergedNodeIsUnspeakable) {
+            view.context.resources.getString(R.string.state_empty)
+        } else null
+    }
+
+    private fun setStateDescription(
+        node: SemanticsNode,
+        info: AccessibilityNodeInfoCompat,
+    ) {
+        info.stateDescription = getInfoStateDescriptionOrNull(node)
+    }
+
+    private fun getInfoIsCheckable(node: SemanticsNode): Boolean {
+        var isCheckable = false
+        val toggleState = node.unmergedConfig.getOrNull(SemanticsProperties.ToggleableState)
+        val role = node.unmergedConfig.getOrNull(SemanticsProperties.Role)
+
+        toggleState?.let { isCheckable = true }
+
+        node.unmergedConfig.getOrNull(SemanticsProperties.Selected)?.let {
+            if (role != Role.Tab) {
+                isCheckable = true
+            }
+        }
+
+        return isCheckable
+    }
+
+    private fun setIsCheckable(node: SemanticsNode, info: AccessibilityNodeInfoCompat) {
+        info.isCheckable = getInfoIsCheckable(node)
+    }
+
+    // This needs to be here instead of around line 3000 because we need access to the `view`
+    // that is inside the `AndroidComposeViewAccessibilityDelegateCompat` class
+    private fun getInfoText(node: SemanticsNode): AnnotatedString? {
+        val editableTextToAssign = node.unmergedConfig.getTextForTextField()
+        val textToAssign = node.unmergedConfig.getOrNull(SemanticsProperties.Text)?.firstOrNull()
+        return editableTextToAssign ?: textToAssign
+    }
+
     @OptIn(InternalTextApi::class)
     private fun AnnotatedString.toSpannableString(): SpannableString? {
         val fontFamilyResolver: FontFamily.Resolver = view.fontFamilyResolver
@@ -2020,11 +2355,11 @@
             if (layoutNode.nodes.has(Nodes.Semantics)) layoutNode
             else layoutNode.findClosestParentNode { it.nodes.has(Nodes.Semantics) }
 
-        val config = semanticsNode?.semanticsConfiguration ?: return
+        val config = semanticsNode?.collapsedSemantics ?: return
         if (!config.isMergingSemanticsOfDescendants) {
             semanticsNode
                 .findClosestParentNode {
-                    it.semanticsConfiguration?.isMergingSemanticsOfDescendants == true
+                    it.collapsedSemantics?.isMergingSemanticsOfDescendants == true
                 }
                 ?.let { semanticsNode = it }
         }
@@ -2901,353 +3236,6 @@
     }
 }
 
-private fun setTraversalValues(
-    currentSemanticsNodes: IntObjectMap<SemanticsNodeWithAdjustedBounds>,
-    idToBeforeMap: MutableIntIntMap,
-    idToAfterMap: MutableIntIntMap,
-    resources: Resources
-) {
-    idToBeforeMap.clear()
-    idToAfterMap.clear()
-
-    val hostSemanticsNode =
-        currentSemanticsNodes[AccessibilityNodeProviderCompat.HOST_VIEW_ID]?.semanticsNode!!
-    val hostLayoutIsRtl = hostSemanticsNode.isRtl
-
-    val semanticsOrderList =
-        subtreeSortedByGeometryGrouping(
-            hostLayoutIsRtl,
-            listOf(hostSemanticsNode),
-            currentSemanticsNodes,
-            resources
-        )
-
-    // Iterate through our ordered list, and creating a mapping of current node to next node ID
-    // We'll later read through this and set traversal order with IdToBeforeMap
-    for (i in 1..semanticsOrderList.lastIndex) {
-        val prevId = semanticsOrderList[i - 1].id
-        val currId = semanticsOrderList[i].id
-        idToBeforeMap[prevId] = currId
-        idToAfterMap[currId] = prevId
-    }
-}
-
-/**
- * This function prepares a subtree for `sortByGeometryGroupings` by retrieving all non-container
- * nodes and adding them to the list to be geometrically sorted. We recurse on containers (if they
- * exist) and add their sorted children to an optional mapping. The list to be sorted and child
- * mapping is passed into `sortByGeometryGroupings`.
- */
-private fun subtreeSortedByGeometryGrouping(
-    layoutIsRtl: Boolean,
-    listToSort: List<SemanticsNode>,
-    currentSemanticsNodes: IntObjectMap<SemanticsNodeWithAdjustedBounds>,
-    resources: Resources
-): MutableList<SemanticsNode> {
-    // This should be mapping of [containerID: listOfSortedChildren], only populated if there
-    // are container nodes in this level. If there are container nodes, `containerMapToChildren`
-    // would look like {containerId: [sortedChild, sortedChild], containerId: [sortedChild]}
-    val containerMapToChildren = mutableIntObjectMapOf<MutableList<SemanticsNode>>()
-    val geometryList = ArrayList<SemanticsNode>()
-
-    listToSort.fastForEach { node ->
-        geometryDepthFirstSearch(
-            node,
-            geometryList,
-            containerMapToChildren,
-            currentSemanticsNodes,
-            resources
-        )
-    }
-
-    return sortByGeometryGroupings(layoutIsRtl, geometryList, resources, containerMapToChildren)
-}
-
-private fun geometryDepthFirstSearch(
-    currNode: SemanticsNode,
-    geometryList: ArrayList<SemanticsNode>,
-    containerMapToChildren: MutableIntObjectMap<MutableList<SemanticsNode>>,
-    currentSemanticsNodes: IntObjectMap<SemanticsNodeWithAdjustedBounds>,
-    resources: Resources
-) {
-    val currRTL = currNode.isRtl
-    // We only want to add children that are either traversalGroups or are
-    // screen reader focusable. The child must also be in the current pruned semantics tree.
-    val isTraversalGroup =
-        currNode.unmergedConfig.getOrElse(SemanticsProperties.IsTraversalGroup) { false }
-
-    if (
-        (isTraversalGroup || isScreenReaderFocusable(currNode, resources)) &&
-            currentSemanticsNodes.containsKey(currNode.id)
-    ) {
-        geometryList.add(currNode)
-    }
-    if (isTraversalGroup) {
-        // Recurse and record the container's children, sorted
-        containerMapToChildren[currNode.id] =
-            subtreeSortedByGeometryGrouping(
-                currRTL,
-                currNode.children,
-                currentSemanticsNodes,
-                resources
-            )
-    } else {
-        // Otherwise, continue adding children to the list that'll be sorted regardless of
-        // hierarchy
-        currNode.children.fastForEach { child ->
-            geometryDepthFirstSearch(
-                child,
-                geometryList,
-                containerMapToChildren,
-                currentSemanticsNodes,
-                resources
-            )
-        }
-    }
-}
-
-/**
- * Returns the results of geometry groupings, which is determined from 1) grouping nodes into
- * distinct, non-overlapping rows based on their top/bottom coordinates, then 2) sorting nodes
- * within each row with the semantics comparator.
- *
- * This method approaches traversal order with more nuance than an approach considering only just
- * hierarchy or only just an individual node's bounds.
- *
- * If [containerChildrenMapping] exists, there are additional children to add, as well as the sorted
- * parent itself
- */
-private fun sortByGeometryGroupings(
-    layoutIsRtl: Boolean,
-    parentListToSort: ArrayList<SemanticsNode>,
-    resources: Resources,
-    containerChildrenMapping: MutableIntObjectMap<MutableList<SemanticsNode>> =
-        mutableIntObjectMapOf()
-): MutableList<SemanticsNode> {
-    // RowGroupings list consists of pairs, first = a rectangle of the bounds of the row
-    // and second = the list of nodes in that row
-    val rowGroupings = ArrayList<Pair<Rect, MutableList<SemanticsNode>>>(parentListToSort.size / 2)
-
-    for (entryIndex in 0..parentListToSort.lastIndex) {
-        val currEntry = parentListToSort[entryIndex]
-        // If this is the first entry, or vertical groups don't overlap
-        if (entryIndex == 0 || !placedEntryRowOverlaps(rowGroupings, currEntry)) {
-            val newRect = currEntry.boundsInWindow
-            rowGroupings.add(Pair(newRect, mutableListOf(currEntry)))
-        } // otherwise, we've already iterated through, found and placed it in a matching group
-    }
-
-    // Sort the rows from top to bottom
-    rowGroupings.sortWith(TopBottomBoundsComparator)
-
-    val returnList = ArrayList<SemanticsNode>()
-    val comparator = semanticComparators[if (layoutIsRtl) 0 else 1]
-    rowGroupings.fastForEach { row ->
-        // Sort each individual row's parent nodes
-        row.second.sortWith(comparator)
-        returnList.addAll(row.second)
-    }
-
-    returnList.sortWith(UnmergedConfigComparator)
-
-    var i = 0
-    // Afterwards, go in and add the containers' children.
-    while (i <= returnList.lastIndex) {
-        val currNodeId = returnList[i].id
-        // If a parent node is a container, then add its children.
-        // Add all container's children after the container itself.
-        // Because we've already recursed on the containers children, the children should
-        // also be sorted by their traversal index
-        val containersChildrenList = containerChildrenMapping[currNodeId]
-        if (containersChildrenList != null) {
-            val containerIsScreenReaderFocusable = isScreenReaderFocusable(returnList[i], resources)
-            if (!containerIsScreenReaderFocusable) {
-                // Container is removed if it is not screenreader-focusable
-                returnList.removeAt(i)
-            } else {
-                // Increase counter if the container was not removed
-                i += 1
-            }
-            // Add all the container's children and increase counter by the number of children
-            returnList.addAll(i, containersChildrenList)
-            i += containersChildrenList.size
-        } else {
-            // Advance to the next item
-            i += 1
-        }
-    }
-    return returnList
-}
-
-private fun isScreenReaderFocusable(node: SemanticsNode, resources: Resources): Boolean {
-    val nodeContentDescriptionOrNull =
-        node.unmergedConfig.getOrNull(SemanticsProperties.ContentDescription)?.firstOrNull()
-    val isSpeakingNode =
-        nodeContentDescriptionOrNull != null ||
-            getInfoText(node) != null ||
-            getInfoStateDescriptionOrNull(node, resources) != null ||
-            getInfoIsCheckable(node)
-
-    return !node.isHidden &&
-        (node.unmergedConfig.isMergingSemanticsOfDescendants ||
-            node.isUnmergedLeafNode && isSpeakingNode)
-}
-
-private fun getInfoText(node: SemanticsNode): AnnotatedString? {
-    val editableTextToAssign = node.unmergedConfig.getOrNull(SemanticsProperties.EditableText)
-    val textToAssign = node.unmergedConfig.getOrNull(SemanticsProperties.Text)?.firstOrNull()
-    return editableTextToAssign ?: textToAssign
-}
-
-private fun getInfoStateDescriptionOrNull(node: SemanticsNode, resources: Resources): String? {
-    var stateDescription = node.unmergedConfig.getOrNull(SemanticsProperties.StateDescription)
-    val toggleState = node.unmergedConfig.getOrNull(SemanticsProperties.ToggleableState)
-    val role = node.unmergedConfig.getOrNull(SemanticsProperties.Role)
-
-    // Check toggle state and retrieve description accordingly
-    toggleState?.let {
-        when (it) {
-            ToggleableState.On -> {
-                // Unfortunately, talkback has a bug of using "checked", so we set state
-                // description here
-                if (role == Role.Switch && stateDescription == null) {
-                    stateDescription = resources.getString(R.string.state_on)
-                }
-            }
-            ToggleableState.Off -> {
-                // Unfortunately, talkback has a bug of using "not checked", so we set state
-                // description here
-                if (role == Role.Switch && stateDescription == null) {
-                    stateDescription = resources.getString(R.string.state_off)
-                }
-            }
-            ToggleableState.Indeterminate -> {
-                if (stateDescription == null) {
-                    stateDescription = resources.getString(R.string.indeterminate)
-                }
-            }
-        }
-    }
-
-    // Check Selected property and retrieve description accordingly
-    node.unmergedConfig.getOrNull(SemanticsProperties.Selected)?.let {
-        if (role != Role.Tab) {
-            if (stateDescription == null) {
-                // If a radio entry (radio button + text) is selectable, it won't have the role
-                // RadioButton, so if we use info.isCheckable/info.isChecked, talkback will say
-                // "checked/not checked" instead "selected/note selected".
-                stateDescription =
-                    if (it) {
-                        resources.getString(R.string.selected)
-                    } else {
-                        resources.getString(R.string.not_selected)
-                    }
-            }
-        }
-    }
-
-    // Check if a node has progress bar range info and retrieve description accordingly
-    val rangeInfo = node.unmergedConfig.getOrNull(SemanticsProperties.ProgressBarRangeInfo)
-    rangeInfo?.let {
-        // let's set state description here and use state description change events.
-        // otherwise, we need to send out type_view_selected event, as the old android
-        // versions do. But the support for type_view_selected event for progress bars
-        // maybe deprecated in talkback in the future.
-        if (rangeInfo !== ProgressBarRangeInfo.Indeterminate) {
-            if (stateDescription == null) {
-                val valueRange = rangeInfo.range
-                val progress =
-                    (if (valueRange.endInclusive - valueRange.start == 0f) 0f
-                        else
-                            (rangeInfo.current - valueRange.start) /
-                                (valueRange.endInclusive - valueRange.start))
-                        .fastCoerceIn(0f, 1f)
-
-                // We only display 0% or 100% when it is exactly 0% or 100%.
-                val percent =
-                    when (progress) {
-                        0f -> 0
-                        1f -> 100
-                        else -> (progress * 100).fastRoundToInt().coerceIn(1, 99)
-                    }
-                stateDescription = resources.getString(R.string.template_percent, percent)
-            }
-        } else if (stateDescription == null) {
-            stateDescription = resources.getString(R.string.in_progress)
-        }
-    }
-
-    if (node.unmergedConfig.contains(SemanticsProperties.EditableText)) {
-        stateDescription = createStateDescriptionForTextField(node, resources)
-    }
-
-    return stateDescription
-}
-
-/**
- * Empty text field should not be ignored by the TB so we set a state description. When there is a
- * speakable child, like a label or a placeholder text, setting this state description is redundant
- */
-private fun createStateDescriptionForTextField(node: SemanticsNode, resources: Resources): String? {
-    val mergedConfig = node.copyWithMergingEnabled().config
-    val mergedNodeIsUnspeakable =
-        mergedConfig.getOrNull(SemanticsProperties.ContentDescription).isNullOrEmpty() &&
-            mergedConfig.getOrNull(SemanticsProperties.Text).isNullOrEmpty() &&
-            mergedConfig.getOrNull(SemanticsProperties.EditableText).isNullOrEmpty()
-    return if (mergedNodeIsUnspeakable) resources.getString(R.string.state_empty) else null
-}
-
-private fun getInfoIsCheckable(node: SemanticsNode): Boolean {
-    var isCheckable = false
-    val toggleState = node.unmergedConfig.getOrNull(SemanticsProperties.ToggleableState)
-    val role = node.unmergedConfig.getOrNull(SemanticsProperties.Role)
-
-    toggleState?.let { isCheckable = true }
-
-    node.unmergedConfig.getOrNull(SemanticsProperties.Selected)?.let {
-        if (role != Role.Tab) {
-            isCheckable = true
-        }
-    }
-
-    return isCheckable
-}
-
-// check to see if this entry overlaps with any groupings in rowGroupings
-private fun placedEntryRowOverlaps(
-    rowGroupings: ArrayList<Pair<Rect, MutableList<SemanticsNode>>>,
-    node: SemanticsNode
-): Boolean {
-    // Conversion to long is needed in order to utilize `until`, which has no float ver
-    val entryTopCoord = node.boundsInWindow.top
-    val entryBottomCoord = node.boundsInWindow.bottom
-    val entryIsEmpty = entryTopCoord >= entryBottomCoord
-
-    for (currIndex in 0..rowGroupings.lastIndex) {
-        val currRect = rowGroupings[currIndex].first
-        val groupIsEmpty = currRect.top >= currRect.bottom
-        val groupOverlapsEntry =
-            !entryIsEmpty &&
-                !groupIsEmpty &&
-                max(entryTopCoord, currRect.top) < min(entryBottomCoord, currRect.bottom)
-
-        // If it overlaps with this row group, update cover and add node
-        if (groupOverlapsEntry) {
-            val newRect =
-                currRect.intersect(0f, entryTopCoord, Float.POSITIVE_INFINITY, entryBottomCoord)
-            // Replace the cover rectangle, copying over the old list of nodes
-            rowGroupings[currIndex] = Pair(newRect, rowGroupings[currIndex].second)
-            // Add current node
-            rowGroupings[currIndex].second.add(node)
-            // We've found an overlapping group, return true
-            return true
-        }
-    }
-
-    // If we've made it here, then there are no groups our entry overlaps with
-    return false
-}
-
 // TODO(mnuzen): Move common semantics logic into `SemanticsUtils` file to make a11y delegate
 // shorter and more readable.
 private fun SemanticsNode.enabled() = (!config.contains(SemanticsProperties.Disabled))
@@ -3276,12 +3264,12 @@
     val ancestor =
         layoutNode.findClosestParentNode {
             // looking for text field merging node
-            val ancestorSemanticsConfiguration = it.semanticsConfiguration
+            val ancestorSemanticsConfiguration = it.collapsedSemantics
             ancestorSemanticsConfiguration?.isMergingSemanticsOfDescendants == true &&
                 ancestorSemanticsConfiguration.contains(SemanticsProperties.EditableText)
         }
     return ancestor != null &&
-        ancestor.semanticsConfiguration?.getOrNull(SemanticsProperties.Focused) != true
+        ancestor.collapsedSemantics?.getOrNull(SemanticsProperties.Focused) != true
 }
 
 private fun AccessibilityAction<*>.accessibilityEquals(other: Any?): Boolean {
diff --git a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/Wrapper.android.kt b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/Wrapper.android.kt
index 88590f8..87dd475 100644
--- a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/Wrapper.android.kt
+++ b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/Wrapper.android.kt
@@ -17,7 +17,7 @@
 
 import android.view.View
 import android.view.ViewGroup
-import androidx.annotation.MainThread
+import androidx.compose.runtime.AbstractApplier
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.Composition
 import androidx.compose.runtime.CompositionContext
@@ -26,7 +26,6 @@
 import androidx.compose.runtime.CompositionServices
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.Recomposer
-import androidx.compose.runtime.ReusableComposition
 import androidx.compose.runtime.currentComposer
 import androidx.compose.runtime.tooling.CompositionData
 import androidx.compose.runtime.tooling.LocalInspectionTables
@@ -39,11 +38,8 @@
 import java.util.Collections
 import java.util.WeakHashMap
 
-@MainThread
-internal actual fun createSubcomposition(
-    container: LayoutNode,
-    parent: CompositionContext
-): ReusableComposition = ReusableComposition(UiApplier(container), parent)
+internal actual fun createApplier(container: LayoutNode): AbstractApplier<LayoutNode> =
+    UiApplier(container)
 
 /**
  * Composes the given composable into the given view.
diff --git a/compose/ui/ui/src/androidUnitTest/kotlin/androidx/compose/ui/node/LayoutNodeTest.kt b/compose/ui/ui/src/androidUnitTest/kotlin/androidx/compose/ui/node/LayoutNodeTest.kt
index e7d573d..0a54858 100644
--- a/compose/ui/ui/src/androidUnitTest/kotlin/androidx/compose/ui/node/LayoutNodeTest.kt
+++ b/compose/ui/ui/src/androidUnitTest/kotlin/androidx/compose/ui/node/LayoutNodeTest.kt
@@ -17,8 +17,6 @@
 
 package androidx.compose.ui.node
 
-import androidx.collection.IntObjectMap
-import androidx.collection.intObjectMapOf
 import androidx.compose.testutils.TestViewConfiguration
 import androidx.compose.ui.InternalComposeUiApi
 import androidx.compose.ui.Modifier
@@ -67,10 +65,8 @@
 import androidx.compose.ui.platform.ViewConfiguration
 import androidx.compose.ui.platform.WindowInfo
 import androidx.compose.ui.platform.invertTo
-import androidx.compose.ui.semantics.EmptySemanticsModifier
 import androidx.compose.ui.semantics.SemanticsConfiguration
 import androidx.compose.ui.semantics.SemanticsModifier
-import androidx.compose.ui.semantics.SemanticsOwner
 import androidx.compose.ui.semantics.SemanticsPropertyReceiver
 import androidx.compose.ui.spatial.RectManager
 import androidx.compose.ui.text.font.Font
@@ -2317,8 +2313,6 @@
 internal class MockOwner(
     private val position: IntOffset = IntOffset.Zero,
     override val root: LayoutNode = LayoutNode(),
-    override val semanticsOwner: SemanticsOwner =
-        SemanticsOwner(root, EmptySemanticsModifier(), intObjectMapOf()),
     override val coroutineContext: CoroutineContext =
         Executors.newFixedThreadPool(3).asCoroutineDispatcher()
 ) : Owner {
@@ -2551,9 +2545,6 @@
     override val viewConfiguration: ViewConfiguration
         get() = TODO("Not yet implemented")
 
-    override val layoutNodes: IntObjectMap<LayoutNode>
-        get() = TODO("Not yet implemented")
-
     override val sharedDrawScope = LayoutNodeDrawScope()
 }
 
diff --git a/compose/ui/ui/src/androidUnitTest/kotlin/androidx/compose/ui/node/ModifierLocalConsumerEntityTest.kt b/compose/ui/ui/src/androidUnitTest/kotlin/androidx/compose/ui/node/ModifierLocalConsumerEntityTest.kt
index 7ff7ee0..b95e433 100644
--- a/compose/ui/ui/src/androidUnitTest/kotlin/androidx/compose/ui/node/ModifierLocalConsumerEntityTest.kt
+++ b/compose/ui/ui/src/androidUnitTest/kotlin/androidx/compose/ui/node/ModifierLocalConsumerEntityTest.kt
@@ -18,8 +18,6 @@
 
 package androidx.compose.ui.node
 
-import androidx.collection.IntObjectMap
-import androidx.collection.intObjectMapOf
 import androidx.compose.runtime.collection.mutableVectorOf
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
@@ -51,8 +49,6 @@
 import androidx.compose.ui.platform.TextToolbar
 import androidx.compose.ui.platform.ViewConfiguration
 import androidx.compose.ui.platform.WindowInfo
-import androidx.compose.ui.semantics.EmptySemanticsModifier
-import androidx.compose.ui.semantics.SemanticsOwner
 import androidx.compose.ui.spatial.RectManager
 import androidx.compose.ui.text.font.Font
 import androidx.compose.ui.text.font.FontFamily
@@ -336,9 +332,7 @@
 
         override fun onDetach(node: LayoutNode) {}
 
-        override val root: LayoutNode = LayoutNode()
-
-        override val layoutNodes: IntObjectMap<LayoutNode>
+        override val root: LayoutNode
             get() = TODO("Not yet implemented")
 
         override val sharedDrawScope: LayoutNodeDrawScope
@@ -380,9 +374,6 @@
         override val focusOwner: FocusOwner
             get() = TODO("Not yet implemented")
 
-        override val semanticsOwner: SemanticsOwner =
-            SemanticsOwner(root, EmptySemanticsModifier(), intObjectMapOf())
-
         override val windowInfo: WindowInfo
             get() = TODO("Not yet implemented")
 
@@ -449,7 +440,7 @@
         override fun forceMeasureTheSubtree(layoutNode: LayoutNode, affectsLookahead: Boolean) =
             TODO("Not yet implemented")
 
-        override fun onSemanticsChange() {}
+        override fun onSemanticsChange() = TODO("Not yet implemented")
 
         override fun onLayoutChange(layoutNode: LayoutNode) = TODO("Not yet implemented")
 
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusTargetModifierNode.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusTargetModifierNode.kt
index 6ef0a80..30f1c82 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusTargetModifierNode.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusTargetModifierNode.kt
@@ -87,8 +87,7 @@
     level = DeprecationLevel.HIDDEN
 )
 @JsName("funFocusTargetModifierNode")
-fun FocusTargetModifierNode(): FocusTargetModifierNode =
-    FocusTargetNode(onDispatchEventsCompleted = InvalidateSemantics::onDispatchEventsCompleted)
+fun FocusTargetModifierNode(): FocusTargetModifierNode = FocusTargetNode()
 
 /**
  * Create a [FocusTargetModifierNode] that can be delegated to in order to create a modifier that
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusTargetNode.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusTargetNode.kt
index 10ee1a9..95d2b93 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusTargetNode.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusTargetNode.kt
@@ -46,8 +46,7 @@
 
 internal class FocusTargetNode(
     focusability: Focusability = Focusability.Always,
-    private val onFocusChange: ((previous: FocusState, current: FocusState) -> Unit)? = null,
-    private val onDispatchEventsCompleted: ((FocusTargetNode) -> Unit)? = null
+    private val onFocusChange: ((previous: FocusState, current: FocusState) -> Unit)? = null
 ) :
     CompositionLocalConsumerModifierNode,
     FocusTargetModifierNode,
@@ -276,7 +275,7 @@
         val focusState = focusState
         // Avoid invoking callback when we initialize the state (from `null` to Inactive) or
         // if we are detached and go from Inactive to `null` - there isn't a conceptual focus
-        // state change here.
+        // state change here
         if (previousOrInactive != focusState) {
             onFocusChange?.invoke(previousOrInactive, focusState)
         }
@@ -284,8 +283,6 @@
             // TODO(251833873): Consider caching it.getFocusState().
             it.onFocusEvent(it.getFocusState())
         }
-
-        onDispatchEventsCompleted?.invoke(this)
     }
 
     internal object FocusTargetElement : ModifierNodeElement<FocusTargetNode>() {
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 ad9e5cb..2da7de0 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
@@ -19,8 +19,6 @@
 import androidx.compose.runtime.CompositionLocalMap
 import androidx.compose.runtime.collection.MutableVector
 import androidx.compose.runtime.collection.mutableVectorOf
-import androidx.compose.ui.ComposeUiFlags
-import androidx.compose.ui.ExperimentalComposeUiApi
 import androidx.compose.ui.InternalComposeUiApi
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.geometry.Offset
@@ -56,7 +54,6 @@
 import androidx.compose.ui.platform.ViewConfiguration
 import androidx.compose.ui.platform.simpleIdentityToString
 import androidx.compose.ui.semantics.SemanticsConfiguration
-import androidx.compose.ui.semantics.SemanticsInfo
 import androidx.compose.ui.semantics.generateSemanticsId
 import androidx.compose.ui.unit.Constraints
 import androidx.compose.ui.unit.Density
@@ -91,7 +88,6 @@
     Remeasurement,
     OwnerScope,
     LayoutInfo,
-    SemanticsInfo,
     ComposeUiNode,
     InteroperableComposeUiNode,
     Owner.OnLayoutCompletedListener {
@@ -398,62 +394,43 @@
         invalidateMeasurements()
     }
 
-    private var _semanticsConfiguration: SemanticsConfiguration? = null
-    override val semanticsConfiguration: SemanticsConfiguration?
-        get() {
-            // This is needed until we completely move to the new world where we always pre-compute
-            // the semantics configuration. At that point, this can be replaced by
-            // check(!isSemanticsInvalidated) or remove this custom getter.
-            if (isSemanticsInvalidated) {
-                _semanticsConfiguration = calculateSemanticsConfiguration()
-            }
-            return _semanticsConfiguration
-        }
-
-    private fun calculateSemanticsConfiguration(): SemanticsConfiguration? {
-        if (!nodes.has(Nodes.Semantics)) return null
-
-        var config = SemanticsConfiguration()
-        requireOwner().snapshotObserver.observeSemanticsReads(this) {
-            nodes.tailToHead(Nodes.Semantics) {
-                if (it.shouldClearDescendantSemantics) {
-                    config = SemanticsConfiguration()
-                    config.isClearingSemantics = true
-                }
-                if (it.shouldMergeDescendantSemantics) {
-                    config.isMergingSemanticsOfDescendants = true
-                }
-                with(config) { with(it) { applySemantics() } }
-            }
-        }
-        return config
-    }
-
-    private var isSemanticsInvalidated = false
+    private var _collapsedSemantics: SemanticsConfiguration? = null
 
     internal fun invalidateSemantics() {
-        if (
-            @OptIn(ExperimentalComposeUiApi::class) !ComposeUiFlags.isSemanticAutofillEnabled ||
-                nodes.isUpdating ||
-                applyingModifierOnAttach
-        ) {
-            // We are currently updating the modifier, so just schedule an invalidation. After
-            // applying the modifier, we will notify listeners of semantics changes.
-            isSemanticsInvalidated = true
-        } else {
-            // We are not currently updating the modifier, so instead of scheduling invalidation,
-            // we update the semantics configuration and send the notification event right away.
-            val prev = _semanticsConfiguration
-            _semanticsConfiguration = calculateSemanticsConfiguration()
-            requireOwner().semanticsOwner.notifySemanticsChange(this, prev)
-        }
-
+        _collapsedSemantics = null
         // TODO(lmr): this ends up scheduling work that diffs the entire tree, but we should
         //  eventually move to marking just this node as invalidated since we are invalidating
         //  on a per-node level. This should preserve current behavior for now.
         requireOwner().onSemanticsChange()
     }
 
+    internal val collapsedSemantics: SemanticsConfiguration?
+        get() {
+            // TODO: investigate if there's a better way to approach "half attached" state and
+            // whether or not deactivated nodes should be considered removed or not.
+            if (!isAttached || isDeactivated) return null
+
+            if (!nodes.has(Nodes.Semantics) || _collapsedSemantics != null) {
+                return _collapsedSemantics
+            }
+
+            var config = SemanticsConfiguration()
+            requireOwner().snapshotObserver.observeSemanticsReads(this) {
+                nodes.tailToHead(Nodes.Semantics) {
+                    if (it.shouldClearDescendantSemantics) {
+                        config = SemanticsConfiguration()
+                        config.isClearingSemantics = true
+                    }
+                    if (it.shouldMergeDescendantSemantics) {
+                        config.isMergingSemanticsOfDescendants = true
+                    }
+                    with(config) { with(it) { applySemantics() } }
+                }
+            }
+            _collapsedSemantics = config
+            return config
+        }
+
     /**
      * Set the [Owner] of this LayoutNode. This LayoutNode must not already be attached. [owner]
      * must match its [parent].[owner].
@@ -486,11 +463,9 @@
         pendingModifier?.let { applyModifier(it) }
         pendingModifier = null
 
-        @OptIn(ExperimentalComposeUiApi::class)
-        if (!ComposeUiFlags.isSemanticAutofillEnabled && nodes.has(Nodes.Semantics)) {
+        if (nodes.has(Nodes.Semantics)) {
             invalidateSemantics()
         }
-
         owner.onAttach(this)
 
         // Update lookahead root when attached. For nested cases, we'll always use the
@@ -509,12 +484,6 @@
         if (!isDeactivated) {
             nodes.markAsAttached()
         }
-
-        @OptIn(ExperimentalComposeUiApi::class)
-        if (ComposeUiFlags.isSemanticAutofillEnabled && nodes.has(Nodes.Semantics)) {
-            invalidateSemantics()
-        }
-
         _foldedChildren.forEach { child -> child.attach(owner) }
         if (!isDeactivated) {
             nodes.runAttachLifecycle()
@@ -548,10 +517,9 @@
         }
         layoutDelegate.resetAlignmentLines()
         onDetach?.invoke(owner)
+
         if (nodes.has(Nodes.Semantics)) {
-            _semanticsConfiguration = null
-            isSemanticsInvalidated = false
-            requireOwner().onSemanticsChange()
+            invalidateSemantics()
         }
         nodes.runDetachLifecycle()
         ignoreRemeasureRequests { _foldedChildren.forEach { child -> child.detach() } }
@@ -587,10 +555,6 @@
             return _zSortedChildren
         }
 
-    @Suppress("UNCHECKED_CAST")
-    override val childrenInfo: MutableVector<SemanticsInfo>
-        get() = zSortedChildren as MutableVector<SemanticsInfo>
-
     override val isValidOwnerScope: Boolean
         get() = isAttached
 
@@ -902,14 +866,6 @@
         if (lookaheadRoot == null && nodes.has(Nodes.ApproachMeasure)) {
             lookaheadRoot = this
         }
-        // Notify semantics listeners if semantics was invalidated.
-        @OptIn(ExperimentalComposeUiApi::class)
-        if (ComposeUiFlags.isSemanticAutofillEnabled && isSemanticsInvalidated) {
-            val prev = _semanticsConfiguration
-            _semanticsConfiguration = calculateSemanticsConfiguration()
-            isSemanticsInvalidated = false
-            requireOwner().semanticsOwner.notifySemanticsChange(this, prev)
-        }
     }
 
     private fun resetModifierState() {
@@ -1314,7 +1270,7 @@
         }
     }
 
-    override val parentInfo: SemanticsInfo?
+    override val parentInfo: LayoutInfo?
         get() = parent
 
     override var isDeactivated = false
@@ -1326,17 +1282,15 @@
         subcompositionsState?.onReuse()
         if (isDeactivated) {
             isDeactivated = false
+            invalidateSemantics()
             // we don't need to reset state as it was done when deactivated
         } else {
             resetModifierState()
         }
-        val oldSemanticsId = semanticsId
-        semanticsId = generateSemanticsId()
-        owner?.onLayoutNodeIdChange(this, oldSemanticsId)
         // resetModifierState detaches all nodes, so we need to re-attach them upon reuse.
+        semanticsId = generateSemanticsId()
         nodes.markAsAttached()
         nodes.runAttachLifecycle()
-        if (nodes.has(Nodes.Semantics)) invalidateSemantics()
         rescheduleRemeasureOrRelayout(this)
     }
 
@@ -1345,8 +1299,10 @@
         subcompositionsState?.onDeactivate()
         isDeactivated = true
         resetModifierState()
-        _semanticsConfiguration = null
-        isSemanticsInvalidated = false
+        // if the node is detached the semantics were already updated without this node.
+        if (isAttached) {
+            invalidateSemantics()
+        }
         owner?.onLayoutNodeDeactivated(this)
     }
 
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/NodeChain.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/NodeChain.kt
index c08a929..ff19036 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/NodeChain.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/NodeChain.kt
@@ -40,8 +40,8 @@
     internal var head: Modifier.Node = tail
         private set
 
-    internal val isUpdating: Boolean
-        get() = head.parent != null
+    private val isUpdating: Boolean
+        get() = head === SentinelHead
 
     private val aggregateChildKindSet: Int
         get() = head.aggregateChildKindSet
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..0ce80dd 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
@@ -1512,7 +1512,7 @@
                 override fun interceptOutOfBoundsChildEvents(node: Modifier.Node) = false
 
                 override fun shouldHitTestChildren(parentLayoutNode: LayoutNode) =
-                    parentLayoutNode.semanticsConfiguration?.isClearingSemantics != true
+                    parentLayoutNode.collapsedSemantics?.isClearingSemantics != true
 
                 override fun childHitTest(
                     layoutNode: LayoutNode,
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/Owner.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/Owner.kt
index 040c537..3137f68 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/Owner.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/Owner.kt
@@ -18,7 +18,6 @@
 package androidx.compose.ui.node
 
 import androidx.annotation.RestrictTo
-import androidx.collection.IntObjectMap
 import androidx.compose.runtime.Applier
 import androidx.compose.ui.InternalComposeUiApi
 import androidx.compose.ui.autofill.Autofill
@@ -47,7 +46,6 @@
 import androidx.compose.ui.platform.TextToolbar
 import androidx.compose.ui.platform.ViewConfiguration
 import androidx.compose.ui.platform.WindowInfo
-import androidx.compose.ui.semantics.SemanticsOwner
 import androidx.compose.ui.spatial.RectManager
 import androidx.compose.ui.text.font.Font
 import androidx.compose.ui.text.font.FontFamily
@@ -69,9 +67,6 @@
     /** The root layout node in the component tree. */
     val root: LayoutNode
 
-    /** A mapping of semantic id to LayoutNode. */
-    val layoutNodes: IntObjectMap<LayoutNode>
-
     /** Draw scope reused for drawing speed up. */
     val sharedDrawScope: LayoutNodeDrawScope
 
@@ -133,8 +128,6 @@
 
     val pointerIconService: PointerIconService
 
-    val semanticsOwner: SemanticsOwner
-
     /** Provide a focus owner that controls focus within Compose. */
     val focusOwner: FocusOwner
 
@@ -271,8 +264,6 @@
 
     fun onLayoutNodeDeactivated(layoutNode: LayoutNode)
 
-    fun onLayoutNodeIdChange(layoutNode: LayoutNode, oldSemanticsId: Int) {}
-
     /**
      * The position and/or size of an interop view (typically, an android.view.View) has changed. On
      * Android, this schedules view tree layout observer callback to be invoked for the underlying
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/SemanticsModifierNode.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/SemanticsModifierNode.kt
index 002be43..dd3e2f8 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/SemanticsModifierNode.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/SemanticsModifierNode.kt
@@ -87,14 +87,7 @@
     fun SemanticsPropertyReceiver.applySemantics()
 }
 
-/**
- * Invalidate semantics associated with this node. This will reset the [SemanticsConfiguration]
- * associated with the layout node backing this modifier node, and will re-calculate it the next
- * time the [SemanticsConfiguration] is read.
- */
-fun SemanticsModifierNode.invalidateSemantics() {
-    requireLayoutNode().invalidateSemantics()
-}
+fun SemanticsModifierNode.invalidateSemantics() = requireLayoutNode().invalidateSemantics()
 
 internal val SemanticsConfiguration.useMinimumTouchTarget: Boolean
     get() = getOrNull(SemanticsActions.OnClick) != null
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/platform/Subcomposition.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/platform/Subcomposition.kt
index fda825d..9ba947e 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/platform/Subcomposition.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/platform/Subcomposition.kt
@@ -15,12 +15,15 @@
  */
 package androidx.compose.ui.platform
 
+import androidx.compose.runtime.AbstractApplier
 import androidx.compose.runtime.CompositionContext
 import androidx.compose.runtime.ReusableComposition
 import androidx.compose.ui.node.LayoutNode
 
+internal expect fun createApplier(container: LayoutNode): AbstractApplier<LayoutNode>
+
 /*@MainThread*/
-internal expect fun createSubcomposition(
+internal fun createSubcomposition(
     container: LayoutNode,
     parent: CompositionContext
-): ReusableComposition
+): ReusableComposition = ReusableComposition(createApplier(container), parent)
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
deleted file mode 100644
index 1f2cc5d..0000000
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/semantics/SemanticsInfo.kt
+++ /dev/null
@@ -1,85 +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.semantics
-
-import androidx.compose.runtime.collection.MutableVector
-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
- * information from layout nodes. The root [SemanticsInfo] can be accessed using
- * [SemanticsOwner.rootInfo], and particular [SemanticsInfo] can be looked up by their [semanticsId]
- * by using [SemanticsOwner.get].
- */
-internal interface SemanticsInfo : LayoutInfo {
-    /** The semantics configuration (Semantic properties and actions) associated with this node. */
-    val semanticsConfiguration: SemanticsConfiguration?
-
-    /**
-     * The [SemanticsInfo] of the parent.
-     *
-     * This includes parents that do not have any semantics modifiers.
-     */
-    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.
-     *
-     * Note that the object is reused so you shouldn't save it for later.
-     */
-    val childrenInfo: MutableVector<SemanticsInfo>
-}
-
-/** The semantics parent (nearest ancestor which has semantic properties). */
-internal fun SemanticsInfo.findSemanticsParent(): SemanticsInfo? {
-    var parent = parentInfo
-    while (parent != null) {
-        if (parent.semanticsConfiguration != null) return parent
-        parent = parent.parentInfo
-    }
-    return null
-}
-
-/** The nearest semantics ancestor that is merging descendants. */
-internal fun SemanticsInfo.findMergingSemanticsParent(): SemanticsInfo? {
-    var parent = parentInfo
-    while (parent != null) {
-        if (parent.semanticsConfiguration?.isMergingSemanticsOfDescendants == true) return parent
-        parent = parent.parentInfo
-    }
-    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 }
-        }
-    }
-}
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/semantics/SemanticsListener.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/semantics/SemanticsListener.kt
deleted file mode 100644
index b51d7c8..0000000
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/semantics/SemanticsListener.kt
+++ /dev/null
@@ -1,34 +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.semantics
-
-/** A listener that can be used to observe semantic changes. */
-internal interface SemanticsListener {
-
-    /**
-     * [onSemanticsChanged] is called when the [SemanticsConfiguration] of a LayoutNode changes, or
-     * when a node calls SemanticsModifierNode.invalidateSemantics.
-     *
-     * @param semanticsInfo the current [SemanticsInfo] of the layout node that has changed.
-     * @param previousSemanticsConfiguration the previous [SemanticsConfiguration] associated with
-     *   the layout node.
-     */
-    fun onSemanticsChanged(
-        semanticsInfo: SemanticsInfo,
-        previousSemanticsConfiguration: SemanticsConfiguration?
-    )
-}
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..35478d6 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
@@ -45,7 +45,7 @@
         layoutNode.nodes.head(Nodes.Semantics)!!.node,
         mergingEnabled,
         layoutNode,
-        layoutNode.semanticsConfiguration ?: SemanticsConfiguration()
+        layoutNode.collapsedSemantics!!
     )
 
 internal fun SemanticsNode(
@@ -70,7 +70,7 @@
         outerSemanticsNode.node,
         mergingEnabled,
         layoutNode,
-        layoutNode.semanticsConfiguration ?: SemanticsConfiguration()
+        layoutNode.collapsedSemantics ?: SemanticsConfiguration()
     )
 
 /**
@@ -99,7 +99,7 @@
             !isFake &&
                 replacedChildren.isEmpty() &&
                 layoutNode.findClosestParentNode {
-                    it.semanticsConfiguration?.isMergingSemanticsOfDescendants == true
+                    it.collapsedSemantics?.isMergingSemanticsOfDescendants == true
                 } == null
 
     /** The [LayoutInfo] that this is associated with. */
@@ -345,7 +345,7 @@
             if (mergingEnabled) {
                 node =
                     this.layoutNode.findClosestParentNode {
-                        it.semanticsConfiguration?.isMergingSemanticsOfDescendants == true
+                        it.collapsedSemantics?.isMergingSemanticsOfDescendants == true
                     }
             }
 
@@ -474,9 +474,7 @@
  * Executes [selector] on every parent of this [LayoutNode] and returns the closest [LayoutNode] to
  * return `true` from [selector] or null if [selector] returns false for all ancestors.
  */
-internal inline fun LayoutNode.findClosestParentNode(
-    selector: (LayoutNode) -> Boolean
-): LayoutNode? {
+internal fun LayoutNode.findClosestParentNode(selector: (LayoutNode) -> Boolean): LayoutNode? {
     var currentParent = this.parent
     while (currentParent != null) {
         if (selector(currentParent)) {
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/semantics/SemanticsOwner.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/semantics/SemanticsOwner.kt
index b987155..dffed0f 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/semantics/SemanticsOwner.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/semantics/SemanticsOwner.kt
@@ -16,8 +16,6 @@
 
 package androidx.compose.ui.semantics
 
-import androidx.collection.IntObjectMap
-import androidx.collection.MutableObjectList
 import androidx.compose.ui.node.LayoutNode
 import androidx.compose.ui.util.fastForEach
 
@@ -25,8 +23,7 @@
 class SemanticsOwner
 internal constructor(
     private val rootNode: LayoutNode,
-    private val outerSemanticsNode: EmptySemanticsModifier,
-    private val nodes: IntObjectMap<LayoutNode>
+    private val outerSemanticsNode: EmptySemanticsModifier
 ) {
     /**
      * The root node of the semantics tree. Does not contain any unmerged data. May contain merged
@@ -50,22 +47,6 @@
                 unmergedConfig = SemanticsConfiguration()
             )
         }
-
-    internal val listeners = MutableObjectList<SemanticsListener>(2)
-
-    internal val rootInfo: SemanticsInfo
-        get() = rootNode
-
-    internal operator fun get(semanticsId: Int): SemanticsInfo? {
-        return nodes[semanticsId]
-    }
-
-    internal fun notifySemanticsChange(
-        semanticsInfo: SemanticsInfo,
-        previousSemanticsConfiguration: SemanticsConfiguration?
-    ) {
-        listeners.forEach { it.onSemanticsChanged(semanticsInfo, previousSemanticsConfiguration) }
-    }
 }
 
 /**
@@ -89,7 +70,6 @@
         .toList()
 }
 
-@Suppress("unused")
 @Deprecated(message = "Use a new overload instead", level = DeprecationLevel.HIDDEN)
 fun SemanticsOwner.getAllSemanticsNodes(mergingEnabled: Boolean) =
     getAllSemanticsNodes(mergingEnabled, true)
diff --git a/compose/ui/ui/src/commonStubsMain/kotlin/androidx/compose/ui/platform/Wrapper.commonStubs.kt b/compose/ui/ui/src/commonStubsMain/kotlin/androidx/compose/ui/platform/Wrapper.commonStubs.kt
index 5150de3..77eb09a 100644
--- a/compose/ui/ui/src/commonStubsMain/kotlin/androidx/compose/ui/platform/Wrapper.commonStubs.kt
+++ b/compose/ui/ui/src/commonStubsMain/kotlin/androidx/compose/ui/platform/Wrapper.commonStubs.kt
@@ -15,12 +15,9 @@
  */
 package androidx.compose.ui.platform
 
-import androidx.compose.runtime.CompositionContext
-import androidx.compose.runtime.ReusableComposition
+import androidx.compose.runtime.AbstractApplier
 import androidx.compose.ui.implementedInJetBrainsFork
 import androidx.compose.ui.node.LayoutNode
 
-internal actual fun createSubcomposition(
-    container: LayoutNode,
-    parent: CompositionContext
-): ReusableComposition = implementedInJetBrainsFork()
+internal actual fun createApplier(container: LayoutNode): AbstractApplier<LayoutNode> =
+    implementedInJetBrainsFork()
diff --git a/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/E2EExtensionTests.kt b/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/E2EExtensionTests.kt
index e19740a..582b073 100644
--- a/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/E2EExtensionTests.kt
+++ b/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/E2EExtensionTests.kt
@@ -58,7 +58,6 @@
 import org.junit.Assert.fail
 import org.junit.Assume.assumeTrue
 import org.junit.Before
-import org.junit.Ignore
 import org.junit.Rule
 import org.junit.Test
 import org.junit.runner.RunWith
@@ -414,21 +413,16 @@
      * This is an end to end test that verifies a VoIP application and InCallService can add the
      * LocalCallSilenceExtension and toggle the value.
      */
-    @SdkSuppress(
-        minSdkVersion = VERSION_CODES.O,
-        maxSdkVersion = VERSION_CODES.TIRAMISU
-    ) // TODO:: b/377707977
     @LargeTest
-    @Ignore("b/377706280")
     @Test(timeout = 10000)
     fun testVoipAndIcsTogglingTheLocalCallSilenceExtension(): Unit = runBlocking {
         usingIcs { ics ->
-            val globalMuteStateReceiver = TestMuteStateReceiver(this)
-            mContext.registerReceiver(
-                globalMuteStateReceiver,
-                IntentFilter(AudioManager.ACTION_MICROPHONE_MUTE_CHANGED)
-            )
+            val globalMuteStateReceiver = TestMuteStateReceiver()
             try {
+                mContext.registerReceiver(
+                    globalMuteStateReceiver,
+                    IntentFilter(AudioManager.ACTION_MICROPHONE_MUTE_CHANGED)
+                )
                 val voipAppControl = bindToVoipAppWithExtensions()
                 val callback = TestCallCallbackListener(this)
                 voipAppControl.setCallback(callback)
@@ -457,12 +451,18 @@
                                 // the mute is called. Otherwise, telecom will unmute during the
                                 // call setup
                                 delay(500)
+                                Log.i("LCS_Test", "manually muting the mic")
                                 am.setMicrophoneMute(true)
                                 assertTrue(am.isMicrophoneMute)
-                                globalMuteStateReceiver.waitForGlobalMuteState(true, "1")
+                                waitForGlobalMuteState(true, "1", callback, globalMuteStateReceiver)
                                 // LocalCallSilenceExtensionImpl handles globally unmuting the
                                 // microphone
-                                globalMuteStateReceiver.waitForGlobalMuteState(false, "2")
+                                waitForGlobalMuteState(
+                                    false,
+                                    "2",
+                                    callback,
+                                    globalMuteStateReceiver
+                                )
                             }
 
                             // VoIP --> ICS
@@ -478,11 +478,16 @@
                             callback.waitForIsLocalSilenced(voipCallId, false)
 
                             // set the call state via voip app control
-                            if (VERSION.SDK_INT >= VERSION_CODES.P) {
+                            if (VERSION.SDK_INT >= VERSION_CODES.R) {
                                 call.hold()
-                                globalMuteStateReceiver.waitForGlobalMuteState(true, "3")
+                                waitForGlobalMuteState(true, "3", callback, globalMuteStateReceiver)
                                 call.unhold()
-                                globalMuteStateReceiver.waitForGlobalMuteState(false, "4")
+                                waitForGlobalMuteState(
+                                    false,
+                                    "4",
+                                    callback,
+                                    globalMuteStateReceiver
+                                )
                             }
                             call.disconnect()
                         }
@@ -495,6 +500,19 @@
         }
     }
 
+    private suspend fun waitForGlobalMuteState(
+        expectedValue: Boolean,
+        tag: String,
+        cb: TestCallCallbackListener,
+        receiver: TestMuteStateReceiver
+    ) {
+        if (VERSION.SDK_INT >= VERSION_CODES.UPSIDE_DOWN_CAKE) {
+            cb.waitForGlobalMuteState(expectedValue, tag)
+        } else if (VERSION.SDK_INT >= VERSION_CODES.P) {
+            receiver.waitForGlobalMuteState(expectedValue, tag)
+        }
+    }
+
     /**
      * Create a VOIP call with a participants extension and attach participant Call extensions.
      * Verify kick participant functionality works as expected
diff --git a/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/utils/TestMuteStateReceiver.kt b/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/utils/TestMuteStateReceiver.kt
new file mode 100644
index 0000000..dc5983d
--- /dev/null
+++ b/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/utils/TestMuteStateReceiver.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.core.telecom.test.utils
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.media.AudioManager
+import android.util.Log
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.firstOrNull
+import kotlinx.coroutines.withTimeoutOrNull
+import org.junit.Assert.assertEquals
+
+class TestMuteStateReceiver : BroadcastReceiver() {
+    private val isMutedFlow: MutableStateFlow<Boolean> = MutableStateFlow(false)
+
+    companion object {
+        private val TAG: String = TestMuteStateReceiver::class.java.simpleName.toString()
+    }
+
+    suspend fun waitForGlobalMuteState(isMuted: Boolean, id: String = "") {
+        Log.i(TAG, "waitForGlobalMuteState: v=[$isMuted], id=[$id]")
+        val result =
+            withTimeoutOrNull(5000) {
+                isMutedFlow
+                    .filter {
+                        Log.i(TAG, "it=[$isMuted], isMuted=[$isMuted]")
+                        it == isMuted
+                    }
+                    .firstOrNull()
+            }
+        Log.i(TAG, "asserting id=[$id], result=$result")
+        assertEquals("Global Mute State {$id} never reached the expected state", isMuted, result)
+    }
+
+    override fun onReceive(context: Context, intent: Intent) {
+        if (AudioManager.ACTION_MICROPHONE_MUTE_CHANGED == intent.action) {
+            val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
+            val isMicGloballyMuted = audioManager.isMicrophoneMute
+            Log.i(TAG, "onReceive: isMicGloballyMuted=[${isMicGloballyMuted}]")
+            isMutedFlow.value = isMicGloballyMuted
+        }
+    }
+}
diff --git a/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/utils/TestUtils.kt b/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/utils/TestUtils.kt
index af9dec8..36a49d80 100644
--- a/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/utils/TestUtils.kt
+++ b/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/utils/TestUtils.kt
@@ -16,9 +16,7 @@
 
 package androidx.core.telecom.test.utils
 
-import android.content.BroadcastReceiver
 import android.content.Context
-import android.content.Intent
 import android.media.AudioManager
 import android.net.Uri
 import android.os.Build
@@ -49,6 +47,7 @@
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.filter
 import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.firstOrNull
 import kotlinx.coroutines.flow.map
 import kotlinx.coroutines.isActive
 import kotlinx.coroutines.launch
@@ -414,8 +413,12 @@
     private val callAddedFlow: MutableSharedFlow<Pair<Int, String>> = MutableSharedFlow(replay = 1)
     private val isMutedFlow: MutableStateFlow<Boolean> = MutableStateFlow(false)
 
+    companion object {
+        private val TAG: String = TestCallCallbackListener::class.java.simpleName.toString()
+    }
+
     override fun onGlobalMuteStateChanged(isMuted: Boolean) {
-        Log.i("TestCallCallbackListener", "onGlobalMuteStateChanged: isMuted: $isMuted")
+        Log.i(TAG, "onGlobalMuteStateChanged: isMuted: $isMuted")
         scope.launch { isMutedFlow.emit(isMuted) }
     }
 
@@ -463,9 +466,19 @@
         assertEquals("<LOCAL CALL SILENCE> never received", expectedState, result?.second)
     }
 
-    suspend fun waitForGlobalMuteState(isMuted: Boolean) {
-        val result = withTimeoutOrNull(5000) { isMutedFlow.filter { it == isMuted }.first() }
-        assertEquals("Global mute state never reached the expected state", isMuted, result)
+    suspend fun waitForGlobalMuteState(isMuted: Boolean, id: String = "") {
+        Log.i(TAG, "waitForGlobalMuteState: v=[$isMuted], id=[$id]")
+        val result =
+            withTimeoutOrNull(5000) {
+                isMutedFlow
+                    .filter {
+                        Log.i(TAG, "it=[$isMuted], isMuted=[$isMuted]")
+                        it == isMuted
+                    }
+                    .firstOrNull()
+            }
+        Log.i(TAG, "asserting id=[$id], result=$result")
+        assertEquals("Global Mute State {$id} never reached the expected state", isMuted, result)
     }
 
     suspend fun waitForKickParticipant(callId: String, expectedParticipant: Participant?) {
@@ -478,27 +491,3 @@
         assertEquals("kick participant action never received", expectedParticipant, result?.second)
     }
 }
-
-class TestMuteStateReceiver(private val scope: CoroutineScope) : BroadcastReceiver() {
-    private val isMutedFlow: MutableStateFlow<Boolean> = MutableStateFlow(false)
-
-    suspend fun waitForGlobalMuteState(isMuted: Boolean, id: String = "") {
-        val result =
-            withTimeoutOrNull(5000) {
-                isMutedFlow
-                    .filter {
-                        Log.i("TestMuteStateReceiver", "received $isMuted")
-                        it == isMuted
-                    }
-                    .first()
-            }
-        assertEquals("Global Mute State {$id} never reached the expected state", isMuted, result)
-    }
-
-    override fun onReceive(context: Context, intent: Intent) {
-        if (AudioManager.ACTION_MICROPHONE_MUTE_CHANGED == intent.action) {
-            val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
-            scope.launch { isMutedFlow.emit(audioManager.isMicrophoneMute) }
-        }
-    }
-}
diff --git a/core/core-viewtree/build.gradle b/core/core-viewtree/build.gradle
index 9fe5bca..45281e8 100644
--- a/core/core-viewtree/build.gradle
+++ b/core/core-viewtree/build.gradle
@@ -48,7 +48,7 @@
 androidx {
     name = "androidx.core:core-viewtree"
     type = LibraryType.PUBLISHED_LIBRARY
-    mavenVersion = LibraryVersions.CORE
+    mavenVersion = LibraryVersions.CORE_VIEWTREE
     inceptionYear = "2024"
     description = "Provides ViewTree extensions packaged for use by other core androidx libraries"
 }
diff --git a/core/core/src/androidTest/java/androidx/core/view/inputmethod/ImeViewCompatMultiWindowTest.java b/core/core/src/androidTest/java/androidx/core/view/inputmethod/ImeViewCompatMultiWindowTest.java
index 8cd0379..7dfdeef 100644
--- a/core/core/src/androidTest/java/androidx/core/view/inputmethod/ImeViewCompatMultiWindowTest.java
+++ b/core/core/src/androidTest/java/androidx/core/view/inputmethod/ImeViewCompatMultiWindowTest.java
@@ -49,7 +49,7 @@
 
 @RunWith(AndroidJUnit4.class)
 @LargeTest
-@SdkSuppress(minSdkVersion = 30)
+@SdkSuppress(minSdkVersion = 31)
 public class ImeViewCompatMultiWindowTest {
 
     @Rule
@@ -73,13 +73,11 @@
 
     /**
      * This test is using a deprecated codepath that doesn't support the workaround, so it is
-     * expected to fail hiding the IME.
-     * If this test begins failing on a new API version (that is, an assertion error is no longer
-     * being thrown), it is likely that the workaround is no longer needed on that API version:
-     * b/280532442
+     * expected to fail hiding the IME. The workaround is no longer needed on API version 35.
+     * See b/280532442 for details.
      */
     @Test(expected = AssertionError.class)
-    @SdkSuppress(minSdkVersion = 30, excludedSdks = { 30 }) // Excluded due to flakes (b/324889554)
+    @SdkSuppress(minSdkVersion = 31, maxSdkVersion = 35)
     public void testImeShowAndHide_splitScreen() {
         if (Build.VERSION.SDK_INT < 32) {
             // FLAG_ACTIVITY_LAUNCH_ADJACENT is not support before Sdk 32, using the
diff --git a/core/uwb/uwb/api/current.txt b/core/uwb/uwb/api/current.txt
index 782a9f8..b5981af 100644
--- a/core/uwb/uwb/api/current.txt
+++ b/core/uwb/uwb/api/current.txt
@@ -110,6 +110,12 @@
     property public abstract androidx.core.uwb.UwbDevice device;
   }
 
+  public static final class RangingResult.RangingResultInitialized extends androidx.core.uwb.RangingResult {
+    ctor public RangingResult.RangingResultInitialized(androidx.core.uwb.UwbDevice device);
+    method public androidx.core.uwb.UwbDevice getDevice();
+    property public androidx.core.uwb.UwbDevice device;
+  }
+
   public static final class RangingResult.RangingResultPeerDisconnected extends androidx.core.uwb.RangingResult {
     ctor public RangingResult.RangingResultPeerDisconnected(androidx.core.uwb.UwbDevice device);
     method public androidx.core.uwb.UwbDevice getDevice();
diff --git a/core/uwb/uwb/api/restricted_current.txt b/core/uwb/uwb/api/restricted_current.txt
index 782a9f8..b5981af 100644
--- a/core/uwb/uwb/api/restricted_current.txt
+++ b/core/uwb/uwb/api/restricted_current.txt
@@ -110,6 +110,12 @@
     property public abstract androidx.core.uwb.UwbDevice device;
   }
 
+  public static final class RangingResult.RangingResultInitialized extends androidx.core.uwb.RangingResult {
+    ctor public RangingResult.RangingResultInitialized(androidx.core.uwb.UwbDevice device);
+    method public androidx.core.uwb.UwbDevice getDevice();
+    property public androidx.core.uwb.UwbDevice device;
+  }
+
   public static final class RangingResult.RangingResultPeerDisconnected extends androidx.core.uwb.RangingResult {
     ctor public RangingResult.RangingResultPeerDisconnected(androidx.core.uwb.UwbDevice device);
     method public androidx.core.uwb.UwbDevice getDevice();
diff --git a/core/uwb/uwb/src/main/java/androidx/core/uwb/RangingResult.kt b/core/uwb/uwb/src/main/java/androidx/core/uwb/RangingResult.kt
index bb26126..f310bb4 100644
--- a/core/uwb/uwb/src/main/java/androidx/core/uwb/RangingResult.kt
+++ b/core/uwb/uwb/src/main/java/androidx/core/uwb/RangingResult.kt
@@ -33,4 +33,7 @@
 
     /** A ranging result with peer disconnected status update. */
     public class RangingResultPeerDisconnected(override val device: UwbDevice) : RangingResult()
+
+    /** A ranging result when a ranging session is initialized with peer device. */
+    public class RangingResultInitialized(override val device: UwbDevice) : RangingResult()
 }
diff --git a/core/uwb/uwb/src/main/java/androidx/core/uwb/impl/UwbClientSessionScopeAospImpl.kt b/core/uwb/uwb/src/main/java/androidx/core/uwb/impl/UwbClientSessionScopeAospImpl.kt
index c7c07f2..9479357 100644
--- a/core/uwb/uwb/src/main/java/androidx/core/uwb/impl/UwbClientSessionScopeAospImpl.kt
+++ b/core/uwb/uwb/src/main/java/androidx/core/uwb/impl/UwbClientSessionScopeAospImpl.kt
@@ -16,7 +16,6 @@
 
 package androidx.core.uwb.impl
 
-import android.util.Log
 import androidx.core.uwb.RangingCapabilities
 import androidx.core.uwb.RangingMeasurement
 import androidx.core.uwb.RangingParameters
@@ -135,7 +134,11 @@
         val callback =
             object : IRangingSessionCallback.Stub() {
                 override fun onRangingInitialized(device: UwbDevice) {
-                    Log.i(TAG, "Started UWB ranging.")
+                    trySend(
+                        RangingResult.RangingResultInitialized(
+                            androidx.core.uwb.UwbDevice(UwbAddress(device.address?.address!!))
+                        )
+                    )
                 }
 
                 override fun onRangingResult(device: UwbDevice, position: RangingPosition) {
diff --git a/core/uwb/uwb/src/main/java/androidx/core/uwb/impl/UwbClientSessionScopeImpl.kt b/core/uwb/uwb/src/main/java/androidx/core/uwb/impl/UwbClientSessionScopeImpl.kt
index e7e6fcb..62a8407 100644
--- a/core/uwb/uwb/src/main/java/androidx/core/uwb/impl/UwbClientSessionScopeImpl.kt
+++ b/core/uwb/uwb/src/main/java/androidx/core/uwb/impl/UwbClientSessionScopeImpl.kt
@@ -16,10 +16,10 @@
 
 package androidx.core.uwb.impl
 
-import android.util.Log
 import androidx.core.uwb.RangingCapabilities
 import androidx.core.uwb.RangingMeasurement
 import androidx.core.uwb.RangingParameters
+import androidx.core.uwb.RangingResult.RangingResultInitialized
 import androidx.core.uwb.RangingResult.RangingResultPeerDisconnected
 import androidx.core.uwb.RangingResult.RangingResultPosition
 import androidx.core.uwb.UwbAddress
@@ -139,7 +139,11 @@
         val callback =
             object : RangingSessionCallback {
                 override fun onRangingInitialized(device: UwbDevice) {
-                    Log.i(TAG, "Started UWB ranging.")
+                    trySend(
+                        RangingResultInitialized(
+                            androidx.core.uwb.UwbDevice(UwbAddress(device.address.address))
+                        )
+                    )
                 }
 
                 override fun onRangingResult(device: UwbDevice, position: RangingPosition) {
diff --git a/credentials/registry/registry-provider/src/main/java/androidx/credentials/registry/provider/ProviderGetCredentialRequest.kt b/credentials/registry/registry-provider/src/main/java/androidx/credentials/registry/provider/ProviderGetCredentialRequest.kt
index 89a12f3..65815eb 100644
--- a/credentials/registry/registry-provider/src/main/java/androidx/credentials/registry/provider/ProviderGetCredentialRequest.kt
+++ b/credentials/registry/registry-provider/src/main/java/androidx/credentials/registry/provider/ProviderGetCredentialRequest.kt
@@ -35,6 +35,9 @@
  * A null return means that entry ID isn't supported for the given type of the use case at all. For
  * example, a [androidx.credentials.provider.PasswordCredentialEntry] does not have an id property
  * and so this getter will return null if the selected entry was a password credential.
+ *
+ * For how to handle a user selection and extract the [ProviderGetCredentialRequest] containing the
+ * selection information, see [RegistryManager.ACTION_GET_CREDENTIAL].
  */
 @get:JvmName("getSelectedEntryId")
 public val ProviderGetCredentialRequest.selectedEntryId: String?
diff --git a/credentials/registry/registry-provider/src/main/java/androidx/credentials/registry/provider/RegistryManager.kt b/credentials/registry/registry-provider/src/main/java/androidx/credentials/registry/provider/RegistryManager.kt
index c601099..4d85bb0 100644
--- a/credentials/registry/registry-provider/src/main/java/androidx/credentials/registry/provider/RegistryManager.kt
+++ b/credentials/registry/registry-provider/src/main/java/androidx/credentials/registry/provider/RegistryManager.kt
@@ -44,10 +44,13 @@
          * when the user selects a credential that belongs to your application. Your activity will
          * be launched and you should use the
          * [androidx.credentials.provider.PendingIntentHandler.retrieveProviderGetCredentialRequest]
-         * API to retrieve information about the user selection and the verifier request contained
-         * in [androidx.credentials.provider.ProviderGetCredentialRequest]. Next, perform the
-         * necessary steps (e.g. consent collection, credential lookup) to generate a response for
-         * the given request. Pass the result back using one of the
+         * API to retrieve information about the user selection (you can do this through
+         * [androidx.credentials.registry.provider.selectedEntryId]), the verifier request, and
+         * other caller app information contained in
+         * [androidx.credentials.provider.ProviderGetCredentialRequest].
+         *
+         * Next, perform the necessary steps (e.g. consent collection, credential lookup) to
+         * generate a response for the given request. Pass the result back using one of the
          * [androidx.credentials.provider.PendingIntentHandler.setGetCredentialResponse] and
          * [androidx.credentials.provider.PendingIntentHandler.setGetCredentialException] APIs.
          */
diff --git a/datastore/datastore-core/bcv/native/current.txt b/datastore/datastore-core/bcv/native/current.txt
index 4e63344..6fe6e1a 100644
--- a/datastore/datastore-core/bcv/native/current.txt
+++ b/datastore/datastore-core/bcv/native/current.txt
@@ -6,6 +6,10 @@
 // - Show declarations: true
 
 // Library unique name: <androidx.datastore:datastore-core>
+abstract interface <#A: kotlin/Any?> androidx.datastore.core/CurrentDataProviderStore : androidx.datastore.core/DataStore<#A> { // androidx.datastore.core/CurrentDataProviderStore|null[0]
+    abstract suspend fun currentData(): #A // androidx.datastore.core/CurrentDataProviderStore.currentData|currentData(){}[0]
+}
+
 abstract interface <#A: kotlin/Any?> androidx.datastore.core/DataMigration { // androidx.datastore.core/DataMigration|null[0]
     abstract suspend fun cleanUp() // androidx.datastore.core/DataMigration.cleanUp|cleanUp(){}[0]
     abstract suspend fun migrate(#A): #A // androidx.datastore.core/DataMigration.migrate|migrate(1:0){}[0]
diff --git a/datastore/datastore-core/src/commonMain/kotlin/androidx/datastore/core/CurrentDataProviderStore.kt b/datastore/datastore-core/src/commonMain/kotlin/androidx/datastore/core/CurrentDataProviderStore.kt
new file mode 100644
index 0000000..54a82bc
--- /dev/null
+++ b/datastore/datastore-core/src/commonMain/kotlin/androidx/datastore/core/CurrentDataProviderStore.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.datastore.core
+
+import androidx.annotation.RestrictTo
+
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+public interface CurrentDataProviderStore<T> : DataStore<T> {
+    public suspend fun currentData(): T
+}
diff --git a/datastore/datastore-core/src/commonMain/kotlin/androidx/datastore/core/DataStoreImpl.kt b/datastore/datastore-core/src/commonMain/kotlin/androidx/datastore/core/DataStoreImpl.kt
index 0dfcb46..3a61af0 100644
--- a/datastore/datastore-core/src/commonMain/kotlin/androidx/datastore/core/DataStoreImpl.kt
+++ b/datastore/datastore-core/src/commonMain/kotlin/androidx/datastore/core/DataStoreImpl.kt
@@ -59,7 +59,7 @@
      */
     private val corruptionHandler: CorruptionHandler<T> = NoOpCorruptionHandler(),
     private val scope: CoroutineScope = CoroutineScope(ioDispatcher() + SupervisorJob())
-) : DataStore<T> {
+) : CurrentDataProviderStore<T> {
 
     /**
      * The actual values of DataStore. This is exposed in the API via [data] to be able to combine
@@ -114,6 +114,17 @@
         )
     }
 
+    override suspend fun currentData(): T {
+        val startState = readState(requireLock = false)
+        when (startState) {
+            is Data<T> -> return startState.value
+            is UnInitialized -> error(BUG_MESSAGE)
+            is ReadException<T> -> throw startState.readException
+            // TODO(b/273990827): decide the contract of accessing when state is Final
+            is Final -> throw startState.finalException
+        }
+    }
+
     private val collectorMutex = Mutex()
     private var collectorCounter = 0
     /**
diff --git a/datastore/datastore-guava/src/main/java/androidx/datastore/guava/GuavaDataStore.kt b/datastore/datastore-guava/src/main/java/androidx/datastore/guava/GuavaDataStore.kt
index b653051..3a95248 100644
--- a/datastore/datastore-guava/src/main/java/androidx/datastore/guava/GuavaDataStore.kt
+++ b/datastore/datastore-guava/src/main/java/androidx/datastore/guava/GuavaDataStore.kt
@@ -18,8 +18,8 @@
 
 import android.content.Context
 import androidx.concurrent.futures.SuspendToFutureAdapter.launchFuture
+import androidx.datastore.core.CurrentDataProviderStore
 import androidx.datastore.core.DataMigration
-import androidx.datastore.core.DataStore
 import androidx.datastore.core.DataStoreFactory
 import androidx.datastore.core.Serializer
 import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
@@ -34,7 +34,6 @@
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.asCoroutineDispatcher
-import kotlinx.coroutines.flow.first
 
 /**
  * The class that wraps around [DataStore] to provide an interface that returns [ListenableFuture]s
@@ -43,7 +42,7 @@
 public class GuavaDataStore<T : Any>
 internal constructor(
     /** The delegate DataStore. */
-    private val dataStore: DataStore<T>,
+    private val dataStore: CurrentDataProviderStore<T>,
     /** The [CoroutineContext] that holds a dispatcher. */
     private val coroutineContext: CoroutineContext,
 ) {
@@ -52,7 +51,7 @@
      * ongoing updates.
      */
     public fun getDataAsync(): ListenableFuture<T> {
-        return launchFuture(coroutineContext) { dataStore.data.first() }
+        return launchFuture(coroutineContext) { dataStore.currentData() }
     }
 
     /**
@@ -156,7 +155,10 @@
                     corruptionHandler = corruptionHandler,
                     migrations = dataMigrations,
                     scope = CoroutineScope(coroutineDispatcher),
-                ),
+                ) as? CurrentDataProviderStore
+                    ?: error(
+                        "Unexpected DataStore object that does not implement CurrentDataStore"
+                    ),
                 coroutineDispatcher,
             )
     }
diff --git a/datastore/datastore-preferences-core/api/current.txt b/datastore/datastore-preferences-core/api/current.txt
index 1de9eb3..6f9afe6 100644
--- a/datastore/datastore-preferences-core/api/current.txt
+++ b/datastore/datastore-preferences-core/api/current.txt
@@ -47,6 +47,14 @@
     method public static androidx.datastore.preferences.core.MutablePreferences createMutable(androidx.datastore.preferences.core.Preferences.Pair<? extends java.lang.Object?>... pairs);
   }
 
+  public final class PreferencesFileSerializer implements androidx.datastore.core.Serializer<androidx.datastore.preferences.core.Preferences> {
+    method public androidx.datastore.preferences.core.Preferences getDefaultValue();
+    method @kotlin.jvm.Throws(exceptionClasses={IOException::class, CorruptionException::class}) public suspend Object? readFrom(java.io.InputStream input, kotlin.coroutines.Continuation<? super androidx.datastore.preferences.core.Preferences>) throws androidx.datastore.core.CorruptionException, java.io.IOException;
+    method @kotlin.jvm.Throws(exceptionClasses={IOException::class, CorruptionException::class}) public suspend Object? writeTo(androidx.datastore.preferences.core.Preferences t, java.io.OutputStream output, kotlin.coroutines.Continuation<? super kotlin.Unit>) throws androidx.datastore.core.CorruptionException, java.io.IOException;
+    property public androidx.datastore.preferences.core.Preferences defaultValue;
+    field public static final androidx.datastore.preferences.core.PreferencesFileSerializer INSTANCE;
+  }
+
   public final class PreferencesKeys {
     method public static androidx.datastore.preferences.core.Preferences.Key<java.lang.Boolean> booleanKey(String name);
     method public static androidx.datastore.preferences.core.Preferences.Key<byte[]> byteArrayKey(String name);
diff --git a/datastore/datastore-preferences-core/api/restricted_current.txt b/datastore/datastore-preferences-core/api/restricted_current.txt
index 1de9eb3..6f9afe6 100644
--- a/datastore/datastore-preferences-core/api/restricted_current.txt
+++ b/datastore/datastore-preferences-core/api/restricted_current.txt
@@ -47,6 +47,14 @@
     method public static androidx.datastore.preferences.core.MutablePreferences createMutable(androidx.datastore.preferences.core.Preferences.Pair<? extends java.lang.Object?>... pairs);
   }
 
+  public final class PreferencesFileSerializer implements androidx.datastore.core.Serializer<androidx.datastore.preferences.core.Preferences> {
+    method public androidx.datastore.preferences.core.Preferences getDefaultValue();
+    method @kotlin.jvm.Throws(exceptionClasses={IOException::class, CorruptionException::class}) public suspend Object? readFrom(java.io.InputStream input, kotlin.coroutines.Continuation<? super androidx.datastore.preferences.core.Preferences>) throws androidx.datastore.core.CorruptionException, java.io.IOException;
+    method @kotlin.jvm.Throws(exceptionClasses={IOException::class, CorruptionException::class}) public suspend Object? writeTo(androidx.datastore.preferences.core.Preferences t, java.io.OutputStream output, kotlin.coroutines.Continuation<? super kotlin.Unit>) throws androidx.datastore.core.CorruptionException, java.io.IOException;
+    property public androidx.datastore.preferences.core.Preferences defaultValue;
+    field public static final androidx.datastore.preferences.core.PreferencesFileSerializer INSTANCE;
+  }
+
   public final class PreferencesKeys {
     method public static androidx.datastore.preferences.core.Preferences.Key<java.lang.Boolean> booleanKey(String name);
     method public static androidx.datastore.preferences.core.Preferences.Key<byte[]> byteArrayKey(String name);
diff --git a/datastore/datastore-preferences-core/src/jvmMain/kotlin/androidx/datastore/preferences/core/PreferencesFileSerializer.jvm.kt b/datastore/datastore-preferences-core/src/jvmMain/kotlin/androidx/datastore/preferences/core/PreferencesFileSerializer.jvm.kt
new file mode 100644
index 0000000..0460111
--- /dev/null
+++ b/datastore/datastore-preferences-core/src/jvmMain/kotlin/androidx/datastore/preferences/core/PreferencesFileSerializer.jvm.kt
@@ -0,0 +1,113 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.datastore.preferences.core
+
+import androidx.datastore.core.CorruptionException
+import androidx.datastore.core.Serializer
+import androidx.datastore.preferences.PreferencesMapCompat
+import androidx.datastore.preferences.PreferencesProto.PreferenceMap
+import androidx.datastore.preferences.PreferencesProto.StringSet
+import androidx.datastore.preferences.PreferencesProto.Value
+import androidx.datastore.preferences.protobuf.ByteString
+import java.io.IOException
+import java.io.InputStream
+import java.io.OutputStream
+import kotlin.jvm.Throws
+
+/**
+ * Proto based serializer for Preferences. Can be used to manually create
+ * [DataStore][androidx.datastore.core.DataStore] using the
+ * [DataStoreFactory#create][androidx.datastore.core.DataStoreFactory.create] function.
+ */
+object PreferencesFileSerializer : Serializer<Preferences> {
+    internal const val fileExtension = "preferences_pb"
+
+    override val defaultValue: Preferences
+        get() {
+            return emptyPreferences()
+        }
+
+    @Throws(IOException::class, CorruptionException::class)
+    override suspend fun readFrom(input: InputStream): Preferences {
+        val preferencesProto = PreferencesMapCompat.readFrom(input)
+
+        val mutablePreferences = mutablePreferencesOf()
+
+        preferencesProto.preferencesMap.forEach { (name, value) ->
+            addProtoEntryToPreferences(name, value, mutablePreferences)
+        }
+
+        return mutablePreferences.toPreferences()
+    }
+
+    @Suppress("InvalidNullabilityOverride") // Remove after b/232460179 is fixed
+    @Throws(IOException::class, CorruptionException::class)
+    override suspend fun writeTo(t: Preferences, output: OutputStream) {
+        val preferences = t.asMap()
+        val protoBuilder = PreferenceMap.newBuilder()
+
+        for ((key, value) in preferences) {
+            protoBuilder.putPreferences(key.name, getValueProto(value))
+        }
+
+        protoBuilder.build().writeTo(output)
+    }
+
+    private fun getValueProto(value: Any): Value {
+        return when (value) {
+            is Boolean -> Value.newBuilder().setBoolean(value).build()
+            is Float -> Value.newBuilder().setFloat(value).build()
+            is Double -> Value.newBuilder().setDouble(value).build()
+            is Int -> Value.newBuilder().setInteger(value).build()
+            is Long -> Value.newBuilder().setLong(value).build()
+            is String -> Value.newBuilder().setString(value).build()
+            is Set<*> ->
+                @Suppress("UNCHECKED_CAST")
+                Value.newBuilder()
+                    .setStringSet(StringSet.newBuilder().addAllStrings(value as Set<String>))
+                    .build()
+            is ByteArray -> Value.newBuilder().setBytes(ByteString.copyFrom(value)).build()
+            else ->
+                throw IllegalStateException(
+                    "PreferencesSerializer does not support type: ${value.javaClass.name}"
+                )
+        }
+    }
+
+    private fun addProtoEntryToPreferences(
+        name: String,
+        value: Value,
+        mutablePreferences: MutablePreferences
+    ) {
+        return when (value.valueCase) {
+            Value.ValueCase.BOOLEAN ->
+                mutablePreferences[booleanPreferencesKey(name)] = value.boolean
+            Value.ValueCase.FLOAT -> mutablePreferences[floatPreferencesKey(name)] = value.float
+            Value.ValueCase.DOUBLE -> mutablePreferences[doublePreferencesKey(name)] = value.double
+            Value.ValueCase.INTEGER -> mutablePreferences[intPreferencesKey(name)] = value.integer
+            Value.ValueCase.LONG -> mutablePreferences[longPreferencesKey(name)] = value.long
+            Value.ValueCase.STRING -> mutablePreferences[stringPreferencesKey(name)] = value.string
+            Value.ValueCase.STRING_SET ->
+                mutablePreferences[stringSetPreferencesKey(name)] =
+                    value.stringSet.stringsList.toSet()
+            Value.ValueCase.BYTES ->
+                mutablePreferences[byteArrayPreferencesKey(name)] = value.bytes.toByteArray()
+            Value.ValueCase.VALUE_NOT_SET -> throw CorruptionException("Value not set.")
+            null -> throw CorruptionException("Value case is null.")
+        }
+    }
+}
diff --git a/datastore/datastore-preferences-core/src/jvmTest/kotlin/androidx/datastore/preferences/core/PreferencesFileSerializerTest.kt b/datastore/datastore-preferences-core/src/jvmTest/kotlin/androidx/datastore/preferences/core/PreferencesFileSerializerTest.kt
new file mode 100644
index 0000000..69dddc3
--- /dev/null
+++ b/datastore/datastore-preferences-core/src/jvmTest/kotlin/androidx/datastore/preferences/core/PreferencesFileSerializerTest.kt
@@ -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.datastore.preferences.core
+
+import androidx.datastore.FileTestIO
+import androidx.datastore.JavaIOFile
+import androidx.datastore.core.CorruptionException
+import androidx.datastore.core.Serializer
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertTrue
+import kotlin.time.Duration.Companion.milliseconds
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.runTest
+
+// TODO(b/375233479): try to dedup the tests between Okio and File
[email protected]
[email protected]
[email protected]
+class PreferencesFileSerializerTest {
+
+    private val testIO = FileTestIO()
+
+    private lateinit var testFile: JavaIOFile
+    private val preferencesSerializer: Serializer<Preferences> = PreferencesFileSerializer
+
+    @BeforeTest
+    fun setUp() {
+        testFile = testIO.newTempFile()
+    }
+
+    fun doTest(test: suspend TestScope.() -> Unit) {
+        runTest(timeout = 10000.milliseconds) { test(this) }
+    }
+
+    @Test
+    fun testThrowsCorruptionException() = doTest {
+        // Not a valid proto - protos cannot start with a 0 byte.
+        testFile.protectedWrite(byteArrayOf(0, 1, 2, 3, 4))
+
+        assertFailsWith<CorruptionException> {
+            testFile.file.inputStream().use { preferencesSerializer.readFrom(it) }
+        }
+    }
+
+    @Test
+    @Suppress("UNCHECKED_CAST")
+    fun testGetAllCantMutateInternalState() {
+        val intKey = intPreferencesKey("int_key")
+        val stringSetKey = stringSetPreferencesKey("string_set_key")
+
+        val prefs = preferencesOf(intKey to 123, stringSetKey to setOf("1", "2", "3"))
+
+        val mutableAllPreferences = prefs.asMap() as MutableMap
+        assertFailsWith<UnsupportedOperationException> { mutableAllPreferences[intKey] = 99999 }
+        assertFailsWith<UnsupportedOperationException> {
+            (mutableAllPreferences[stringSetKey] as MutableSet<String>).clear()
+        }
+
+        assertEquals(123, prefs[intKey])
+        assertEquals(setOf("1", "2", "3"), prefs[stringSetKey])
+    }
+
+    @Test
+    fun testModifyingStringSetDoesntModifyInternalState() {
+        val stringSetKey = stringSetPreferencesKey("string_set_key")
+
+        val stringSet = mutableSetOf("1", "2", "3")
+
+        val prefs = preferencesOf(stringSetKey to stringSet)
+
+        stringSet.add("4") // modify the set passed into preferences
+
+        // modify the returned set.
+        val returnedSet: Set<String> = prefs[stringSetKey]!!
+        val mutableReturnedSet: MutableSet<String> = returnedSet as MutableSet<String>
+
+        assertFailsWith<UnsupportedOperationException> { mutableReturnedSet.clear() }
+        assertFailsWith<UnsupportedOperationException> {
+            mutableReturnedSet.add("Original set does not contain this string")
+        }
+
+        assertEquals(setOf("1", "2", "3"), prefs[stringSetKey])
+    }
+
+    // TODO: This doesn't pass on native: https://youtrack.jetbrains.com/issue/KT-42903
+    @Test
+    @Suppress("UNUSED_VARIABLE")
+    fun testWrongTypeThrowsClassCastException() {
+        val stringKey = stringPreferencesKey("string_key")
+        val intKey = intPreferencesKey("string_key") // long key of the same name as stringKey!
+        val longKey = longPreferencesKey("string_key")
+
+        val prefs = preferencesOf(intKey to 123456)
+
+        assertTrue { prefs.contains(intKey) }
+        assertTrue { prefs.contains(stringKey) } // TODO: I don't think we can prevent this
+
+        // Trying to get a long where there is an Int value throws a ClassCastException.
+        assertFailsWith<ClassCastException> {
+            var unused = prefs[stringKey] // This only throws if it's assigned to a
+            // variable
+        }
+
+        // Trying to get a Long where there is an Int value throws a ClassCastException.
+        assertFailsWith<ClassCastException> {
+            var unused = prefs[longKey] // This only throws if it's assigned to a
+            // variable
+        }
+    }
+}
diff --git a/development/build_log_simplifier/messages.ignore b/development/build_log_simplifier/messages.ignore
index a83629c..bd140da 100644
--- a/development/build_log_simplifier/messages.ignore
+++ b/development/build_log_simplifier/messages.ignore
@@ -342,3 +342,6 @@
 # > Task :wasmJsBrowserTest, known problem and Jetbrains will filter out in following releases:
 # https://slack-chats.kotlinlang.org/t/21024041/any-suggestiong-how-to-solve-uncaught-in-promise-linkerror-i
 Critical dependency: Accessing import\.meta directly is unsupported.*
+# Comes up after test execution or after Karma attempts to kill the browser and it takes too long
+# Can be ignored as the test is complete by the time the message is printed
+ChromeHeadless was not killed in.*
diff --git a/exifinterface/exifinterface/src/androidTest/java/androidx/exifinterface/media/ExifInterfaceTest.java b/exifinterface/exifinterface/src/androidTest/java/androidx/exifinterface/media/ExifInterfaceTest.java
index b138827..1e85fb5 100644
--- a/exifinterface/exifinterface/src/androidTest/java/androidx/exifinterface/media/ExifInterfaceTest.java
+++ b/exifinterface/exifinterface/src/androidTest/java/androidx/exifinterface/media/ExifInterfaceTest.java
@@ -325,12 +325,13 @@
 
     @Test
     @LargeTest
-    public void testPngWithExif() throws Throwable {
+    public void testPngWithExifAndXmp() throws Throwable {
         File imageFile =
                 copyFromResourceToFile(
-                        R.raw.png_with_exif_byte_order_ii, "png_with_exif_byte_order_ii.png");
-        readFromFilesWithExif(imageFile, ExpectedAttributes.PNG_WITH_EXIF_BYTE_ORDER_II);
-        testWritingExif(imageFile, ExpectedAttributes.PNG_WITH_EXIF_BYTE_ORDER_II);
+                        R.raw.png_with_exif_and_xmp_byte_order_ii,
+                        "png_with_exif_and_xmp_byte_order_ii.png");
+        readFromFilesWithExif(imageFile, ExpectedAttributes.PNG_WITH_EXIF_AND_XMP_BYTE_ORDER_II);
+        testWritingExif(imageFile, ExpectedAttributes.PNG_WITH_EXIF_AND_XMP_BYTE_ORDER_II);
     }
 
     @Test
diff --git a/exifinterface/exifinterface/src/androidTest/java/androidx/exifinterface/media/ExpectedAttributes.java b/exifinterface/exifinterface/src/androidTest/java/androidx/exifinterface/media/ExpectedAttributes.java
index 0a51698..8b39aa1 100644
--- a/exifinterface/exifinterface/src/androidTest/java/androidx/exifinterface/media/ExpectedAttributes.java
+++ b/exifinterface/exifinterface/src/androidTest/java/androidx/exifinterface/media/ExpectedAttributes.java
@@ -151,13 +151,15 @@
                     .setXmpOffsetAndLength(1809, 13197)
                     .build();
 
-    /** Expected attributes for {@link R.raw#png_with_exif_byte_order_ii}. */
-    public static final ExpectedAttributes PNG_WITH_EXIF_BYTE_ORDER_II =
+    /** Expected attributes for {@link R.raw#png_with_exif_and_xmp_byte_order_ii}. */
+    public static final ExpectedAttributes PNG_WITH_EXIF_AND_XMP_BYTE_ORDER_II =
             JPEG_WITH_EXIF_BYTE_ORDER_II
                     .buildUpon()
                     .setThumbnailOffset(212271)
                     .setMakeOffset(211525)
                     .setFocalLength("41/10")
+                    // TODO: b/332793608 - Add expected XMP values and offset/length when
+                    //  ExifInterface can parse the iTXt chunk.
                     .build();
 
     /** Expected attributes for {@link R.raw#webp_with_exif}. */
diff --git a/exifinterface/exifinterface/src/androidTest/res/raw/png_with_exif_byte_order_ii.png b/exifinterface/exifinterface/src/androidTest/res/raw/png_with_exif_and_xmp_byte_order_ii.png
similarity index 100%
rename from exifinterface/exifinterface/src/androidTest/res/raw/png_with_exif_byte_order_ii.png
rename to exifinterface/exifinterface/src/androidTest/res/raw/png_with_exif_and_xmp_byte_order_ii.png
Binary files differ
diff --git a/exifinterface/exifinterface/src/main/java/androidx/exifinterface/media/ExifInterface.java b/exifinterface/exifinterface/src/main/java/androidx/exifinterface/media/ExifInterface.java
index f52cec7..4ed56d3 100644
--- a/exifinterface/exifinterface/src/main/java/androidx/exifinterface/media/ExifInterface.java
+++ b/exifinterface/exifinterface/src/main/java/androidx/exifinterface/media/ExifInterface.java
@@ -3101,9 +3101,9 @@
             (byte) 0x47, (byte) 0x0d, (byte) 0x0a, (byte) 0x1a, (byte) 0x0a};
     // See "Extensions to the PNG 1.2 Specification, Version 1.5.0",
     // 3.7. eXIf Exchangeable Image File (Exif) Profile
-    private static final int PNG_CHUNK_TYPE_EXIF = intFromBytes('e', 'X', 'I', 'f');
-    private static final int PNG_CHUNK_TYPE_IHDR = intFromBytes('I', 'H', 'D', 'R');
-    private static final int PNG_CHUNK_TYPE_IEND = intFromBytes('I', 'E', 'N', 'D');
+    private static final int PNG_CHUNK_TYPE_EXIF = 'e' << 24 | 'X' << 16 | 'I' << 8 | 'f';
+    private static final int PNG_CHUNK_TYPE_IHDR = 'I' << 24 | 'H' << 16 | 'D' << 8 | 'R';
+    private static final int PNG_CHUNK_TYPE_IEND = 'I' << 24 | 'E' << 16 | 'N' << 8 | 'D';
     private static final int PNG_CHUNK_TYPE_BYTE_LENGTH = 4;
     private static final int PNG_CHUNK_CRC_BYTE_LENGTH = 4;
 
@@ -8328,12 +8328,4 @@
         }
         return false;
     }
-
-    /*
-     * Combines the lower eight bits of each parameter into a 32-bit int. {@code b1} is the highest
-     * byte of the result, {@code b4} is the lowest.
-     */
-    private static int intFromBytes(int b1, int b2, int b3, int b4) {
-        return ((b1 & 0xFF) << 24) | ((b2 & 0xFF) << 16) | ((b3 & 0xFF) << 8) | (b4 & 0xFF);
-    }
 }
diff --git a/fragment/integration-tests/testapp/build.gradle b/fragment/integration-tests/testapp/build.gradle
index 5447bc6..42604a1 100644
--- a/fragment/integration-tests/testapp/build.gradle
+++ b/fragment/integration-tests/testapp/build.gradle
@@ -22,6 +22,7 @@
 
 android {
     namespace "androidx.fragment.testapp"
+    compileSdk 35
 }
 
 dependencies {
@@ -34,6 +35,8 @@
     implementation("androidx.recyclerview:recyclerview:1.1.0")
     debugImplementation(project(":fragment:fragment-testing-manifest"))
 
+    androidTestImplementation(project(":core:core-ktx"))
+    androidTestImplementation(project(":core:core"))
     androidTestImplementation(project(":fragment:fragment-testing"))
     androidTestImplementation("androidx.lifecycle:lifecycle-common:2.6.2")
     androidTestImplementation(project(":fragment:fragment-testing-manifest"))
diff --git a/health/connect/connect-client/src/androidTest/java/androidx/health/connect/client/impl/HealthConnectClientUpsideDownImplTest.kt b/health/connect/connect-client/src/androidTest/java/androidx/health/connect/client/impl/HealthConnectClientUpsideDownImplTest.kt
index e1f40ab..14dde25 100644
--- a/health/connect/connect-client/src/androidTest/java/androidx/health/connect/client/impl/HealthConnectClientUpsideDownImplTest.kt
+++ b/health/connect/connect-client/src/androidTest/java/androidx/health/connect/client/impl/HealthConnectClientUpsideDownImplTest.kt
@@ -25,8 +25,9 @@
 import androidx.health.connect.client.changes.DeletionChange
 import androidx.health.connect.client.changes.UpsertionChange
 import androidx.health.connect.client.feature.ExperimentalFeatureAvailabilityApi
-import androidx.health.connect.client.impl.converters.datatype.RECORDS_CLASS_NAME_MAP
 import androidx.health.connect.client.impl.platform.aggregate.AGGREGATE_METRICS_ADDED_IN_SDK_EXT_10
+import androidx.health.connect.client.impl.platform.records.SDK_TO_PLATFORM_RECORD_CLASS
+import androidx.health.connect.client.impl.platform.records.SDK_TO_PLATFORM_RECORD_CLASS_EXT_13
 import androidx.health.connect.client.permission.HealthPermission.Companion.PERMISSION_PREFIX
 import androidx.health.connect.client.readRecord
 import androidx.health.connect.client.records.BloodPressureRecord
@@ -109,9 +110,15 @@
 
     @After
     fun tearDown() = runTest {
-        for (recordType in RECORDS_CLASS_NAME_MAP.keys) {
+        for (recordType in SDK_TO_PLATFORM_RECORD_CLASS.keys) {
             healthConnectClient.deleteRecords(recordType, TimeRangeFilter.none())
         }
+
+        if (SdkExtensions.getExtensionVersion(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) >= 13) {
+            for (recordType in SDK_TO_PLATFORM_RECORD_CLASS_EXT_13.keys) {
+                healthConnectClient.deleteRecords(recordType, TimeRangeFilter.none())
+            }
+        }
     }
 
     @Test
diff --git a/health/connect/connect-client/src/androidTest/java/androidx/health/connect/client/impl/platform/aggregate/HealthConnectClientAggregationExtensionsTest.kt b/health/connect/connect-client/src/androidTest/java/androidx/health/connect/client/impl/platform/aggregate/HealthConnectClientAggregationExtensionsTest.kt
index 653ea0c..b48b88b 100644
--- a/health/connect/connect-client/src/androidTest/java/androidx/health/connect/client/impl/platform/aggregate/HealthConnectClientAggregationExtensionsTest.kt
+++ b/health/connect/connect-client/src/androidTest/java/androidx/health/connect/client/impl/platform/aggregate/HealthConnectClientAggregationExtensionsTest.kt
@@ -23,7 +23,8 @@
 import android.os.ext.SdkExtensions
 import androidx.health.connect.client.HealthConnectClient
 import androidx.health.connect.client.impl.HealthConnectClientUpsideDownImpl
-import androidx.health.connect.client.impl.converters.datatype.RECORDS_CLASS_NAME_MAP
+import androidx.health.connect.client.impl.platform.records.SDK_TO_PLATFORM_RECORD_CLASS
+import androidx.health.connect.client.impl.platform.records.SDK_TO_PLATFORM_RECORD_CLASS_EXT_13
 import androidx.health.connect.client.permission.HealthPermission.Companion.PERMISSION_PREFIX
 import androidx.health.connect.client.records.BloodPressureRecord
 import androidx.health.connect.client.records.CyclingPedalingCadenceRecord
@@ -87,9 +88,15 @@
 
     @After
     fun tearDown() = runTest {
-        for (recordType in RECORDS_CLASS_NAME_MAP.keys) {
+        for (recordType in SDK_TO_PLATFORM_RECORD_CLASS.keys) {
             healthConnectClient.deleteRecords(recordType, TimeRangeFilter.none())
         }
+
+        if (SdkExtensions.getExtensionVersion(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) >= 13) {
+            for (recordType in SDK_TO_PLATFORM_RECORD_CLASS_EXT_13.keys) {
+                healthConnectClient.deleteRecords(recordType, TimeRangeFilter.none())
+            }
+        }
     }
 
     @Test
diff --git a/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/converters/datatype/RecordsTypeNameMap.kt b/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/converters/datatype/RecordsTypeNameMap.kt
index cf92c55..e7a4dd8 100644
--- a/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/converters/datatype/RecordsTypeNameMap.kt
+++ b/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/converters/datatype/RecordsTypeNameMap.kt
@@ -49,6 +49,7 @@
 import androidx.health.connect.client.records.RespiratoryRateRecord
 import androidx.health.connect.client.records.RestingHeartRateRecord
 import androidx.health.connect.client.records.SexualActivityRecord
+import androidx.health.connect.client.records.SkinTemperatureRecord
 import androidx.health.connect.client.records.SleepSessionRecord
 import androidx.health.connect.client.records.SpeedRecord
 import androidx.health.connect.client.records.StepsCadenceRecord
@@ -91,6 +92,7 @@
         "RespiratoryRate" to RespiratoryRateRecord::class,
         "RestingHeartRate" to RestingHeartRateRecord::class,
         "SexualActivity" to SexualActivityRecord::class,
+        "SkinTemperature" to SkinTemperatureRecord::class,
         "SleepSession" to SleepSessionRecord::class,
         "SpeedSeries" to SpeedRecord::class, // Keep legacy Series suffix
         "IntermenstrualBleeding" to IntermenstrualBleedingRecord::class,
diff --git a/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/converters/records/ProtoToRecordConverters.kt b/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/converters/records/ProtoToRecordConverters.kt
index 5b61ecc..25d642fa8 100644
--- a/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/converters/records/ProtoToRecordConverters.kt
+++ b/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/converters/records/ProtoToRecordConverters.kt
@@ -55,6 +55,7 @@
 import androidx.health.connect.client.records.RespiratoryRateRecord
 import androidx.health.connect.client.records.RestingHeartRateRecord
 import androidx.health.connect.client.records.SexualActivityRecord
+import androidx.health.connect.client.records.SkinTemperatureRecord
 import androidx.health.connect.client.records.SleepSessionRecord
 import androidx.health.connect.client.records.SpeedRecord
 import androidx.health.connect.client.records.StepsCadenceRecord
@@ -523,6 +524,22 @@
                     endZoneOffset = endZoneOffset,
                     metadata = metadata
                 )
+            "SkinTemperature" ->
+                SkinTemperatureRecord(
+                    baseline = valuesMap["baseline"]?.doubleVal?.celsius,
+                    measurementLocation =
+                        mapEnum(
+                            "measurementLocation",
+                            SkinTemperatureRecord.MEASUREMENT_LOCATION_STRING_TO_INT_MAP,
+                            SkinTemperatureRecord.MEASUREMENT_LOCATION_UNKNOWN,
+                        ),
+                    startTime = startTime,
+                    startZoneOffset = startZoneOffset,
+                    endTime = endTime,
+                    endZoneOffset = endZoneOffset,
+                    deltas = subTypeDataListsMap["deltas"]?.toDeltasList() ?: emptyList(),
+                    metadata = metadata
+                )
             "SleepSession" ->
                 SleepSessionRecord(
                     title = getString("title"),
diff --git a/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/converters/records/ProtoToRecordUtils.kt b/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/converters/records/ProtoToRecordUtils.kt
index e460b00..566a61b 100644
--- a/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/converters/records/ProtoToRecordUtils.kt
+++ b/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/converters/records/ProtoToRecordUtils.kt
@@ -22,11 +22,13 @@
 import androidx.health.connect.client.records.ExerciseRoute
 import androidx.health.connect.client.records.ExerciseSegment
 import androidx.health.connect.client.records.ExerciseSegment.Companion.EXERCISE_SEGMENT_TYPE_UNKNOWN
+import androidx.health.connect.client.records.SkinTemperatureRecord
 import androidx.health.connect.client.records.SleepSessionRecord
 import androidx.health.connect.client.records.SleepSessionRecord.Companion.STAGE_TYPE_STRING_TO_INT_MAP
 import androidx.health.connect.client.records.metadata.DataOrigin
 import androidx.health.connect.client.records.metadata.Device
 import androidx.health.connect.client.records.metadata.Metadata
+import androidx.health.connect.client.units.TemperatureDelta
 import androidx.health.connect.client.units.meters
 import androidx.health.platform.client.proto.DataProto
 import androidx.health.platform.client.proto.DataProto.DataPointOrBuilder
@@ -113,6 +115,15 @@
     )
 }
 
+internal fun DataProto.DataPoint.SubTypeDataList.toDeltasList(): List<SkinTemperatureRecord.Delta> {
+    return valuesList.map {
+        SkinTemperatureRecord.Delta(
+            time = Instant.ofEpochMilli(it.startTimeMillis),
+            delta = TemperatureDelta.celsius(it.valuesMap["delta"]?.doubleVal ?: 0.0),
+        )
+    }
+}
+
 internal fun DataProto.DataPoint.SubTypeDataList.toStageList(): List<SleepSessionRecord.Stage> {
     return valuesList.map {
         SleepSessionRecord.Stage(
diff --git a/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/converters/records/RecordToProtoConverters.kt b/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/converters/records/RecordToProtoConverters.kt
index 8438aa2..1f75aa8 100644
--- a/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/converters/records/RecordToProtoConverters.kt
+++ b/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/converters/records/RecordToProtoConverters.kt
@@ -55,6 +55,7 @@
 import androidx.health.connect.client.records.RestingHeartRateRecord
 import androidx.health.connect.client.records.SeriesRecord
 import androidx.health.connect.client.records.SexualActivityRecord
+import androidx.health.connect.client.records.SkinTemperatureRecord
 import androidx.health.connect.client.records.SleepSessionRecord
 import androidx.health.connect.client.records.SpeedRecord
 import androidx.health.connect.client.records.StepsCadenceRecord
@@ -481,6 +482,28 @@
                     name?.let { putValues("name", stringVal(it)) }
                 }
                 .build()
+        is SkinTemperatureRecord ->
+            intervalProto()
+                .setDataType(protoDataType("SkinTemperature"))
+                .apply {
+                    if (baseline != null) {
+                        putValues("baseline", doubleVal(baseline.inCelsius))
+                    }
+                    if (deltas.isNotEmpty()) {
+                        putSubTypeDataLists(
+                            "deltas",
+                            DataProto.DataPoint.SubTypeDataList.newBuilder()
+                                .addAllValues(deltas.map { it.toProto() })
+                                .build(),
+                        )
+                    }
+                    enumValFromInt(
+                            measurementLocation,
+                            SkinTemperatureRecord.MEASUREMENT_LOCATION_INT_TO_STRING_MAP,
+                        )
+                        ?.let { putValues("measurementLocation", it) }
+                }
+                .build()
         is SleepSessionRecord ->
             intervalProto()
                 .setDataType(protoDataType("SleepSession"))
diff --git a/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/converters/records/RecordToProtoUtils.kt b/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/converters/records/RecordToProtoUtils.kt
index 3e5fa01..8ce8f01 100644
--- a/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/converters/records/RecordToProtoUtils.kt
+++ b/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/converters/records/RecordToProtoUtils.kt
@@ -23,6 +23,7 @@
 import androidx.health.connect.client.records.ExerciseSegment
 import androidx.health.connect.client.records.InstantaneousRecord
 import androidx.health.connect.client.records.IntervalRecord
+import androidx.health.connect.client.records.SkinTemperatureRecord
 import androidx.health.connect.client.records.SleepSessionRecord
 import androidx.health.connect.client.records.metadata.Device
 import androidx.health.connect.client.records.metadata.DeviceTypes
@@ -92,6 +93,14 @@
         .build()
 }
 
+internal fun SkinTemperatureRecord.Delta.toProto(): DataProto.SubTypeDataValue {
+    return DataProto.SubTypeDataValue.newBuilder()
+        .setStartTimeMillis(time.toEpochMilli())
+        .setEndTimeMillis(time.toEpochMilli())
+        .putValues("delta", doubleVal(delta.inCelsius))
+        .build()
+}
+
 internal fun SleepSessionRecord.Stage.toProto(): DataProto.SubTypeDataValue {
     return DataProto.SubTypeDataValue.newBuilder()
         .setStartTimeMillis(startTime.toEpochMilli())
diff --git a/health/connect/connect-client/src/test/java/androidx/health/connect/client/impl/converters/records/AllRecordsConverterTest.kt b/health/connect/connect-client/src/test/java/androidx/health/connect/client/impl/converters/records/AllRecordsConverterTest.kt
index 24dac4c..d9fb1f2 100644
--- a/health/connect/connect-client/src/test/java/androidx/health/connect/client/impl/converters/records/AllRecordsConverterTest.kt
+++ b/health/connect/connect-client/src/test/java/androidx/health/connect/client/impl/converters/records/AllRecordsConverterTest.kt
@@ -54,6 +54,7 @@
 import androidx.health.connect.client.records.RespiratoryRateRecord
 import androidx.health.connect.client.records.RestingHeartRateRecord
 import androidx.health.connect.client.records.SexualActivityRecord
+import androidx.health.connect.client.records.SkinTemperatureRecord
 import androidx.health.connect.client.records.SleepSessionRecord
 import androidx.health.connect.client.records.SpeedRecord
 import androidx.health.connect.client.records.StepsCadenceRecord
@@ -67,6 +68,7 @@
 import androidx.health.connect.client.records.metadata.Metadata
 import androidx.health.connect.client.units.BloodGlucose
 import androidx.health.connect.client.units.Length
+import androidx.health.connect.client.units.TemperatureDelta
 import androidx.health.connect.client.units.celsius
 import androidx.health.connect.client.units.grams
 import androidx.health.connect.client.units.kilocalories
@@ -805,6 +807,46 @@
     }
 
     @Test
+    fun testSkinTemperature() {
+        val data =
+            SkinTemperatureRecord(
+                baseline = 34.3.celsius,
+                measurementLocation = SkinTemperatureRecord.MEASUREMENT_LOCATION_WRIST,
+                startTime = START_TIME,
+                startZoneOffset = START_ZONE_OFFSET,
+                endTime = END_TIME,
+                endZoneOffset = END_ZONE_OFFSET,
+                metadata = TEST_METADATA,
+                deltas =
+                    listOf(
+                        SkinTemperatureRecord.Delta(
+                            time = Instant.ofEpochMilli(1234L),
+                            delta = TemperatureDelta.celsius(1.2),
+                        )
+                    )
+            )
+
+        checkProtoAndRecordTypeNameMatch(data)
+        assertThat(toRecord(data.toProto())).isEqualTo(data)
+    }
+
+    @Test
+    fun testSkinTemperatureWithEmptyDeltasList() {
+        val data =
+            SkinTemperatureRecord(
+                startTime = START_TIME,
+                startZoneOffset = START_ZONE_OFFSET,
+                endTime = END_TIME,
+                endZoneOffset = END_ZONE_OFFSET,
+                metadata = TEST_METADATA,
+                deltas = emptyList()
+            )
+
+        checkProtoAndRecordTypeNameMatch(data)
+        assertThat(toRecord(data.toProto())).isEqualTo(data)
+    }
+
+    @Test
     fun testSleepSession() {
         val data =
             SleepSessionRecord(
diff --git a/health/health-services-client-external-protobuf/OWNERS b/health/health-services-client-external-protobuf/OWNERS
new file mode 100644
index 0000000..ff2eed8
--- /dev/null
+++ b/health/health-services-client-external-protobuf/OWNERS
@@ -0,0 +1,5 @@
+# Bug component: 1126127
[email protected]
[email protected]
[email protected]
[email protected]
diff --git a/health/health-services-client-proto/OWNERS b/health/health-services-client-proto/OWNERS
new file mode 100644
index 0000000..ff2eed8
--- /dev/null
+++ b/health/health-services-client-proto/OWNERS
@@ -0,0 +1,5 @@
+# Bug component: 1126127
[email protected]
[email protected]
[email protected]
[email protected]
diff --git a/health/health-services-client-proto/src/main/proto/data.proto b/health/health-services-client-proto/src/main/proto/data.proto
index 3da3cca..c12c497 100644
--- a/health/health-services-client-proto/src/main/proto/data.proto
+++ b/health/health-services-client-proto/src/main/proto/data.proto
@@ -224,13 +224,15 @@
   repeated BatchingMode batching_mode_overrides = 11 [packed = true];
   repeated ExerciseEventType exercise_event_types = 12 [packed = true];
   repeated DebouncedGoal debounced_goals = 15;
-  reserved 9, 13, 14, 16 to max;
+  reserved 9, 13, 14, 16, 17, 18, 19;
+  reserved 20 to max; // Next ID
 }
 
 message ExerciseInfo {
   optional ExerciseTrackedStatus exercise_tracked_status = 1;
   optional ExerciseType exercise_type = 2;
-  reserved 3 to max;  // Next ID
+  reserved 3;
+  reserved 4 to max;  // Next ID
 }
 
 message ExerciseGoal {
@@ -490,7 +492,8 @@
   optional ExerciseEndReason exercise_end_reason = 11;
   repeated DebouncedGoal latest_achieved_debounced_goals = 13;
 
-  reserved 12, 14 to max;
+  reserved 12, 14;
+  reserved 15 to max;  // Next ID
 }
 
 enum ExerciseEndReason {
@@ -561,7 +564,8 @@
 message WarmUpConfig {
   optional ExerciseType exercise_type = 1;
   repeated DataType data_types = 2;
-  reserved 3 to max;  // Next ID
+  reserved 3, 4, 5, 6;
+  reserved 7 to max;  // Next ID
 }
 
 message PassiveGoal {
diff --git a/libraryversions.toml b/libraryversions.toml
index 057ae7b..082fb04 100644
--- a/libraryversions.toml
+++ b/libraryversions.toml
@@ -1,12 +1,12 @@
 [versions]
-ACTIVITY = "1.10.0-beta01"
+ACTIVITY = "1.10.0-rc01"
 ANNOTATION = "1.9.0-rc01"
 ANNOTATION_EXPERIMENTAL = "1.5.0-alpha01"
 APPCOMPAT = "1.8.0-alpha01"
 APPSEARCH = "1.1.0-alpha07"
 ARCH_CORE = "2.3.0-alpha01"
 ASYNCLAYOUTINFLATER = "1.1.0-alpha02"
-AUTOFILL = "1.3.0-beta01"
+AUTOFILL = "1.3.0-rc01"
 BENCHMARK = "1.4.0-alpha05"
 BIOMETRIC = "1.4.0-alpha02"
 BLUETOOTH = "1.0.0-alpha02"
@@ -45,6 +45,7 @@
 CORE_SPLASHSCREEN = "1.2.0-alpha02"
 CORE_TELECOM = "1.0.0-alpha4"
 CORE_UWB = "1.0.0-alpha09"
+CORE_VIEWTREE = "1.0.0-alpha01"
 CREDENTIALS = "1.5.0-beta01"
 CREDENTIALS_E2EE_QUARANTINE = "1.0.0-alpha02"
 CREDENTIALS_FIDO_QUARANTINE = "1.0.0-alpha02"
@@ -92,14 +93,14 @@
 LEANBACK_TAB = "1.1.0-beta01"
 LEGACY = "1.1.0-alpha01"
 LIBYUV = "0.1.0-dev01"
-LIFECYCLE = "2.9.0-alpha07"
+LIFECYCLE = "2.9.0-alpha08"
 LIFECYCLE_EXTENSIONS = "2.2.0"
-LINT = "1.0.0-alpha02"
+LINT = "1.0.0-alpha03"
 LOADER = "1.2.0-alpha01"
 MEDIA = "1.7.0-rc01"
 MEDIAROUTER = "1.8.0-alpha01"
 METRICS = "1.0.0-beta02"
-NAVIGATION = "2.9.0-alpha03"
+NAVIGATION = "2.9.0-alpha04"
 NAVIGATION3 = "0.1.0-dev01"
 PAGING = "3.4.0-alpha01"
 PALETTE = "1.1.0-alpha01"
@@ -121,7 +122,7 @@
 RESOURCEINSPECTION = "1.1.0-alpha01"
 ROOM = "2.7.0-alpha12"
 SAFEPARCEL = "1.0.0-alpha01"
-SAVEDSTATE = "1.3.0-alpha05"
+SAVEDSTATE = "1.3.0-alpha06"
 SECURITY = "1.1.0-alpha07"
 SECURITY_APP_AUTHENTICATOR = "1.0.0-rc01"
 SECURITY_APP_AUTHENTICATOR_TESTING = "1.0.0-rc01"
@@ -157,8 +158,8 @@
 VIEWPAGER = "1.1.0-rc01"
 VIEWPAGER2 = "1.2.0-alpha01"
 WEAR = "1.4.0-alpha01"
-WEAR_COMPOSE = "1.5.0-alpha06"
-WEAR_COMPOSE_MATERIAL3 = "1.0.0-alpha29"
+WEAR_COMPOSE = "1.5.0-alpha07"
+WEAR_COMPOSE_MATERIAL3 = "1.0.0-alpha30"
 WEAR_CORE = "1.0.0-alpha01"
 WEAR_INPUT = "1.2.0-alpha03"
 WEAR_INPUT_TESTING = "1.2.0-alpha03"
diff --git a/lifecycle/integration-tests/incrementality/build.gradle b/lifecycle/integration-tests/incrementality/build.gradle
index 7a8cfcf..2ab870e 100644
--- a/lifecycle/integration-tests/incrementality/build.gradle
+++ b/lifecycle/integration-tests/incrementality/build.gradle
@@ -44,6 +44,7 @@
             ":lifecycle:lifecycle-compiler:publish",
             ":lifecycle:lifecycle-common:publish",
             ":lifecycle:lifecycle-runtime:publish",
+            ":core:core-viewtree:publish",
             ":annotation:annotation:publish",
             ":arch:core:core-common:publish",
             ":arch:core:core-runtime:publish"
diff --git a/lifecycle/lifecycle-viewmodel-navigation3/build.gradle b/lifecycle/lifecycle-viewmodel-navigation3/build.gradle
index 90b6646..4f4b58b 100644
--- a/lifecycle/lifecycle-viewmodel-navigation3/build.gradle
+++ b/lifecycle/lifecycle-viewmodel-navigation3/build.gradle
@@ -41,11 +41,12 @@
     sourceSets {
         commonMain {
             dependencies {
+                api("androidx.lifecycle:lifecycle-viewmodel:2.8.7")
+                api("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
+
                 implementation(libs.kotlinStdlib)
                 implementation("androidx.compose.runtime:runtime:1.7.5")
                 implementation("androidx.compose.runtime:runtime-saveable:1.7.5")
-                implementation("androidx.lifecycle:lifecycle-viewmodel:2.8.7")
-                implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
             }
         }
 
diff --git a/mediarouter/mediarouter/api/current.txt b/mediarouter/mediarouter/api/current.txt
index 45aff505..6ed85777 100644
--- a/mediarouter/mediarouter/api/current.txt
+++ b/mediarouter/mediarouter/api/current.txt
@@ -514,6 +514,7 @@
   public class MediaRouterParams {
     method public int getDialogType();
     method public boolean isMediaTransferReceiverEnabled();
+    method public boolean isMediaTransferRestrictedToSelfProviders();
     method public boolean isOutputSwitcherEnabled();
     method public boolean isTransferToLocalEnabled();
     field public static final int DIALOG_TYPE_DEFAULT = 1; // 0x1
@@ -527,6 +528,7 @@
     method public androidx.mediarouter.media.MediaRouterParams build();
     method public androidx.mediarouter.media.MediaRouterParams.Builder setDialogType(int);
     method public androidx.mediarouter.media.MediaRouterParams.Builder setMediaTransferReceiverEnabled(boolean);
+    method public androidx.mediarouter.media.MediaRouterParams.Builder setMediaTransferRestrictedToSelfProviders(boolean);
     method public androidx.mediarouter.media.MediaRouterParams.Builder setOutputSwitcherEnabled(boolean);
     method public androidx.mediarouter.media.MediaRouterParams.Builder setTransferToLocalEnabled(boolean);
   }
diff --git a/mediarouter/mediarouter/api/restricted_current.txt b/mediarouter/mediarouter/api/restricted_current.txt
index 45aff505..6ed85777 100644
--- a/mediarouter/mediarouter/api/restricted_current.txt
+++ b/mediarouter/mediarouter/api/restricted_current.txt
@@ -514,6 +514,7 @@
   public class MediaRouterParams {
     method public int getDialogType();
     method public boolean isMediaTransferReceiverEnabled();
+    method public boolean isMediaTransferRestrictedToSelfProviders();
     method public boolean isOutputSwitcherEnabled();
     method public boolean isTransferToLocalEnabled();
     field public static final int DIALOG_TYPE_DEFAULT = 1; // 0x1
@@ -527,6 +528,7 @@
     method public androidx.mediarouter.media.MediaRouterParams build();
     method public androidx.mediarouter.media.MediaRouterParams.Builder setDialogType(int);
     method public androidx.mediarouter.media.MediaRouterParams.Builder setMediaTransferReceiverEnabled(boolean);
+    method public androidx.mediarouter.media.MediaRouterParams.Builder setMediaTransferRestrictedToSelfProviders(boolean);
     method public androidx.mediarouter.media.MediaRouterParams.Builder setOutputSwitcherEnabled(boolean);
     method public androidx.mediarouter.media.MediaRouterParams.Builder setTransferToLocalEnabled(boolean);
   }
diff --git a/mediarouter/mediarouter/src/androidTest/AndroidManifest.xml b/mediarouter/mediarouter/src/androidTest/AndroidManifest.xml
index 7961fb1..787c415 100644
--- a/mediarouter/mediarouter/src/androidTest/AndroidManifest.xml
+++ b/mediarouter/mediarouter/src/androidTest/AndroidManifest.xml
@@ -54,7 +54,8 @@
             android:exported="true"
             android:enabled="true">
             <intent-filter>
-                <action android:name="aandroid.media.MediaRouteProviderService"/>
+                <action android:name="android.media.MediaRouteProviderService"/>
+                <action android:name="android.media.MediaRoute2ProviderService"/>
             </intent-filter>
         </service>
     </application>
diff --git a/mediarouter/mediarouter/src/androidTest/java/androidx/mediarouter/media/MediaRouteProviderServiceTest.java b/mediarouter/mediarouter/src/androidTest/java/androidx/mediarouter/media/MediaRouteProviderServiceTest.java
index 9fd3cc8..032e7b9 100644
--- a/mediarouter/mediarouter/src/androidTest/java/androidx/mediarouter/media/MediaRouteProviderServiceTest.java
+++ b/mediarouter/mediarouter/src/androidTest/java/androidx/mediarouter/media/MediaRouteProviderServiceTest.java
@@ -25,6 +25,7 @@
 
 import android.content.Context;
 import android.content.Intent;
+import android.os.Build;
 import android.os.Bundle;
 import android.os.Handler;
 import android.os.IBinder;
@@ -37,6 +38,7 @@
 import androidx.test.core.app.ApplicationProvider;
 import androidx.test.ext.junit.runners.AndroidJUnit4;
 import androidx.test.filters.LargeTest;
+import androidx.test.filters.SdkSuppress;
 import androidx.test.filters.SmallTest;
 import androidx.test.rule.ServiceTestRule;
 
@@ -192,8 +194,10 @@
 
     @LargeTest
     @Test
-    public void testSetEmptyPassiveDiscoveryRequest_shouldNotRequestScan() throws Exception {
-        sendDiscoveryRequest(mReceiveMessenger1,
+    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.S)
+    public void setEmptyPassiveDiscoveryRequest_shouldNotScan() throws Exception {
+        sendDiscoveryRequest(
+                mReceiveMessenger1,
                 new MediaRouteDiscoveryRequest(MediaRouteSelector.EMPTY, false));
 
         Thread.sleep(TIME_OUT_MS);
diff --git a/mediarouter/mediarouter/src/androidTest/java/androidx/mediarouter/media/MediaRouterParamsTest.java b/mediarouter/mediarouter/src/androidTest/java/androidx/mediarouter/media/MediaRouterParamsTest.java
new file mode 100644
index 0000000..98bf620
--- /dev/null
+++ b/mediarouter/mediarouter/src/androidTest/java/androidx/mediarouter/media/MediaRouterParamsTest.java
@@ -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.mediarouter.media;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertSame;
+
+import android.os.Build;
+import android.os.Bundle;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SdkSuppress;
+import androidx.test.filters.SmallTest;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/** Test {@link MediaRouterParams}. */
+@RunWith(AndroidJUnit4.class)
+public class MediaRouterParamsTest {
+
+    private static final String TEST_KEY = "test_key";
+    private static final String TEST_VALUE = "test_value";
+
+    @Test
+    @SmallTest
+    @SdkSuppress(maxSdkVersion = Build.VERSION_CODES.Q)
+    public void mediaRouterParamsBuilder_androidQOrBelow() {
+        verifyMediaRouterParamsBuilder(/* isAndroidROrAbove= */ false);
+    }
+
+    @Test
+    @SmallTest
+    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.R)
+    public void mediaRouterParamsBuilder_androidROrAbove() {
+        verifyMediaRouterParamsBuilder(/* isAndroidROrAbove= */ true);
+    }
+
+    private void verifyMediaRouterParamsBuilder(boolean isAndroidROrAbove) {
+        final int dialogType = MediaRouterParams.DIALOG_TYPE_DYNAMIC_GROUP;
+        final boolean isOutputSwitcherEnabled = true;
+        final boolean transferToLocalEnabled = true;
+        final boolean transferReceiverEnabled = false;
+        final boolean mediaTransferRestrictedToSelfProviders = true;
+        final Bundle extras = new Bundle();
+        extras.putString(TEST_KEY, TEST_VALUE);
+
+        MediaRouterParams params =
+                new MediaRouterParams.Builder()
+                        .setDialogType(dialogType)
+                        .setOutputSwitcherEnabled(isOutputSwitcherEnabled)
+                        .setTransferToLocalEnabled(transferToLocalEnabled)
+                        .setMediaTransferReceiverEnabled(transferReceiverEnabled)
+                        .setMediaTransferRestrictedToSelfProviders(
+                                mediaTransferRestrictedToSelfProviders)
+                        .setExtras(extras)
+                        .build();
+
+        assertEquals(dialogType, params.getDialogType());
+
+        if (isAndroidROrAbove) {
+            assertEquals(isOutputSwitcherEnabled, params.isOutputSwitcherEnabled());
+            assertEquals(transferToLocalEnabled, params.isTransferToLocalEnabled());
+            assertEquals(transferReceiverEnabled, params.isMediaTransferReceiverEnabled());
+            assertEquals(
+                    mediaTransferRestrictedToSelfProviders,
+                    params.isMediaTransferRestrictedToSelfProviders());
+        } else {
+            // Earlier than Android R, output switcher cannot be enabled.
+            // Same for transfer to local.
+            assertFalse(params.isOutputSwitcherEnabled());
+            assertFalse(params.isTransferToLocalEnabled());
+            assertFalse(params.isMediaTransferReceiverEnabled());
+            assertFalse(params.isMediaTransferRestrictedToSelfProviders());
+        }
+
+        extras.remove(TEST_KEY);
+        assertEquals(TEST_VALUE, params.getExtras().getString(TEST_KEY));
+
+        // Tests copy constructor of builder
+        MediaRouterParams copiedParams = new MediaRouterParams.Builder(params).build();
+        assertEquals(params.getDialogType(), copiedParams.getDialogType());
+        assertEquals(params.isOutputSwitcherEnabled(), copiedParams.isOutputSwitcherEnabled());
+        assertEquals(params.isTransferToLocalEnabled(), copiedParams.isTransferToLocalEnabled());
+        assertEquals(
+                params.isMediaTransferReceiverEnabled(),
+                copiedParams.isMediaTransferReceiverEnabled());
+        assertEquals(
+                params.isMediaTransferRestrictedToSelfProviders(),
+                copiedParams.isMediaTransferRestrictedToSelfProviders());
+        assertBundleEquals(params.getExtras(), copiedParams.getExtras());
+    }
+
+    /** Asserts that two Bundles are equal. */
+    @SuppressWarnings("deprecation")
+    public static void assertBundleEquals(Bundle expected, Bundle observed) {
+        if (expected == null || observed == null) {
+            assertSame(expected, observed);
+        }
+        assertEquals(expected.size(), observed.size());
+        for (String key : expected.keySet()) {
+            assertEquals(expected.get(key), observed.get(key));
+        }
+    }
+}
diff --git a/mediarouter/mediarouter/src/androidTest/java/androidx/mediarouter/media/MediaRouterTest.java b/mediarouter/mediarouter/src/androidTest/java/androidx/mediarouter/media/MediaRouterTest.java
index 6f7b964..6a54716 100644
--- a/mediarouter/mediarouter/src/androidTest/java/androidx/mediarouter/media/MediaRouterTest.java
+++ b/mediarouter/mediarouter/src/androidTest/java/androidx/mediarouter/media/MediaRouterTest.java
@@ -24,7 +24,6 @@
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertNull;
-import static org.junit.Assert.assertSame;
 import static org.junit.Assert.assertTrue;
 
 import android.content.Context;
@@ -37,6 +36,7 @@
 import androidx.test.annotation.UiThreadTest;
 import androidx.test.ext.junit.runners.AndroidJUnit4;
 import androidx.test.filters.LargeTest;
+import androidx.test.filters.SdkSuppress;
 import androidx.test.filters.SmallTest;
 
 import org.junit.After;
@@ -70,7 +70,7 @@
     private CountDownLatch mPassiveScanCountDownLatch;
 
     @Before
-    public void setUp() throws Exception {
+    public void setUp() {
         resetActiveAndPassiveScanCountDownLatches();
         getInstrumentation()
                 .runOnMainSync(
@@ -80,10 +80,11 @@
                             mSession = new MediaSessionCompat(mContext, SESSION_TAG);
                             mProvider = new MediaRouteProviderImpl(mContext);
                         });
+        assertTrue(MediaTransferReceiver.isDeclared(mContext));
     }
 
     @After
-    public void tearDown() throws Exception {
+    public void tearDown() {
         mSession.release();
         getInstrumentation().runOnMainSync(() -> MediaRouterTestHelper.resetMediaRouter());
     }
@@ -119,67 +120,26 @@
 
     @Test
     @SmallTest
-    public void mediaRouterParamsBuilder() {
-        final int dialogType = MediaRouterParams.DIALOG_TYPE_DYNAMIC_GROUP;
-        final boolean isOutputSwitcherEnabled = true;
-        final boolean transferToLocalEnabled = true;
-        final boolean transferReceiverEnabled = false;
-        final Bundle extras = new Bundle();
-        extras.putString(TEST_KEY, TEST_VALUE);
-
-        MediaRouterParams params = new MediaRouterParams.Builder()
-                .setDialogType(dialogType)
-                .setOutputSwitcherEnabled(isOutputSwitcherEnabled)
-                .setTransferToLocalEnabled(transferToLocalEnabled)
-                .setMediaTransferReceiverEnabled(transferReceiverEnabled)
-                .setExtras(extras)
-                .build();
-
-        assertEquals(dialogType, params.getDialogType());
-
-        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
-            assertEquals(isOutputSwitcherEnabled, params.isOutputSwitcherEnabled());
-            assertEquals(transferToLocalEnabled, params.isTransferToLocalEnabled());
-            assertEquals(transferReceiverEnabled, params.isMediaTransferReceiverEnabled());
-        } else {
-            // Earlier than Android R, output switcher cannot be enabled.
-            // Same for transfer to local.
-            assertFalse(params.isOutputSwitcherEnabled());
-            assertFalse(params.isTransferToLocalEnabled());
-            assertFalse(params.isMediaTransferReceiverEnabled());
-        }
-
-        extras.remove(TEST_KEY);
-        assertEquals(TEST_VALUE, params.getExtras().getString(TEST_KEY));
-
-        // Tests copy constructor of builder
-        MediaRouterParams copiedParams = new MediaRouterParams.Builder(params).build();
-        assertEquals(params.getDialogType(), copiedParams.getDialogType());
-        assertEquals(params.isOutputSwitcherEnabled(), copiedParams.isOutputSwitcherEnabled());
-        assertEquals(params.isTransferToLocalEnabled(), copiedParams.isTransferToLocalEnabled());
-        assertEquals(params.isMediaTransferReceiverEnabled(),
-                copiedParams.isMediaTransferReceiverEnabled());
-        assertBundleEquals(params.getExtras(), copiedParams.getExtras());
-    }
-
-    @Test
-    @SmallTest
     @UiThreadTest
     public void getRouterParams_afterSetRouterParams_returnsSetParams() {
         final int dialogType = MediaRouterParams.DIALOG_TYPE_DYNAMIC_GROUP;
         final boolean isOutputSwitcherEnabled = true;
         final boolean transferToLocalEnabled = true;
         final boolean transferReceiverEnabled = false;
+        final boolean mediaTransferRestrictedToSelfProviders = true;
         final Bundle paramExtras = new Bundle();
         paramExtras.putString(TEST_KEY, TEST_VALUE);
 
-        MediaRouterParams expectedParams = new MediaRouterParams.Builder()
-                .setDialogType(dialogType)
-                .setOutputSwitcherEnabled(isOutputSwitcherEnabled)
-                .setTransferToLocalEnabled(transferToLocalEnabled)
-                .setMediaTransferReceiverEnabled(transferReceiverEnabled)
-                .setExtras(paramExtras)
-                .build();
+        MediaRouterParams expectedParams =
+                new MediaRouterParams.Builder()
+                        .setDialogType(dialogType)
+                        .setOutputSwitcherEnabled(isOutputSwitcherEnabled)
+                        .setTransferToLocalEnabled(transferToLocalEnabled)
+                        .setMediaTransferReceiverEnabled(transferReceiverEnabled)
+                        .setMediaTransferRestrictedToSelfProviders(
+                                mediaTransferRestrictedToSelfProviders)
+                        .setExtras(paramExtras)
+                        .build();
 
         paramExtras.remove(TEST_KEY);
         mRouter.setRouterParams(expectedParams);
@@ -188,6 +148,29 @@
         assertEquals(expectedParams, actualParams);
     }
 
+    @SmallTest
+    @Test
+    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.R)
+    public void setRouterParams_shouldSetMediaTransferRestrictToSelfProviders() {
+        MediaRouterParams params =
+                new MediaRouterParams.Builder()
+                        .setMediaTransferRestrictedToSelfProviders(true)
+                        .build();
+        getInstrumentation()
+                .runOnMainSync(
+                        () -> {
+                            mRouter.setRouterParams(params);
+                        });
+        assertTrue(
+                MediaRouter.getGlobalRouter()
+                        .mRegisteredProviderWatcher
+                        .isMediaTransferRestrictedToSelfProvidersForTesting());
+        assertTrue(
+                MediaRouter.getGlobalRouter()
+                        .getMediaRoute2ProviderForTesting()
+                        .isMediaTransferRestrictedToSelfProviders());
+    }
+
     @Test
     @LargeTest
     public void testRegisterActiveScanCallback_suppressActiveScanAfter30Seconds() throws Exception {
@@ -289,20 +272,6 @@
         assertFalse(newInstance.getRoutes().isEmpty());
     }
 
-    /**
-     * Asserts that two Bundles are equal.
-     */
-    @SuppressWarnings("deprecation")
-    public static void assertBundleEquals(Bundle expected, Bundle observed) {
-        if (expected == null || observed == null) {
-            assertSame(expected, observed);
-        }
-        assertEquals(expected.size(), observed.size());
-        for (String key : expected.keySet()) {
-            assertEquals(expected.get(key), observed.get(key));
-        }
-    }
-
     private class MediaSessionCallback extends MediaSessionCompat.Callback {
         private boolean mOnPlayCalled;
         private boolean mOnPauseCalled;
@@ -348,7 +317,6 @@
                 }
             }
         }
-
     }
 
     private void resetActiveAndPassiveScanCountDownLatches() {
diff --git a/mediarouter/mediarouter/src/androidTest/java/androidx/mediarouter/media/RegisteredMediaRouteProviderWatcherTest.java b/mediarouter/mediarouter/src/androidTest/java/androidx/mediarouter/media/RegisteredMediaRouteProviderWatcherTest.java
new file mode 100644
index 0000000..a132e52
--- /dev/null
+++ b/mediarouter/mediarouter/src/androidTest/java/androidx/mediarouter/media/RegisteredMediaRouteProviderWatcherTest.java
@@ -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.mediarouter.media;
+
+import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import android.content.Context;
+import android.content.pm.ServiceInfo;
+import android.os.Build;
+import android.text.TextUtils;
+
+import androidx.annotation.NonNull;
+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.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.util.List;
+
+/** Test {@link RegisteredMediaRouteProviderWatcher}. */
+@RunWith(AndroidJUnit4.class)
+public class RegisteredMediaRouteProviderWatcherTest {
+    private Context mContext;
+    private RegisteredMediaRouteProviderWatcher mProviderWatcher;
+
+    @Before
+    public void setUp() {
+        mContext = ApplicationProvider.getApplicationContext();
+        RegisteredMediaRouteProviderWatcher.Callback callback =
+                new RegisteredMediaRouteProviderWatcher.Callback() {
+                    @Override
+                    public void addProvider(@NonNull MediaRouteProvider provider) {}
+
+                    @Override
+                    public void removeProvider(@NonNull MediaRouteProvider provider) {}
+
+                    @Override
+                    public void releaseProviderController(
+                            @NonNull RegisteredMediaRouteProvider provider,
+                            @NonNull MediaRouteProvider.RouteController controller) {}
+                };
+
+        getInstrumentation()
+                .runOnMainSync(
+                        () -> {
+                            mProviderWatcher =
+                                    new RegisteredMediaRouteProviderWatcher(mContext, callback);
+                        });
+    }
+
+    @SmallTest
+    @Test
+    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.R)
+    public void getMediaRoute2ProviderServices_restrictedToSelfProviders_shouldGetSelfProviders() {
+        mProviderWatcher.setMediaTransferRestrictedToSelfProviders(true);
+        assertTrue(mProviderWatcher.isMediaTransferRestrictedToSelfProvidersForTesting());
+        List<ServiceInfo> serviceInfos = mProviderWatcher.getMediaRoute2ProviderServices();
+        assertTrue(isSelfProvidersContained(serviceInfos));
+    }
+
+    @SmallTest
+    @Test
+    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.R)
+    public void getMediaRoute2ProviderServices_notRestrictedToSelfProviders_shouldGetAllProvider() {
+        mProviderWatcher.setMediaTransferRestrictedToSelfProviders(false);
+        assertFalse(mProviderWatcher.isMediaTransferRestrictedToSelfProvidersForTesting());
+        List<ServiceInfo> serviceInfos = mProviderWatcher.getMediaRoute2ProviderServices();
+        assertTrue(isSelfProvidersContained(serviceInfos));
+    }
+
+    private boolean isSelfProvidersContained(List<ServiceInfo> serviceInfos) {
+        for (ServiceInfo serviceInfo : serviceInfos) {
+            if (TextUtils.equals(serviceInfo.packageName, mContext.getPackageName())) {
+                return true;
+            }
+        }
+        return false;
+    }
+}
diff --git a/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/GlobalMediaRouter.java b/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/GlobalMediaRouter.java
index f95695e..f24905e 100644
--- a/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/GlobalMediaRouter.java
+++ b/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/GlobalMediaRouter.java
@@ -285,8 +285,13 @@
                 addProvider(mMr2Provider, /* treatRouteDescriptorIdsAsUnique= */ true);
                 // Make sure mDiscoveryRequestForMr2Provider is updated
                 updateDiscoveryRequest();
-                mRegisteredProviderWatcher.rescan();
             }
+            boolean mediaTransferRestrictedToSelfProviders =
+                    params != null && params.isMediaTransferRestrictedToSelfProviders();
+            mMr2Provider.setMediaTransferRestrictedToSelfProviders(
+                    mediaTransferRestrictedToSelfProviders);
+            mRegisteredProviderWatcher.setMediaTransferRestrictedToSelfProviders(
+                    mediaTransferRestrictedToSelfProviders);
 
             boolean oldTransferToLocalEnabled =
                     oldParams != null && oldParams.isTransferToLocalEnabled();
@@ -1343,9 +1348,13 @@
         }
     }
 
+    @VisibleForTesting
+    /* package */ MediaRoute2Provider getMediaRoute2ProviderForTesting() {
+        return mMr2Provider;
+    }
+
     private final class ProviderCallback extends MediaRouteProvider.Callback {
-        ProviderCallback() {
-        }
+        ProviderCallback() {}
 
         @Override
         public void onDescriptorChanged(
diff --git a/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/MediaRoute2Provider.java b/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/MediaRoute2Provider.java
index eb1009c..654df9b 100644
--- a/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/MediaRoute2Provider.java
+++ b/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/MediaRoute2Provider.java
@@ -47,6 +47,7 @@
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
+import androidx.annotation.VisibleForTesting;
 import androidx.mediarouter.R;
 import androidx.mediarouter.media.MediaRouteProvider.DynamicGroupRouteController.DynamicRouteDescriptor;
 import androidx.mediarouter.media.MediaRouter.ControlRequestCallback;
@@ -67,6 +68,7 @@
 class MediaRoute2Provider extends MediaRouteProvider {
     static final String TAG = "MR2Provider";
     static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG);
+    private static final String PACKAGE_NAME_SEPARATOR = "/";
 
     final MediaRouter2 mMediaRouter2;
     final Callback mCallback;
@@ -77,9 +79,10 @@
     private final MediaRouter2.ControllerCallback mControllerCallback = new ControllerCallback();
     private final Handler mHandler;
     private final Executor mHandlerExecutor;
-
+    private boolean mMediaTransferRestrictedToSelfProviders;
     private List<MediaRoute2Info> mRoutes = new ArrayList<>();
     private Map<String, String> mRouteIdToOriginalRouteIdMap = new ArrayMap<>();
+
     @SuppressWarnings({"SyntheticAccessor"})
     MediaRoute2Provider(@NonNull Context context, @NonNull Callback callback) {
         super(context);
@@ -153,6 +156,17 @@
         return null;
     }
 
+    /* package */ void setMediaTransferRestrictedToSelfProviders(
+            boolean mediaTransferRestrictedToSelfProviders) {
+        mMediaTransferRestrictedToSelfProviders = mediaTransferRestrictedToSelfProviders;
+        refreshRoutes();
+    }
+
+    @VisibleForTesting
+    /* package */ boolean isMediaTransferRestrictedToSelfProviders() {
+        return mMediaTransferRestrictedToSelfProviders;
+    }
+
     public void transferTo(@NonNull String routeId) {
         MediaRoute2Info route = getRouteById(routeId);
         if (route == null) {
@@ -171,6 +185,17 @@
             if (route == null || route2InfoSet.contains(route) || route.isSystemRoute()) {
                 continue;
             }
+
+            if (mMediaTransferRestrictedToSelfProviders) {
+                // The routeId is created by Android framework with the provider's package name.
+                boolean isRoutePublishedBySelfProviders =
+                        route.getId()
+                                .startsWith(getContext().getPackageName() + PACKAGE_NAME_SEPARATOR);
+                if (!isRoutePublishedBySelfProviders) {
+                    continue;
+                }
+            }
+
             route2InfoSet.add(route);
 
             // Not using new ArrayList(route2InfoSet) here for preserving the order.
@@ -374,8 +399,9 @@
     }
 
     abstract static class Callback {
-        public abstract void onSelectRoute(@NonNull String routeDescriptorId,
-                @MediaRouter.UnselectReason int reason);
+        public abstract void onSelectRoute(
+                @NonNull String routeDescriptorId, @MediaRouter.UnselectReason int reason);
+
         public abstract void onSelectFallbackRoute(@MediaRouter.UnselectReason int reason);
 
         public abstract void onReleaseController(@NonNull RouteController controller);
diff --git a/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/MediaRouterParams.java b/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/MediaRouterParams.java
index a799b00..7883ff3 100644
--- a/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/MediaRouterParams.java
+++ b/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/MediaRouterParams.java
@@ -72,11 +72,11 @@
     public static final String EXTRAS_KEY_FIXED_CAST_ICON =
             "androidx.mediarouter.media.MediaRouterParams.FIXED_CAST_ICON";
 
-    @DialogType
-    final int mDialogType;
+    @DialogType final int mDialogType;
     final boolean mMediaTransferReceiverEnabled;
     final boolean mOutputSwitcherEnabled;
     final boolean mTransferToLocalEnabled;
+    final boolean mMediaTransferRestrictedToSelfProviders;
     final Bundle mExtras;
 
     MediaRouterParams(@NonNull Builder builder) {
@@ -84,6 +84,7 @@
         mMediaTransferReceiverEnabled = builder.mMediaTransferEnabled;
         mOutputSwitcherEnabled = builder.mOutputSwitcherEnabled;
         mTransferToLocalEnabled = builder.mTransferToLocalEnabled;
+        mMediaTransferRestrictedToSelfProviders = builder.mMediaTransferRestrictedToSelfProviders;
 
         Bundle extras = builder.mExtras;
         mExtras = extras == null ? Bundle.EMPTY : new Bundle(extras);
@@ -120,8 +121,8 @@
 
     /**
      * Returns whether transferring media from remote to local is enabled.
-     * <p>
-     * Note that it always returns {@code false} for Android versions earlier than Android R.
+     *
+     * <p>Note that it always returns {@code false} for Android versions earlier than Android R.
      *
      * @see Builder#setTransferToLocalEnabled(boolean)
      */
@@ -130,7 +131,16 @@
     }
 
     /**
+     * Returns whether the declared {@link MediaTransferReceiver} feature is restricted to the app's
+     * own media route providers.
+     *
+     * @see Builder#setMediaTransferRestrictedToSelfProviders(boolean)
      */
+    public boolean isMediaTransferRestrictedToSelfProviders() {
+        return mMediaTransferRestrictedToSelfProviders;
+    }
+
+    /** */
     @NonNull
     @RestrictTo(RestrictTo.Scope.LIBRARY)
     public Bundle getExtras() {
@@ -141,21 +151,19 @@
      * Builder class for {@link MediaRouterParams}.
      */
     public static final class Builder {
-        @DialogType
-        int mDialogType = DIALOG_TYPE_DEFAULT;
+        @DialogType int mDialogType = DIALOG_TYPE_DEFAULT;
         boolean mMediaTransferEnabled = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R;
         boolean mOutputSwitcherEnabled;
         boolean mTransferToLocalEnabled;
+        boolean mMediaTransferRestrictedToSelfProviders;
         Bundle mExtras;
 
-        /**
-         * Constructor for builder to create {@link MediaRouterParams}.
-         */
+        /** Constructor for builder to create {@link MediaRouterParams}. */
         public Builder() {}
 
         /**
-         * Constructor for builder to create {@link MediaRouterParams} with existing
-         * {@link MediaRouterParams} instance.
+         * Constructor for builder to create {@link MediaRouterParams} with existing {@link
+         * MediaRouterParams} instance.
          *
          * @param params the existing instance to copy data from.
          */
@@ -168,15 +176,17 @@
             mOutputSwitcherEnabled = params.mOutputSwitcherEnabled;
             mTransferToLocalEnabled = params.mTransferToLocalEnabled;
             mMediaTransferEnabled = params.mMediaTransferReceiverEnabled;
+            mMediaTransferRestrictedToSelfProviders =
+                    params.mMediaTransferRestrictedToSelfProviders;
             mExtras = params.mExtras == null ? null : new Bundle(params.mExtras);
         }
 
         /**
-         * Sets the media route controller dialog type. Default value is
-         * {@link #DIALOG_TYPE_DEFAULT}.
-         * <p>
-         * Note that from Android R, output switcher will be used rather than the dialog type set by
-         * this method if both {@link #setOutputSwitcherEnabled(boolean)} output switcher} and
+         * Sets the media route controller dialog type. Default value is {@link
+         * #DIALOG_TYPE_DEFAULT}.
+         *
+         * <p>Note that from Android R, output switcher will be used rather than the dialog type set
+         * by this method if both {@link #setOutputSwitcherEnabled(boolean)} output switcher} and
          * {@link MediaTransferReceiver media transfer feature} are enabled.
          *
          * @param dialogType the dialog type
@@ -255,9 +265,30 @@
         }
 
         /**
-         * Set extras. Default value is {@link Bundle#EMPTY} if not set.
+         * Sets whether the declared {@link MediaTransferReceiver} feature is restricted to {@link
+         * MediaRouteProviderService} provider services that handle the action {@code
+         * android.media.MediaRoute2ProviderService} declared by this app.
          *
+         * <p>If this app restricts the {@link MediaTransferReceiver} feature to its own {@link
+         * MediaRouteProviderService} provider service that handles the action {@code
+         * android.media.MediaRoute2ProviderService}, then all other media route providers that
+         * declare both the {@code android.media.MediaRouteProviderService} action and the {@code
+         * android.media.MediaRoute2ProviderService} action would be treated as {@link
+         * MediaRouteProviderService} provider services with only the action {@code
+         * android.media.MediaRouteProviderService}.
+         *
+         * <p>For {@link MediaRouteProviderService} provider services that only handle the action
+         * {@code android.media.MediaRouteProviderService}, they are not affected by this flag.
          */
+        @NonNull
+        public Builder setMediaTransferRestrictedToSelfProviders(boolean enabled) {
+            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+                mMediaTransferRestrictedToSelfProviders = enabled;
+            }
+            return this;
+        }
+
+        /** Set extras. Default value is {@link Bundle#EMPTY} if not set. */
         @RestrictTo(RestrictTo.Scope.LIBRARY)
         @NonNull
         public Builder setExtras(@Nullable Bundle extras) {
@@ -265,9 +296,7 @@
             return this;
         }
 
-        /**
-         * Builds the {@link MediaRouterParams} instance.
-         */
+        /** Builds the {@link MediaRouterParams} instance. */
         @NonNull
         public MediaRouterParams build() {
             return new MediaRouterParams(this);
diff --git a/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/RegisteredMediaRouteProviderWatcher.java b/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/RegisteredMediaRouteProviderWatcher.java
index f80c24e0..82c5ca3 100644
--- a/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/RegisteredMediaRouteProviderWatcher.java
+++ b/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/RegisteredMediaRouteProviderWatcher.java
@@ -27,9 +27,11 @@
 import android.media.MediaRoute2ProviderService;
 import android.os.Build;
 import android.os.Handler;
+import android.text.TextUtils;
 
 import androidx.annotation.NonNull;
 import androidx.annotation.RequiresApi;
+import androidx.annotation.VisibleForTesting;
 
 import java.util.ArrayList;
 import java.util.Collections;
@@ -48,6 +50,7 @@
     private final PackageManager mPackageManager;
 
     private final ArrayList<RegisteredMediaRouteProvider> mProviders = new ArrayList<>();
+    private boolean mMediaTransferRestrictedToSelfProviders;
     private boolean mRunning;
 
     RegisteredMediaRouteProviderWatcher(Context context, Callback callback) {
@@ -97,6 +100,17 @@
         }
     }
 
+    /* package */ void setMediaTransferRestrictedToSelfProviders(
+            boolean mediaTransferRestrictedToSelfProviders) {
+        mMediaTransferRestrictedToSelfProviders = mediaTransferRestrictedToSelfProviders;
+        rescan();
+    }
+
+    @VisibleForTesting
+    /* package */ boolean isMediaTransferRestrictedToSelfProvidersForTesting() {
+        return mMediaTransferRestrictedToSelfProviders;
+    }
+
     void scanPackages() {
         if (!mRunning) {
             return;
@@ -171,7 +185,13 @@
 
         List<ServiceInfo> serviceInfoList = new ArrayList<>();
         for (ResolveInfo resolveInfo : mPackageManager.queryIntentServices(intent, 0)) {
-            serviceInfoList.add(resolveInfo.serviceInfo);
+            ServiceInfo serviceInfo = resolveInfo.serviceInfo;
+            if (mMediaTransferRestrictedToSelfProviders
+                    && !TextUtils.equals(mContext.getPackageName(), serviceInfo.packageName)) {
+                // The app only allows its own Media Router provider to be a MediaRoute2 provider.
+                continue;
+            }
+            serviceInfoList.add(serviceInfo);
         }
         return serviceInfoList;
     }
diff --git a/metrics/metrics-benchmark/src/androidTest/java/androidx/metrics/performance/benchmark/JankStatsBenchmark.kt b/metrics/metrics-benchmark/src/androidTest/java/androidx/metrics/performance/benchmark/JankStatsBenchmark.kt
index 74fbd4b..27f3fbf 100644
--- a/metrics/metrics-benchmark/src/androidTest/java/androidx/metrics/performance/benchmark/JankStatsBenchmark.kt
+++ b/metrics/metrics-benchmark/src/androidTest/java/androidx/metrics/performance/benchmark/JankStatsBenchmark.kt
@@ -25,12 +25,12 @@
 import androidx.annotation.RequiresApi
 import androidx.benchmark.junit4.BenchmarkRule
 import androidx.benchmark.junit4.measureRepeated
+import androidx.benchmark.junit4.measureRepeatedOnMainThread
 import androidx.metrics.performance.FrameData
 import androidx.metrics.performance.JankStats
 import androidx.metrics.performance.JankStatsInternalsForTesting
 import androidx.metrics.performance.PerformanceMetricsState
 import androidx.metrics.performance.benchmark.test.R
-import androidx.test.annotation.UiThreadTest
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.LargeTest
 import androidx.test.filters.SdkSuppress
@@ -76,26 +76,25 @@
         }
     }
 
-    @UiThreadTest
     @Test
     fun setNewState() {
         var iteration = 0
-        benchmarkRule.measureRepeated {
+        benchmarkRule.measureRepeatedOnMainThread {
             iteration++
             metricsStateHolder.state?.putState("Activity$iteration", "activity")
         }
     }
 
-    @UiThreadTest
     @Test
     fun setStateOverAndOver() {
-        benchmarkRule.measureRepeated { metricsStateHolder.state?.putState("Activity", "activity") }
+        benchmarkRule.measureRepeatedOnMainThread {
+            metricsStateHolder.state?.putState("Activity", "activity")
+        }
     }
 
-    @UiThreadTest
     @Test
     fun setAndRemoveState() {
-        benchmarkRule.measureRepeated {
+        benchmarkRule.measureRepeatedOnMainThread {
             // Simply calling removeState() on the public API is not sufficient for benchmarking
             // allocations, because it will not actually be removed until later, when JankStats
             // issues data for a frame after the time the state was removed. Thus we call
diff --git a/navigation/integration-tests/testapp/build.gradle b/navigation/integration-tests/testapp/build.gradle
index a3b7ee4..03d2eac 100644
--- a/navigation/integration-tests/testapp/build.gradle
+++ b/navigation/integration-tests/testapp/build.gradle
@@ -45,6 +45,7 @@
 
 android {
     namespace "androidx.navigation.testapp"
+    compileSdk 35
 }
 
 tasks["check"].dependsOn(tasks["connectedCheck"])
diff --git a/navigation/navigation-common/build.gradle b/navigation/navigation-common/build.gradle
index 52382c7..a506467 100644
--- a/navigation/navigation-common/build.gradle
+++ b/navigation/navigation-common/build.gradle
@@ -41,11 +41,11 @@
 
 dependencies {
     api("androidx.annotation:annotation:1.8.1")
-    api("androidx.lifecycle:lifecycle-common:2.6.2")
-    api("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2")
-    api("androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.2")
-    api("androidx.savedstate:savedstate-ktx:1.2.1")
-    api("androidx.lifecycle:lifecycle-viewmodel-savedstate:2.6.2")
+    api("androidx.lifecycle:lifecycle-common:2.9.0-alpha07")
+    api("androidx.lifecycle:lifecycle-runtime-ktx:2.9.0-alpha07")
+    api("androidx.lifecycle:lifecycle-viewmodel-ktx:2.9.0-alpha07")
+    api("androidx.lifecycle:lifecycle-viewmodel-savedstate:2.9.0-alpha07")
+    api("androidx.savedstate:savedstate-ktx:1.3.0-alpha05")
     api(libs.kotlinStdlib)
 
     implementation("androidx.core:core-ktx:1.1.0")
diff --git a/navigation/navigation-common/src/main/java/androidx/navigation/NavGraph.kt b/navigation/navigation-common/src/main/java/androidx/navigation/NavGraph.kt
index 5fbe69d..f1454d9 100644
--- a/navigation/navigation-common/src/main/java/androidx/navigation/NavGraph.kt
+++ b/navigation/navigation-common/src/main/java/androidx/navigation/NavGraph.kt
@@ -238,16 +238,32 @@
      * Searches all children and parents recursively.
      *
      * Does not revisit graphs (whether it's a child or parent) if it has already been visited.
+     *
+     * @param resId the [NavDestination.id]
+     * @param lastVisited the previously visited node
+     * @param searchChildren searches the graph's children for the node when true
+     * @param matchingDest an optional NavDestination that the node should match with. This is
+     *   because [resId] is only unique to a local graph. Nodes in sibling graphs can have the same
+     *   id.
      */
     @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
     public fun findNodeComprehensive(
         @IdRes resId: Int,
         lastVisited: NavDestination?,
-        searchChildren: Boolean
+        searchChildren: Boolean,
+        matchingDest: NavDestination? = null,
     ): NavDestination? {
         // first search direct children
         var destination = nodes[resId]
-        if (destination != null) return destination
+        when {
+            matchingDest != null ->
+                // check parent in case of duplicated destinations to ensure it finds the correct
+                // nested destination
+                if (destination == matchingDest && destination.parent == matchingDest.parent)
+                    return destination
+                else destination = null
+            else -> if (destination != null) return destination
+        }
 
         if (searchChildren) {
             // then dfs through children. Avoid re-visiting children that were recursing up this
@@ -255,7 +271,7 @@
             destination =
                 nodes.valueIterator().asSequence().firstNotNullOfOrNull { child ->
                     if (child is NavGraph && child != lastVisited) {
-                        child.findNodeComprehensive(resId, this, true)
+                        child.findNodeComprehensive(resId, this, true, matchingDest)
                     } else null
                 }
         }
@@ -264,7 +280,7 @@
         // this way.
         return destination
             ?: if (parent != null && parent != lastVisited) {
-                parent!!.findNodeComprehensive(resId, this, searchChildren)
+                parent!!.findNodeComprehensive(resId, this, searchChildren, matchingDest)
             } else null
     }
 
diff --git a/navigation/navigation-runtime/src/androidTest/java/androidx/navigation/NavControllerRouteTest.kt b/navigation/navigation-runtime/src/androidTest/java/androidx/navigation/NavControllerRouteTest.kt
index 22c047f..fb3748f 100644
--- a/navigation/navigation-runtime/src/androidTest/java/androidx/navigation/NavControllerRouteTest.kt
+++ b/navigation/navigation-runtime/src/androidTest/java/androidx/navigation/NavControllerRouteTest.kt
@@ -1076,6 +1076,32 @@
 
     @UiThreadTest
     @Test
+    fun testNavigateNestedDuplicateDestination() {
+        val navController = createNavController()
+        navController.graph =
+            navController.createGraph(route = "root", startDestination = "start") {
+                test("start")
+                navigation(route = "second", startDestination = "duplicate") { test("duplicate") }
+                navigation(route = "duplicate", startDestination = "third") { test("third") }
+            }
+        assertThat(navController.currentDestination?.route).isEqualTo("start")
+
+        navController.navigate("second")
+        assertThat(navController.currentBackStack.value.map { it.destination.route })
+            .containsExactly("root", "start", "second", "duplicate")
+
+        navController.navigate("third")
+        assertThat(navController.currentBackStack.value.map { it.destination.route })
+            .containsExactly("root", "start", "second", "duplicate", "duplicate", "third")
+        val duplicateNode =
+            navController.currentBackStack.value
+                .last { it.destination.route == "duplicate" }
+                .destination
+        assertThat(duplicateNode.parent?.route).isEqualTo("root")
+    }
+
+    @UiThreadTest
+    @Test
     fun testNavigateWithObject() {
         val navController = createNavController()
         navController.graph =
diff --git a/navigation/navigation-runtime/src/main/java/androidx/navigation/NavController.kt b/navigation/navigation-runtime/src/main/java/androidx/navigation/NavController.kt
index 4f43515..8d9d678 100644
--- a/navigation/navigation-runtime/src/main/java/androidx/navigation/NavController.kt
+++ b/navigation/navigation-runtime/src/main/java/androidx/navigation/NavController.kt
@@ -1713,34 +1713,72 @@
             return currentBackStackEntry?.destination
         }
 
-    /** Recursively searches through parents */
+    /**
+     * Recursively searches through parents
+     *
+     * @param destinationId the [NavDestination.id]
+     * @param matchingDest an optional NavDestination that the node should match with. This is
+     *   because [destinationId] is only unique to a local graph. Nodes in sibling graphs can have
+     *   the same id.
+     */
     @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
-    public fun findDestination(@IdRes destinationId: Int): NavDestination? {
+    public fun findDestination(
+        @IdRes destinationId: Int,
+        matchingDest: NavDestination? = null,
+    ): NavDestination? {
         if (_graph == null) {
             return null
         }
+
         if (_graph!!.id == destinationId) {
-            return _graph
+            when {
+                /**
+                 * if the search expected a specific NavDestination (i.e. a duplicated destination
+                 * within a specific graph), we need to make sure the result matches it to ensure
+                 * this search returns the correct duplicate.
+                 */
+                matchingDest != null ->
+                    if (_graph == matchingDest && matchingDest.parent == null) return _graph
+                else -> return _graph
+            }
         }
+
         val currentNode = backQueue.lastOrNull()?.destination ?: _graph!!
-        return currentNode.findDestinationComprehensive(destinationId, false)
+        return currentNode.findDestinationComprehensive(destinationId, false, matchingDest)
     }
 
     /**
      * Recursively searches through parents. If [searchChildren] is true, also recursively searches
      * children.
+     *
+     * @param destinationId the [NavDestination.id]
+     * @param searchChildren recursively searches children when true
+     * @param matchingDest an optional NavDestination that the node should match with. This is
+     *   because [destinationId] is only unique to a local graph. Nodes in sibling graphs can have
+     *   the same id.
      */
     @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
     public fun NavDestination.findDestinationComprehensive(
         @IdRes destinationId: Int,
-        searchChildren: Boolean
+        searchChildren: Boolean,
+        matchingDest: NavDestination? = null,
     ): NavDestination? {
-
         if (id == destinationId) {
-            return this
+            when {
+                // check parent in case of duplicated destinations to ensure it finds the correct
+                // nested destination
+                matchingDest != null ->
+                    if (this == matchingDest && this.parent == matchingDest.parent) return this
+                else -> return this
+            }
         }
         val currentGraph = if (this is NavGraph) this else parent!!
-        return currentGraph.findNodeComprehensive(destinationId, currentGraph, searchChildren)
+        return currentGraph.findNodeComprehensive(
+            destinationId,
+            currentGraph,
+            searchChildren,
+            matchingDest
+        )
     }
 
     /** Recursively searches through parents */
@@ -2352,7 +2390,9 @@
         // equality to ensure that same destinations with a parent that is not this _graph
         // will also have their parents added to the hierarchy.
         destination = if (hierarchy.isEmpty()) newDest else hierarchy.first().destination
-        while (destination != null && findDestination(destination.id) !== destination) {
+        while (
+            destination != null && findDestination(destination.id, destination) !== destination
+        ) {
             val parent = destination.parent
             if (parent != null) {
                 val args = if (finalArgs?.isEmpty == true) null else finalArgs
diff --git a/navigation3/navigation3/api/current.txt b/navigation3/navigation3/api/current.txt
index 17c13b7..b41230e 100644
--- a/navigation3/navigation3/api/current.txt
+++ b/navigation3/navigation3/api/current.txt
@@ -1,6 +1,16 @@
 // Signature format: 4.0
 package androidx.navigation3 {
 
+  public final class AnimatedNavDisplay {
+    method public java.util.Map<java.lang.String,java.lang.Object> isDialog(boolean boolean);
+    method public java.util.Map<java.lang.String,java.lang.Object> transition(androidx.compose.animation.EnterTransition? enter, androidx.compose.animation.ExitTransition? exit);
+    field public static final androidx.navigation3.AnimatedNavDisplay INSTANCE;
+  }
+
+  public final class AnimatedNavDisplay_androidKt {
+    method @androidx.compose.runtime.Composable public static void AnimatedNavDisplay(java.util.List<?> backstack, androidx.navigation3.NavWrapperManager wrapperManager, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.ui.Alignment contentAlignment, optional androidx.compose.animation.SizeTransform? sizeTransform, optional kotlin.jvm.functions.Function0<kotlin.Unit> onBack, kotlin.jvm.functions.Function1<java.lang.Object,androidx.navigation3.Record> recordProvider);
+  }
+
   public interface NavContentWrapper {
     method public default void WrapBackStack(java.util.List<?> backStack);
     method public void WrapContent(androidx.navigation3.Record record);
diff --git a/navigation3/navigation3/api/restricted_current.txt b/navigation3/navigation3/api/restricted_current.txt
index 17c13b7..b41230e 100644
--- a/navigation3/navigation3/api/restricted_current.txt
+++ b/navigation3/navigation3/api/restricted_current.txt
@@ -1,6 +1,16 @@
 // Signature format: 4.0
 package androidx.navigation3 {
 
+  public final class AnimatedNavDisplay {
+    method public java.util.Map<java.lang.String,java.lang.Object> isDialog(boolean boolean);
+    method public java.util.Map<java.lang.String,java.lang.Object> transition(androidx.compose.animation.EnterTransition? enter, androidx.compose.animation.ExitTransition? exit);
+    field public static final androidx.navigation3.AnimatedNavDisplay INSTANCE;
+  }
+
+  public final class AnimatedNavDisplay_androidKt {
+    method @androidx.compose.runtime.Composable public static void AnimatedNavDisplay(java.util.List<?> backstack, androidx.navigation3.NavWrapperManager wrapperManager, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.ui.Alignment contentAlignment, optional androidx.compose.animation.SizeTransform? sizeTransform, optional kotlin.jvm.functions.Function0<kotlin.Unit> onBack, kotlin.jvm.functions.Function1<java.lang.Object,androidx.navigation3.Record> recordProvider);
+  }
+
   public interface NavContentWrapper {
     method public default void WrapBackStack(java.util.List<?> backStack);
     method public void WrapContent(androidx.navigation3.Record record);
diff --git a/navigation3/navigation3/build.gradle b/navigation3/navigation3/build.gradle
index f61edaa..1911ba9 100644
--- a/navigation3/navigation3/build.gradle
+++ b/navigation3/navigation3/build.gradle
@@ -66,6 +66,7 @@
             dependencies {
                 implementation("androidx.activity:activity-compose:1.9.3")
                 implementation("androidx.annotation:annotation:1.8.0")
+                implementation("androidx.compose.animation:animation:1.7.5")
                 implementation("androidx.compose.foundation:foundation:1.7.5")
                 implementation("androidx.compose.ui:ui:1.7.5")
                 implementation("androidx.lifecycle:lifecycle-runtime:2.8.7")
diff --git a/navigation3/navigation3/samples/build.gradle b/navigation3/navigation3/samples/build.gradle
index b2b8be8..8e42ee7 100644
--- a/navigation3/navigation3/samples/build.gradle
+++ b/navigation3/navigation3/samples/build.gradle
@@ -37,6 +37,7 @@
     compileOnly(project(":annotation:annotation-sampled"))
 
     implementation(libs.kotlinStdlib)
+    implementation("androidx.compose.animation:animation:1.7.5")
     implementation("androidx.compose.foundation:foundation:1.7.5")
     implementation("androidx.compose.foundation:foundation-layout:1.7.5")
     implementation("androidx.compose.material:material:1.7.5")
diff --git a/navigation3/navigation3/samples/src/main/kotlin/androidx/navigation3/samples/NavDisplaySamples.kt b/navigation3/navigation3/samples/src/main/kotlin/androidx/navigation3/samples/NavDisplaySamples.kt
index f7adfa2..a6364e4 100644
--- a/navigation3/navigation3/samples/src/main/kotlin/androidx/navigation3/samples/NavDisplaySamples.kt
+++ b/navigation3/navigation3/samples/src/main/kotlin/androidx/navigation3/samples/NavDisplaySamples.kt
@@ -17,11 +17,14 @@
 package androidx.navigation3.samples
 
 import androidx.annotation.Sampled
+import androidx.compose.animation.slideInHorizontally
+import androidx.compose.animation.slideOutHorizontally
 import androidx.compose.material3.Text
 import androidx.compose.runtime.Composable
 import androidx.lifecycle.ViewModel
 import androidx.lifecycle.viewmodel.compose.viewModel
 import androidx.lifecycle.viewmodel.navigation3.ViewModelStoreNavContentWrapper
+import androidx.navigation3.AnimatedNavDisplay
 import androidx.navigation3.NavDisplay
 import androidx.navigation3.Record
 import androidx.navigation3.SavedStateNavContentWrapper
@@ -71,3 +74,64 @@
 class ProfileViewModel : ViewModel() {
     val name = "no user"
 }
+
+@Sampled
+@Composable
+fun AnimatedNav() {
+    val backStack = rememberMutableStateListOf(Profile)
+    val manager =
+        rememberNavWrapperManager(
+            listOf(SavedStateNavContentWrapper, ViewModelStoreNavContentWrapper)
+        )
+    AnimatedNavDisplay(
+        backstack = backStack,
+        wrapperManager = manager,
+        onBack = { backStack.removeLast() },
+    ) { key ->
+        when (key) {
+            Profile -> {
+                Record(
+                    Profile,
+                    AnimatedNavDisplay.transition(
+                        slideInHorizontally { it },
+                        slideOutHorizontally { it }
+                    )
+                ) {
+                    val viewModel = viewModel<ProfileViewModel>()
+                    Profile(viewModel, { backStack.add(it) }) { backStack.removeLast() }
+                }
+            }
+            Scrollable -> {
+                Record(
+                    Scrollable,
+                    AnimatedNavDisplay.transition(
+                        slideInHorizontally { it },
+                        slideOutHorizontally { it }
+                    )
+                ) {
+                    Scrollable({ backStack.add(it) }) { backStack.removeLast() }
+                }
+            }
+            Dialog -> {
+                Record(Dialog, featureMap = NavDisplay.isDialog(true)) {
+                    DialogContent { backStack.removeLast() }
+                }
+            }
+            Dashboard -> {
+                Record(
+                    Dashboard,
+                    AnimatedNavDisplay.transition(
+                        slideInHorizontally { it },
+                        slideOutHorizontally { it }
+                    )
+                ) { dashboardArgs ->
+                    val userId = (dashboardArgs as Dashboard).userId
+                    Dashboard(userId, onBack = { backStack.removeLast() })
+                }
+            }
+            else -> {
+                Record(Unit) { Text(text = "Invalid Key") }
+            }
+        }
+    }
+}
diff --git a/navigation3/navigation3/src/androidInstrumentedTest/kotlin/androidx/navigation3/AnimatedNavDisplayTest.kt b/navigation3/navigation3/src/androidInstrumentedTest/kotlin/androidx/navigation3/AnimatedNavDisplayTest.kt
new file mode 100644
index 0000000..bd42647
--- /dev/null
+++ b/navigation3/navigation3/src/androidInstrumentedTest/kotlin/androidx/navigation3/AnimatedNavDisplayTest.kt
@@ -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 androidx.navigation3
+
+import androidx.compose.animation.core.AnimationConstants.DefaultDurationMillis
+import androidx.compose.material3.Text
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.test.isDisplayed
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithText
+import androidx.kruth.assertThat
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.LargeTest
+import kotlin.test.Test
+import org.junit.Rule
+import org.junit.runner.RunWith
+
+@LargeTest
+@RunWith(AndroidJUnit4::class)
+class AnimatedNavDisplayTest {
+    @get:Rule val composeTestRule = createComposeRule()
+
+    @Test
+    fun testNavHostAnimations() {
+        lateinit var backstack: MutableList<Any>
+
+        composeTestRule.mainClock.autoAdvance = false
+
+        composeTestRule.setContent {
+            backstack = remember { mutableStateListOf(first) }
+            val manager = rememberNavWrapperManager(emptyList())
+            AnimatedNavDisplay(backstack, wrapperManager = manager) {
+                when (it) {
+                    first -> Record(first) { Text(first) }
+                    second -> Record(second) { Text(second) }
+                    else -> error("Invalid key passed")
+                }
+            }
+        }
+
+        composeTestRule.mainClock.autoAdvance = true
+
+        composeTestRule.waitForIdle()
+        assertThat(composeTestRule.onNodeWithText(first).isDisplayed()).isTrue()
+
+        composeTestRule.mainClock.autoAdvance = false
+
+        composeTestRule.runOnIdle { backstack.add(second) }
+
+        // advance half way between animations
+        composeTestRule.mainClock.advanceTimeBy(DefaultDurationMillis.toLong() / 2)
+
+        composeTestRule.waitForIdle()
+        composeTestRule.onNodeWithText(first).assertExists()
+        composeTestRule.onNodeWithText(second).assertExists()
+
+        composeTestRule.mainClock.autoAdvance = true
+
+        composeTestRule.waitForIdle()
+        composeTestRule.onNodeWithText(first).assertDoesNotExist()
+        composeTestRule.onNodeWithText(second).assertExists()
+    }
+}
+
+private const val first = "first"
+private const val second = "second"
diff --git a/navigation3/navigation3/src/androidMain/kotlin/androidx/navigation3/AnimatedNavDisplay.android.kt b/navigation3/navigation3/src/androidMain/kotlin/androidx/navigation3/AnimatedNavDisplay.android.kt
new file mode 100644
index 0000000..4699ac1
--- /dev/null
+++ b/navigation3/navigation3/src/androidMain/kotlin/androidx/navigation3/AnimatedNavDisplay.android.kt
@@ -0,0 +1,125 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package androidx.navigation3
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.ContentTransform
+import androidx.compose.animation.EnterTransition
+import androidx.compose.animation.ExitTransition
+import androidx.compose.animation.SizeTransform
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.core.updateTransition
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.window.Dialog
+
+/** Object that indicates the features that can be handled by the [AnimatedNavDisplay] */
+public object AnimatedNavDisplay {
+    /**
+     * Function to be called on the [Record.featureMap] to notify the [AnimatedNavDisplay] that the
+     * content should be animated using the provided transitions.
+     */
+    public fun transition(enter: EnterTransition?, exit: ExitTransition?): Map<String, Any> =
+        if (enter == null || exit == null) emptyMap()
+        else mapOf(ENTER_TRANSITION_KEY to enter, EXIT_TRANSITION_KEY to exit)
+
+    /**
+     * Function to be called on the [Record.featureMap] to notify the [NavDisplay] that the content
+     * should be displayed inside of a [Dialog]
+     */
+    public fun isDialog(boolean: Boolean): Map<String, Any> =
+        if (!boolean) emptyMap() else mapOf(DIALOG_KEY to true)
+
+    internal const val ENTER_TRANSITION_KEY = "enterTransition"
+    internal const val EXIT_TRANSITION_KEY = "exitTransition"
+    internal const val DIALOG_KEY = "dialog"
+}
+
+/**
+ * Display for Composable content that displays a single pane of content at a time, but can move
+ * that content in and out with customized transitions.
+ *
+ * The AnimatedNavDisplay displays the content associated with the last key on the back stack in
+ * most circumstances. If that content wants to be displayed as a dialog, as communicated by adding
+ * [NavDisplay.isDialog] to a [Record.featureMap], then the last key's content is a dialog and the
+ * second to last key is a displayed in the background.
+ *
+ * @param backstack the collection of keys that represents the state that needs to be handled
+ * @param wrapperManager the manager that combines all of the [NavContentWrapper]s
+ * @param modifier the modifier to be applied to the layout.
+ * @param contentAlignment The [Alignment] of the [AnimatedContent]
+ * @param onBack a callback for handling system back presses
+ * @param recordProvider lambda used to construct each possible [Record]
+ * @sample androidx.navigation3.samples.AnimatedNav
+ */
+@Composable
+public fun AnimatedNavDisplay(
+    backstack: List<Any>,
+    wrapperManager: NavWrapperManager,
+    modifier: Modifier = Modifier,
+    contentAlignment: Alignment = Alignment.TopStart,
+    sizeTransform: SizeTransform? = null,
+    onBack: () -> Unit = {},
+    recordProvider: (key: Any) -> Record
+) {
+    BackHandler(backstack.size > 1, onBack)
+    wrapperManager.PrepareBackStack(backStack = backstack)
+    val key = backstack.last()
+    val record = recordProvider.invoke(key)
+
+    // Incoming record defines transitions, otherwise it defaults to a fade
+    val enterTransition =
+        record.featureMap[AnimatedNavDisplay.ENTER_TRANSITION_KEY] as? EnterTransition
+            ?: fadeIn(animationSpec = tween(700))
+    val exitTransition =
+        record.featureMap[AnimatedNavDisplay.EXIT_TRANSITION_KEY] as? ExitTransition
+            ?: fadeOut(animationSpec = tween(700))
+
+    // if there is a dialog, we should create a transition with the next to last entry instead.
+    val transition =
+        if (record.featureMap[AnimatedNavDisplay.DIALOG_KEY] == true) {
+            if (backstack.size > 1) {
+                val previousKey = backstack[backstack.size - 2]
+                updateTransition(targetState = previousKey, label = previousKey.toString())
+            } else {
+                null
+            }
+        } else {
+            updateTransition(targetState = key, label = key.toString())
+        }
+
+    transition?.AnimatedContent(
+        modifier = modifier,
+        transitionSpec = {
+            ContentTransform(
+                targetContentEnter = enterTransition,
+                initialContentExit = exitTransition,
+                sizeTransform = sizeTransform
+            )
+        },
+        contentAlignment = contentAlignment
+    ) { innerKey ->
+        wrapperManager.ContentForRecord(recordProvider.invoke(innerKey))
+    }
+
+    if (record.featureMap[AnimatedNavDisplay.DIALOG_KEY] == true) {
+        Dialog(onBack) { wrapperManager.ContentForRecord(record) }
+    }
+}
diff --git a/pdf/integration-tests/testapp/src/androidTest/kotlin/androidx/pdf/PdfViewerFragmentTestSuite.kt b/pdf/integration-tests/testapp/src/androidTest/kotlin/androidx/pdf/PdfViewerFragmentTestSuite.kt
index 9d4d1fe..1469558 100644
--- a/pdf/integration-tests/testapp/src/androidTest/kotlin/androidx/pdf/PdfViewerFragmentTestSuite.kt
+++ b/pdf/integration-tests/testapp/src/androidTest/kotlin/androidx/pdf/PdfViewerFragmentTestSuite.kt
@@ -16,10 +16,8 @@
 
 package androidx.pdf
 
-import android.annotation.SuppressLint
 import android.content.pm.ActivityInfo
 import android.os.Build
-import android.view.KeyEvent
 import androidx.annotation.RequiresExtension
 import androidx.fragment.app.testing.FragmentScenario
 import androidx.fragment.app.testing.launchFragmentInContainer
@@ -31,7 +29,6 @@
 import androidx.test.espresso.IdlingRegistry
 import androidx.test.espresso.action.ViewActions.click
 import androidx.test.espresso.action.ViewActions.longClick
-import androidx.test.espresso.action.ViewActions.pressKey
 import androidx.test.espresso.action.ViewActions.swipeDown
 import androidx.test.espresso.action.ViewActions.swipeUp
 import androidx.test.espresso.action.ViewActions.typeText
@@ -50,7 +47,6 @@
 import org.junit.Test
 import org.junit.runner.RunWith
 
-@SuppressLint("BanThreadSleep")
 @LargeTest
 @RunWith(AndroidJUnit4::class)
 @SdkSuppress(minSdkVersion = 35)
@@ -239,38 +235,6 @@
             .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.GONE)))
     }
 
-    fun testPdfViewerFragment_setDocumentUri_passwordProtected_portrait() {
-        val scenario =
-            scenarioLoadDocument(
-                TEST_PROTECTED_DOCUMENT_FILE,
-                Lifecycle.State.RESUMED,
-                ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
-            )
-
-        // Delay required for password dialog to come up
-        // TODO: Implement callback based delay and remove Thread.sleep
-        Thread.sleep(DELAY_TIME_MS)
-        onView(withId(R.id.password)).perform(typeText(PROTECTED_DOCUMENT_PASSWORD))
-        onView(withId(R.id.password)).perform(pressKey(KeyEvent.KEYCODE_ENTER))
-
-        // Delay required for the PDF to load
-        // TODO: Implement callback based delay and remove Thread.sleep
-        Thread.sleep(DELAY_TIME_MS)
-        onView(withId(R.id.loadingView))
-            .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.GONE)))
-        scenario.onFragment {
-            Preconditions.checkArgument(
-                it.documentLoaded,
-                "Unable to load document due to ${it.documentError?.message}"
-            )
-        }
-
-        // Swipe actions
-        onView(withId(R.id.parent_pdf_container)).perform(swipeUp())
-        onView(withId(R.id.parent_pdf_container)).perform(swipeDown())
-        scenario.close()
-    }
-
     @Test
     fun testPdfViewerFragment_onLoadDocumentError_corruptPdf() {
         val scenario =
diff --git a/pdf/pdf-viewer-fragment/src/main/java/androidx/pdf/viewer/fragment/PdfViewerFragment.kt b/pdf/pdf-viewer-fragment/src/main/java/androidx/pdf/viewer/fragment/PdfViewerFragment.kt
index f5a5059..11a7754a 100644
--- a/pdf/pdf-viewer-fragment/src/main/java/androidx/pdf/viewer/fragment/PdfViewerFragment.kt
+++ b/pdf/pdf-viewer-fragment/src/main/java/androidx/pdf/viewer/fragment/PdfViewerFragment.kt
@@ -339,7 +339,6 @@
                 fastScrollView = fastScrollView!!,
                 zoomView = zoomView!!,
                 paginatedView = paginatedView!!,
-                loadingView = loadingView!!,
                 findInFileView = findInFileView!!,
                 isTextSearchActive = isTextSearchActive,
                 viewState = viewState,
@@ -363,6 +362,9 @@
                             onRequestImmersiveMode(false)
                         }
                     }
+
+                    hideSpinner()
+                    showPdfView()
                 },
                 onDocumentLoadFailure = { exception, showErrorView ->
                     // Update state to reflect document load failure.
@@ -430,6 +432,14 @@
         delayedContentsAvailable?.run()
         super.onStart()
         started = true
+
+        // Check if the document file exists, return early if not
+        documentUri?.let {
+            val fileExist = checkAndFetchFile(it, false)
+            if (!fileExist) {
+                return
+            }
+        }
         if (delayedEnter || onScreen) {
             onEnter()
             delayedEnter = false
@@ -833,6 +843,16 @@
             }
     }
 
+    private fun resetViewsAndModels(fileUri: Uri) {
+        if (pdfLoader != null) {
+            pdfLoaderCallbacks?.uri = fileUri
+            paginatedView?.resetModels()
+            destroyContentModel()
+        }
+        fastScrollView?.resetContents()
+        findInFileView?.resetFindInFile()
+    }
+
     private fun loadFile(fileUri: Uri) {
         // Early return if fragment is not in STARTED state
         if (!lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
@@ -848,38 +868,56 @@
                 putParcelable(KEY_DOCUMENT_URI, fileUri)
                 putBoolean(KEY_TEXT_SEARCH_ACTIVE, false)
             }
-        if (pdfLoader != null) {
-            pdfLoaderCallbacks?.uri = fileUri
-            paginatedView?.resetModels()
-            destroyContentModel()
-        }
+
+        // Reset UI components and models before loading the file
+        resetViewsAndModels(fileUri)
         detachViewsAndObservers()
-        fastScrollView?.resetContents()
-        findInFileView?.resetFindInFile()
-        try {
-            validateFileUri(fileUri)
-            fetchFile(fileUri)
-        } catch (error: Exception) {
-            when (error) {
-                is IOException,
-                is SecurityException,
-                is NullPointerException -> handleError(error)
-                else -> throw error
-            }
-        }
+
+        // Validate the file URI and attempt to load the file contents
+        checkAndFetchFile(fileUri, true)
+
         if (localUri != null && localUri != fileUri) {
             onRequestImmersiveMode(true)
         }
         localUri = fileUri
     }
 
+    private fun checkAndFetchFile(fileUri: Uri, performLoad: Boolean): Boolean {
+        try {
+            validateFileUri(fileUri)
+            fetchFile(fileUri, performLoad)
+            return true
+        } catch (error: Exception) {
+            when (error) {
+                is IOException,
+                is SecurityException,
+                is NullPointerException -> handleFileNotAvailable(fileUri, error)
+                else -> {
+                    throw error
+                }
+            }
+            return false
+        }
+    }
+
+    private fun handleFileNotAvailable(fileUri: Uri, error: Throwable) {
+        // Reset views and models when file error occurs
+        resetViewsAndModels(fileUri)
+
+        // Hide fast scroll and show loading view to display error message
+        fastScrollView?.visibility = View.GONE
+        loadingView?.visibility = View.VISIBLE
+
+        handleError(error)
+    }
+
     private fun validateFileUri(fileUri: Uri) {
         if (!Uris.isContentUri(fileUri) && !Uris.isFileUri(fileUri)) {
             throw IllegalArgumentException("Only content and file uri is supported")
         }
     }
 
-    private fun fetchFile(fileUri: Uri) {
+    private fun fetchFile(fileUri: Uri, performLoad: Boolean) {
         Preconditions.checkNotNull(fileUri)
         val fileName: String = getFileName(fileUri)
         val openable: FutureValue<Openable> = fetcher?.loadLocal(fileUri)!!
@@ -887,7 +925,10 @@
         openable[
             object : FutureValue.Callback<Openable> {
                 override fun available(value: Openable) {
-                    viewerAvailable(fileUri, fileName, value)
+                    // If loading is required, notify the viewer with the available content.
+                    if (performLoad) {
+                        viewerAvailable(fileUri, fileName, value)
+                    }
                 }
 
                 override fun failed(thrown: Throwable) {
@@ -951,10 +992,23 @@
         intent.setData(localUri)
         intent.putExtra(EXTRA_PDF_FILE_NAME, getFileName(localUri!!))
         // TODO: Pass current page number to restore it in edit mode.
-        intent.putExtra(EXTRA_STARTING_PAGE, 0)
+        intent.putExtra(EXTRA_STARTING_PAGE, getStartingPageNumber())
         startActivity(intent)
     }
 
+    private fun getStartingPageNumber(): Int {
+        // Return the page that is centered in the view.
+        return paginationModel?.midPage ?: 0
+    }
+
+    private fun hideSpinner() {
+        loadingView?.visibility = View.GONE
+    }
+
+    private fun showPdfView() {
+        fastScrollView?.visibility = View.VISIBLE
+    }
+
     private companion object {
         /** Key for saving page layout reach in bundles. */
         private const val KEY_LAYOUT_REACH: String = "plr"
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 b0ba835..4a544c5 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
@@ -22,20 +22,20 @@
 import android.view.LayoutInflater
 import android.view.View
 import android.view.ViewGroup
-import android.widget.FrameLayout
+import android.view.ViewGroup.LayoutParams
 import androidx.annotation.RestrictTo
 import androidx.fragment.app.Fragment
 import androidx.fragment.app.viewModels
 import androidx.lifecycle.lifecycleScope
-import androidx.pdf.R
 import androidx.pdf.exceptions.PdfPasswordException
+import androidx.pdf.view.PdfView
 import kotlinx.coroutines.launch
 
 @RestrictTo(RestrictTo.Scope.LIBRARY)
 public class PdfViewerFragmentV2 : Fragment() {
     private val documentViewModel: PdfDocumentViewModel by
         viewModels() { PdfDocumentViewModel.Factory }
-    private lateinit var pdfViewer: FrameLayout
+    private lateinit var pdfView: PdfView
 
     public var documentUri: Uri? = null
         set(value) {
@@ -48,8 +48,11 @@
         container: ViewGroup?,
         savedInstanceState: Bundle?
     ): View? {
-        pdfViewer = inflater.inflate(R.layout.pdf_viewer_container, container, false) as FrameLayout
-        return pdfViewer
+        pdfView =
+            PdfView(requireContext()).apply {
+                layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
+            }
+        return pdfView
     }
 
     override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
@@ -60,6 +63,7 @@
                 if (result != null) {
                     if (result.isSuccess) {
                         // Document loaded
+                        pdfView.pdfDocument = result.getOrNull()
                         Log.i("DDDDD", "Document has been loaded successfully")
                     } else if (result.exceptionOrNull() is PdfPasswordException) {
                         // Display password prompt
diff --git a/pdf/pdf-viewer/build.gradle b/pdf/pdf-viewer/build.gradle
index 7ed8998..c40df24 100644
--- a/pdf/pdf-viewer/build.gradle
+++ b/pdf/pdf-viewer/build.gradle
@@ -56,6 +56,8 @@
     androidTestImplementation(libs.dexmakerMockito)
     androidTestImplementation(libs.truth)
     androidTestImplementation(libs.espressoCore)
+    androidTestImplementation(libs.kotlinCoroutinesTest)
+    androidTestImplementation(libs.mockitoKotlin4)
 }
 
 android {
diff --git a/pdf/pdf-viewer/src/androidTest/AndroidManifest.xml b/pdf/pdf-viewer/src/androidTest/AndroidManifest.xml
index fc250fb..d2d4b0d 100644
--- a/pdf/pdf-viewer/src/androidTest/AndroidManifest.xml
+++ b/pdf/pdf-viewer/src/androidTest/AndroidManifest.xml
@@ -21,5 +21,9 @@
             android:name="androidx.pdf.TestActivity"
             android:exported="false"
             android:theme="@style/Theme.Material3.DynamicColors.DayNight"/>
+        <activity
+            android:name="androidx.pdf.view.PdfViewTestActivity"
+            android:exported="false"
+            android:theme="@style/Theme.Material3.DynamicColors.DayNight"/>
     </application>
 </manifest>
\ No newline at end of file
diff --git a/pdf/pdf-viewer/src/androidTest/java/androidx/pdf/viewer/loader/PdfLoaderTest.java b/pdf/pdf-viewer/src/androidTest/java/androidx/pdf/viewer/loader/PdfLoaderTest.java
index bdba879..30461c7 100644
--- a/pdf/pdf-viewer/src/androidTest/java/androidx/pdf/viewer/loader/PdfLoaderTest.java
+++ b/pdf/pdf-viewer/src/androidTest/java/androidx/pdf/viewer/loader/PdfLoaderTest.java
@@ -59,6 +59,7 @@
 import com.google.common.base.Objects;
 import com.google.common.collect.Iterables;
 
+import org.junit.After;
 import org.junit.Before;
 import org.junit.Ignore;
 import org.junit.Test;
@@ -105,10 +106,11 @@
 
     /** {@link PdfTaskExecutor} waits 10 seconds if it doesn't have any tasks, so we use 12. */
     private static final int LATCH_TIMEOUT_MS = 12000;
+    private AutoCloseable mCloseable;
 
     @Before
     public void setUp() throws Exception {
-        MockitoAnnotations.initMocks(this);
+        mCloseable = MockitoAnnotations.openMocks(this);
         mContext = ApplicationProvider.getApplicationContext();
 
         when(mConnection.isLoaded()).thenReturn(true);
@@ -132,6 +134,15 @@
         mFileOutputStream = new FileOutputStream(file);
     }
 
+    @After
+    public void cleanUp() {
+        try {
+            mCloseable.close();
+        } catch (Exception e) {
+            // No-op
+        }
+    }
+
     @Test
     @UiThreadTest
     public void testLoadDimensions() {
diff --git a/pdf/pdf-viewer/src/androidTest/java/androidx/pdf/viewer/loader/PdfTaskExecutorTest.java b/pdf/pdf-viewer/src/androidTest/java/androidx/pdf/viewer/loader/PdfTaskExecutorTest.java
index fe945ea..c131023 100644
--- a/pdf/pdf-viewer/src/androidTest/java/androidx/pdf/viewer/loader/PdfTaskExecutorTest.java
+++ b/pdf/pdf-viewer/src/androidTest/java/androidx/pdf/viewer/loader/PdfTaskExecutorTest.java
@@ -29,6 +29,7 @@
 import androidx.test.ext.junit.runners.AndroidJUnit4;
 import androidx.test.filters.MediumTest;
 
+import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
@@ -58,10 +59,11 @@
 
     private CountDownLatch mStartTaskLatch;
     private CountDownLatch mFinishedTaskLatch;
+    private AutoCloseable mCloseable;
 
     @Before
     public void setUp() {
-        MockitoAnnotations.initMocks(this);
+        mCloseable = MockitoAnnotations.openMocks(this);
         when(mPdfLoader.getCallbacks()).thenReturn(mCallbacks);
         when(mPdfLoader.getLoadedPdfDocument(isA(String.class))).thenReturn(mPdfDocument);
         mExecutor = new PdfTaskExecutor();
@@ -71,6 +73,15 @@
         mFinishedTaskResults = new ArrayList<>();
     }
 
+    @After
+    public void cleanUp() {
+        try {
+            mCloseable.close();
+        } catch (Exception e) {
+            // No-op
+        }
+    }
+
     @Test
     public void testSchedule() throws Exception {
         mFinishedTaskLatch = new CountDownLatch(6);
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
new file mode 100644
index 0000000..5cd2d49
--- /dev/null
+++ b/pdf/pdf-viewer/src/androidTest/kotlin/androidx/pdf/view/FakePdfDocument.kt
@@ -0,0 +1,243 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR 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.Color
+import android.graphics.Point
+import android.graphics.PointF
+import android.graphics.Rect
+import android.net.Uri
+import android.os.Build
+import android.util.Size
+import android.util.SparseArray
+import androidx.annotation.OpenForTesting
+import androidx.annotation.RequiresExtension
+import androidx.pdf.PdfDocument
+import androidx.pdf.content.PageMatchBounds
+import androidx.pdf.content.PageSelection
+import androidx.pdf.content.SelectionBoundary
+import kotlin.random.Random
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.withContext
+import kotlinx.coroutines.withTimeout
+
+/**
+ * Fake implementation of [PdfDocument], for testing
+ *
+ * Provides an implementation of [getPageInfo] and [getPageInfos] that produces the dimensions
+ * provided as [pages]. Provides an implementation of [getPageBitmapSource] that produces a random
+ * solid RGB color bitmap for each page in [pages]. All other methods are fulfilled with no-op
+ * implementations that return empty values.
+ *
+ * Requests made against an instance can be tracked:
+ * - Using [layoutReach] to detect the maximum page whose dimensions have been requested
+ * - Using [renderReach] to detect the maximum page for which any bitmap has been requested from the
+ *   corresponding [PdfDocument.BitmapSource]
+ * - Using [bitmapRequests] to examine the type of bitmaps that have been requested for any page
+ *
+ * @param pages a list of [Point] defining the number of pages in the fake PDF and their dimensions
+ * @param formType one of [PDF_FORM_TYPE_ACRO_FORM], [PDF_FORM_TYPE_XFA_FULL],
+ *   [PDF_FORM_TYPE_XFA_FOREGROUND], or [PDF_FORM_TYPE_NONE] depending on the type of PDF form this
+ *   fake PDF should represent
+ * @param isLinearized true if this fake PDF is linearized
+ */
+@OpenForTesting
+internal open class FakePdfDocument(
+    /** A list of (x, y) page dimensions in content coordinates */
+    private val pages: List<Point>,
+    override val formType: Int = PDF_FORM_TYPE_NONE,
+    override val isLinearized: Boolean = false,
+) : PdfDocument {
+    override val pageCount: Int = pages.size
+
+    override val uri: Uri
+        get() = Uri.parse("content://test.app/document.pdf")
+
+    @get:Synchronized @set:Synchronized internal var layoutReach: Int = 0
+
+    @get:Synchronized @set:Synchronized internal var renderReach: Int = 0
+
+    private val bitmapRequestsLock = Object()
+    private val _bitmapRequests = mutableMapOf<Int, SizeParams>()
+    internal val bitmapRequests
+        get() = _bitmapRequests
+
+    override fun getPageBitmapSource(pageNumber: Int): PdfDocument.BitmapSource {
+        return FakeBitmapSource(pageNumber)
+    }
+
+    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())
+    }
+
+    @RequiresExtension(extension = Build.VERSION_CODES.S, version = 13)
+    override suspend fun getPageContent(pageNumber: Int): PdfDocument.PdfPageContent {
+        // TODO(b/376136746) provide a useful implementation when it's needed for testing
+        return PdfDocument.PdfPageContent(listOf(), listOf())
+    }
+
+    @RequiresExtension(extension = Build.VERSION_CODES.S, version = 13)
+    override suspend fun getSelectionBounds(
+        pageNumber: Int,
+        start: PointF,
+        stop: PointF
+    ): PageSelection {
+        // TODO(b/376136631) provide a useful implementation when it's needed for testing
+        return PageSelection(0, SelectionBoundary(0), SelectionBoundary(0), listOf())
+    }
+
+    override suspend fun searchDocument(
+        query: String,
+        pageRange: IntRange
+    ): SparseArray<List<PageMatchBounds>> {
+        // TODO - provide a useful implementation when it's needed for testing
+        return SparseArray()
+    }
+
+    override suspend fun getPageInfos(pageRange: IntRange): List<PdfDocument.PageInfo> {
+        return pageRange.map { getPageInfo(it) }
+    }
+
+    override suspend fun getPageInfo(pageNumber: Int): PdfDocument.PageInfo {
+        layoutReach = maxOf(pageNumber, layoutReach)
+        val size = pages[pageNumber]
+        return PdfDocument.PageInfo(pageNumber, size.y, size.x)
+    }
+
+    override fun close() {
+        // No-op, fake
+    }
+
+    /**
+     * A fake [PdfDocument.BitmapSource] that produces random RGB [Bitmap]s of the requested size
+     */
+    private inner class FakeBitmapSource(override val pageNumber: Int) : PdfDocument.BitmapSource {
+
+        override suspend fun getBitmap(scaledPageSizePx: Size, tileRegion: Rect?): Bitmap {
+            renderReach = maxOf(renderReach, pageNumber)
+            logRequest(scaledPageSizePx, tileRegion)
+            // Generate a solid random RGB bitmap at the requested size
+            val size =
+                if (tileRegion != null) Size(tileRegion.width(), tileRegion.height())
+                else scaledPageSizePx
+            val bitmap = Bitmap.createBitmap(size.width, size.height, Bitmap.Config.ARGB_8888)
+            bitmap.apply {
+                val colorRng = Random(System.currentTimeMillis())
+                eraseColor(
+                    Color.argb(
+                        255,
+                        colorRng.nextInt(256),
+                        colorRng.nextInt(256),
+                        colorRng.nextInt(256)
+                    )
+                )
+            }
+            return bitmap
+        }
+
+        /**
+         * Logs the nature of a bitmap request to [bitmapRequests], so that testing code can examine
+         * the total set of bitmap requests observed during a test
+         */
+        private fun logRequest(scaledPageSizePx: Size, tileRegion: Rect?) {
+            synchronized(bitmapRequestsLock) {
+                val requestedSize = _bitmapRequests[pageNumber]
+                // Not tiling, log a full bitmap request
+                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) {
+                    requestedSize.withTile(tileRegion)
+                    // Tiling, and this is the first rect requested
+                } else {
+                    _bitmapRequests[pageNumber] =
+                        TileBoard(scaledPageSizePx).apply { withTile(tileRegion) }
+                }
+            }
+        }
+
+        override fun close() {
+            /* No-op, fake */
+        }
+    }
+}
+
+/** Represents the size and scale of a [Bitmap] requested from [PdfDocument.BitmapSource] */
+internal sealed class SizeParams(val scaledPageSizePx: Size)
+
+/** Represents a full page [Bitmap] requested from [PdfDocument.BitmapSource] */
+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) {
+    private val _tiles = mutableListOf<Rect>()
+    val tiles: List<Rect>
+        get() = _tiles
+
+    fun withTile(region: Rect) = _tiles.add(region)
+}
+
+// Duplicated from PdfRenderer to avoid a hard dependency on SDK 35
+/** Represents a PDF with no form fields */
+internal const val PDF_FORM_TYPE_NONE = 0
+
+/** Represents a PDF with form fields specified using the AcroForm spec */
+internal const val PDF_FORM_TYPE_ACRO_FORM = 1
+
+/** Represents a PDF with form fields specified using the entire XFA spec */
+internal const val PDF_FORM_TYPE_XFA_FULL = 2
+
+/** Represents a PDF with form fields specified using the XFAF subset of the XFA spec */
+internal const val PDF_FORM_TYPE_XFA_FOREGROUND = 3
+
+/**
+ * Laying out pages involves waiting for multiple coroutines that are started sequentially. It is
+ * not possible to use TestScheduler alone to wait for a certain amount of layout to happen. This
+ * uses a polling loop to wait for a certain number of pages to be laid out, up to [timeoutMillis]
+ */
+@OptIn(ExperimentalCoroutinesApi::class)
+internal suspend fun FakePdfDocument.waitForLayout(untilPage: Int, timeoutMillis: Long = 1000) {
+    // Jump to Dispatchers.Default, as TestDispatcher will skip delays and timeouts
+    withContext(Dispatchers.Default.limitedParallelism(1)) {
+        withTimeout(timeoutMillis) {
+            while (layoutReach < untilPage) {
+                delay(100)
+            }
+        }
+    }
+}
+
+/**
+ * Rendering pages involves waiting for multiple coroutines that are started sequentially. It is not
+ * possible to use TestScheduler alone to wait for a certain amount of rendering to happen. This
+ * uses a polling loop to wait for a certain number of pages to be rendered, up to [timeoutMillis]
+ */
+@OptIn(ExperimentalCoroutinesApi::class)
+internal suspend fun FakePdfDocument.waitForRender(untilPage: Int, timeoutMillis: Long = 1000) {
+    // Jump to Dispatchers.Default, as TestDispatcher will skip delays and timeouts
+    withContext(Dispatchers.Default.limitedParallelism(1)) {
+        withTimeout(timeoutMillis) {
+            while (renderReach < untilPage) {
+                delay(100)
+            }
+        }
+    }
+}
diff --git a/pdf/pdf-viewer/src/androidTest/kotlin/androidx/pdf/view/PdfViewGestureTest.kt b/pdf/pdf-viewer/src/androidTest/kotlin/androidx/pdf/view/PdfViewGestureTest.kt
new file mode 100644
index 0000000..b84d2b7
--- /dev/null
+++ b/pdf/pdf-viewer/src/androidTest/kotlin/androidx/pdf/view/PdfViewGestureTest.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.pdf.view
+
+import android.graphics.Point
+import android.view.ViewGroup
+import android.widget.FrameLayout
+import androidx.test.core.app.ActivityScenario
+import androidx.test.espresso.Espresso
+import androidx.test.espresso.action.ViewActions
+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 org.junit.After
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+@LargeTest
+class PdfViewGestureTest {
+    @After
+    fun tearDown() {
+        PdfViewTestActivity.onCreateCallback = {}
+    }
+
+    @Test
+    fun testScrollGesture() {
+        val fakePdfDocument = FakePdfDocument(List(100) { Point(500, 1000) })
+        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)
+        }
+
+        var scrollBefore = Int.MAX_VALUE
+        var scrollAfter = Int.MIN_VALUE
+        with(ActivityScenario.launch(PdfViewTestActivity::class.java)) {
+            Espresso.onView(withId(PDF_VIEW_ID))
+                .check { view, noViewFoundException ->
+                    view ?: throw noViewFoundException
+                    scrollBefore = view.scrollY
+                }
+                .perform(ViewActions.swipeUp())
+                .check { view, noViewFoundException ->
+                    view ?: throw noViewFoundException
+                    scrollAfter = view.scrollY
+                }
+            close()
+        }
+
+        assertThat(scrollAfter).isGreaterThan(scrollBefore)
+    }
+
+    @Test fun testZoomGesture() {}
+}
+
+/** Arbitrary fixed ID for PdfView */
+private const val PDF_VIEW_ID = 123456789
diff --git a/pdf/pdf-viewer/src/androidTest/kotlin/androidx/pdf/view/PdfViewPaginationTest.kt b/pdf/pdf-viewer/src/androidTest/kotlin/androidx/pdf/view/PdfViewPaginationTest.kt
new file mode 100644
index 0000000..551a72a
--- /dev/null
+++ b/pdf/pdf-viewer/src/androidTest/kotlin/androidx/pdf/view/PdfViewPaginationTest.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.pdf.view
+
+import android.content.Context
+import android.graphics.Point
+import android.os.Looper
+import android.view.View
+import android.view.View.MeasureSpec
+import androidx.pdf.PdfDocument
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.LargeTest
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.withContext
+import org.junit.BeforeClass
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+@LargeTest
+class PdfViewPaginationTest {
+    private val context = ApplicationProvider.getApplicationContext<Context>()
+    private val testScope = TestScope()
+
+    companion object {
+        @BeforeClass
+        @JvmStatic
+        fun setup() {
+            // PdfView creates a ScaleGestureDetector internally, which can only be done from Looper
+            // threads, so let's make sure we're executing on one
+            if (Looper.myLooper() == null) {
+                Looper.prepare()
+            }
+        }
+    }
+
+    @Test
+    fun testPageVisibility() = runTest {
+        // Layout at 500x1000, and expect to see pages [0, 4] at 100x200
+        val pdfDocument = FakePdfDocument(List(10) { Point(100, 200) })
+        val pdfView = setupPdfView(500, 1000, pdfDocument)
+
+        pdfDocument.waitForLayout(untilPage = 4)
+
+        assertThat(pdfView.firstVisiblePage).isEqualTo(0)
+        assertThat(pdfView.visiblePagesCount).isEqualTo(5)
+    }
+
+    @Test
+    fun testPageVisibility_withoutPdfDocument() {
+        val pdfView = setupPdfViewOnMain(500, 1000, null)
+
+        assertThat(pdfView.firstVisiblePage).isEqualTo(0)
+        // Default visible pages is [0, 1]
+        assertThat(pdfView.visiblePagesCount).isEqualTo(2)
+    }
+
+    @Test
+    fun testPageVisibility_onSizeDecreased() = runTest {
+        // Layout at 500x1000 initially, and expect to see pages [0, 3] at 100x300
+        val pdfDocument = FakePdfDocument(List(10) { Point(100, 300) })
+        val pdfView = setupPdfView(500, 1000, pdfDocument)
+        pdfDocument.waitForLayout(untilPage = 3)
+        assertThat(pdfView.firstVisiblePage).isEqualTo(0)
+        assertThat(pdfView.visiblePagesCount).isEqualTo(4)
+
+        // Reduce size to 100x200, and expect to see only page 0
+        pdfView.layoutAndMeasure(100, 200)
+        assertThat(pdfView.firstVisiblePage).isEqualTo(0)
+        assertThat(pdfView.visiblePagesCount).isEqualTo(1)
+    }
+
+    @Test
+    fun testPageVisibility_onScrollChanged() = runTest {
+        // Layout at 1000x2000 initially, and expect to see pages [0, 3] at 200x500
+        val pdfDocument = FakePdfDocument(List(10) { Point(200, 500) })
+        val pdfView = setupPdfView(1000, 2000, pdfDocument)
+        pdfDocument.waitForLayout(untilPage = 3)
+        assertThat(pdfView.firstVisiblePage).isEqualTo(0)
+        assertThat(pdfView.visiblePagesCount).isEqualTo(4)
+
+        // Scroll until the viewport spans [500, 2500] vertically and expect to see pages [1, 4]
+        pdfView.scrollBy(0, 500)
+        pdfDocument.waitForLayout(untilPage = 4)
+        assertThat(pdfView.firstVisiblePage).isEqualTo(1)
+        assertThat(pdfView.visiblePagesCount).isEqualTo(4)
+    }
+
+    @Test
+    fun testPageVisibility_onZoomChanged() = runTest {
+        // Layout at 100x500 initially, and expect to see pages [0, 5] at 30x80
+        val pdfDocument = FakePdfDocument(List(10) { Point(30, 80) })
+        val pdfView = setupPdfView(100, 500, pdfDocument)
+        pdfDocument.waitForLayout(untilPage = 5)
+        assertThat(pdfView.firstVisiblePage).isEqualTo(0)
+        assertThat(pdfView.visiblePagesCount).isEqualTo(6)
+
+        // Set zoom to 2f and expect to see pages [0, 2]
+        withContext(Dispatchers.Main) { pdfView.zoom = 2f }
+        assertThat(pdfView.firstVisiblePage).isEqualTo(0)
+        assertThat(pdfView.visiblePagesCount).isEqualTo(3)
+    }
+
+    /** Create, measure, and layout a [PdfView] at the specified [width] and [height] */
+    private suspend fun setupPdfView(width: Int, height: Int, pdfDocument: FakePdfDocument?) =
+        withContext(Dispatchers.Main) { setupPdfViewOnMain(width, height, pdfDocument) }
+
+    private fun setupPdfViewOnMain(width: Int, height: Int, pdfDocument: PdfDocument?): PdfView {
+        val pdfView = PdfView(context)
+        pdfView.layoutAndMeasure(width, height)
+        pdfDocument?.let { pdfView.pdfDocument = it }
+        return pdfView
+    }
+}
+
+private fun View.layoutAndMeasure(width: Int, height: Int) {
+    measure(
+        MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
+        MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)
+    )
+    layout(0, 0, measuredWidth, measuredHeight)
+}
diff --git a/pdf/pdf-viewer/src/androidTest/kotlin/androidx/pdf/view/PdfViewRenderingTest.kt b/pdf/pdf-viewer/src/androidTest/kotlin/androidx/pdf/view/PdfViewRenderingTest.kt
new file mode 100644
index 0000000..b8503c3
--- /dev/null
+++ b/pdf/pdf-viewer/src/androidTest/kotlin/androidx/pdf/view/PdfViewRenderingTest.kt
@@ -0,0 +1,137 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR 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.Point
+import android.os.Looper
+import android.view.View
+import android.view.View.MeasureSpec
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.LargeTest
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.withContext
+import org.junit.BeforeClass
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+@LargeTest
+class PdfViewRenderingTest {
+    private val context = ApplicationProvider.getApplicationContext<Context>()
+
+    companion object {
+        @BeforeClass
+        @JvmStatic
+        fun setup() {
+            // PdfView creates a ScaleGestureDetector internally, which can only be done from Looper
+            // threads, so let's make sure we're executing on one
+            if (Looper.myLooper() == null) {
+                Looper.prepare()
+            }
+        }
+    }
+
+    @Test
+    fun testPageRendering() = runTest {
+        // Layout at 500x1000, and expect to render pages [0, 4] at 100x200
+        val pdfDocument = FakePdfDocument(List(10) { Point(100, 200) })
+        setupPdfView(500, 1000, pdfDocument)
+
+        pdfDocument.waitForRender(untilPage = 4)
+
+        val requestedBitmaps = pdfDocument.bitmapRequests
+        for (i in 0..4) {
+            assertThat((requestedBitmaps[i] as? FullBitmap)?.scaledPageSizePx?.width).isEqualTo(100)
+            assertThat((requestedBitmaps[i] as? FullBitmap)?.scaledPageSizePx?.height)
+                .isEqualTo(200)
+        }
+    }
+
+    @Test
+    fun testPageRendering_renderNewPagesOnScroll() = runTest {
+        // Layout at 500x1000, and expect to render pages [0, 4] at 100x200
+        val pdfDocument = FakePdfDocument(List(10) { Point(100, 200) })
+        val pdfView = setupPdfView(500, 1000, pdfDocument)
+        pdfDocument.waitForRender(untilPage = 4)
+
+        // Scroll until the viewport spans [1000, 2000] vertically and expect to render pages
+        // [5, 9] at 200x400
+        pdfView.scrollTo(0, 1000)
+        pdfDocument.waitForRender(untilPage = 9)
+        val requestedBitmaps = pdfDocument.bitmapRequests
+        for (i in 0..4) {
+            assertThat((requestedBitmaps[i] as? FullBitmap)?.scaledPageSizePx?.width).isEqualTo(100)
+            assertThat((requestedBitmaps[i] as? FullBitmap)?.scaledPageSizePx?.height)
+                .isEqualTo(200)
+        }
+    }
+
+    @Test
+    fun testPageRendering_renderNewBitmapsOnZoom() = runTest {
+        // Layout at 500x1000, and expect to render pages [0, 4] at 100x200
+        val pdfDocument = FakePdfDocument(List(10) { Point(100, 200) })
+        val pdfView = setupPdfView(500, 1000, pdfDocument)
+        pdfDocument.waitForRender(untilPage = 4)
+
+        // Set zoom to 2f, and expect to render pages [0, 2] at 200x400
+        // Reset render reach, as we expect to start rendering new Bitmaps for already-rendered
+        // pages
+        pdfDocument.renderReach = 0
+        withContext(Dispatchers.Main) { pdfView.zoom = 2f }
+        pdfDocument.waitForRender(2)
+        val requestedBitmaps = pdfDocument.bitmapRequests
+        for (i in 0..2) {
+            assertThat((requestedBitmaps[i] as? FullBitmap)?.scaledPageSizePx?.width).isEqualTo(200)
+            assertThat((requestedBitmaps[i] as? FullBitmap)?.scaledPageSizePx?.height)
+                .isEqualTo(400)
+        }
+    }
+
+    /** Create, measure, and layout a [PdfView] at the specified [width] and [height] */
+    private suspend fun setupPdfView(
+        width: Int,
+        height: Int,
+        pdfDocument: FakePdfDocument?
+    ): PdfView {
+        return withContext(Dispatchers.Main) {
+            setupPdfViewOnMainThread(width, height, pdfDocument)
+        }
+    }
+
+    private fun setupPdfViewOnMainThread(
+        width: Int,
+        height: Int,
+        pdfDocument: FakePdfDocument?
+    ): PdfView {
+        val pdfView = PdfView(context)
+        pdfView.layoutAndMeasure(width, height)
+        pdfDocument?.let { pdfView.pdfDocument = it }
+        return pdfView
+    }
+}
+
+private fun View.layoutAndMeasure(width: Int, height: Int) {
+    measure(
+        MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
+        MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)
+    )
+    layout(0, 0, measuredWidth, measuredHeight)
+}
diff --git a/pdf/pdf-viewer/src/androidTest/kotlin/androidx/pdf/view/PdfViewTestActivity.kt b/pdf/pdf-viewer/src/androidTest/kotlin/androidx/pdf/view/PdfViewTestActivity.kt
new file mode 100644
index 0000000..6a38570
--- /dev/null
+++ b/pdf/pdf-viewer/src/androidTest/kotlin/androidx/pdf/view/PdfViewTestActivity.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.pdf.view
+
+import android.app.Activity
+import android.os.Bundle
+
+/** Bare bones test helper [Activity] for [PdfView] integration tests */
+class PdfViewTestActivity : Activity() {
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        onCreateCallback(this)
+        // disable enter animation.
+        @Suppress("Deprecation") overridePendingTransition(0, 0)
+    }
+
+    override fun finish() {
+        super.finish()
+        // disable exit animation.
+        @Suppress("Deprecation") overridePendingTransition(0, 0)
+    }
+
+    companion object {
+        var onCreateCallback: ((PdfViewTestActivity) -> Unit) = {}
+    }
+}
diff --git a/pdf/pdf-viewer/src/main/java/androidx/pdf/viewer/PaginationModel.java b/pdf/pdf-viewer/src/main/java/androidx/pdf/viewer/PaginationModel.java
index 3279dfe..d519aa3 100644
--- a/pdf/pdf-viewer/src/main/java/androidx/pdf/viewer/PaginationModel.java
+++ b/pdf/pdf-viewer/src/main/java/androidx/pdf/viewer/PaginationModel.java
@@ -71,6 +71,9 @@
     /** The maximum number of pages this model can accommodate. */
     private int mMaxPages = -1;
 
+    /** The page centered in the view. */
+    private int mMidPage = -1;
+
     /** The Dimensions of each page in the list. */
     private Dimensions[] mPages;
 
@@ -264,12 +267,14 @@
         int bottomResult = Collections.binarySearch(endList, intervalPx.getLast());
         int rangeEnd = Math.abs(bottomResult + 1) - 1; // Before insertion point.
 
+        int midPoint = (intervalPx.getFirst() + intervalPx.getLast()) / 2;
+        int midResult = Collections.binarySearch(mPageTops, midPoint);
+
+        mMidPage = Math.max(Math.abs(midResult + 1) - 1, 0); // Before insertion point.
+
         if (rangeEnd < rangeStart) {
             // No page is entirely visible.
-            int midPoint = (intervalPx.getFirst() + intervalPx.getLast()) / 2;
-            int midResult = Collections.binarySearch(mPageTops, midPoint);
-            int page = Math.max(Math.abs(midResult + 1) - 1, 0); // Before insertion point.
-            return new Range(page, page);
+            return new Range(mMidPage, mMidPage);
         }
 
         return new Range(rangeStart, rangeEnd);
@@ -352,6 +357,11 @@
         return mSize;
     }
 
+    /** Returns the centered page */
+    public int getMidPage() {
+        return mMidPage;
+    }
+
     /**
      * Returns the number of pages in the document.
      *
diff --git a/pdf/pdf-viewer/src/main/java/androidx/pdf/viewer/loader/PdfLoaderCallbacksImpl.kt b/pdf/pdf-viewer/src/main/java/androidx/pdf/viewer/loader/PdfLoaderCallbacksImpl.kt
index 3a40bdb..8f446be 100644
--- a/pdf/pdf-viewer/src/main/java/androidx/pdf/viewer/loader/PdfLoaderCallbacksImpl.kt
+++ b/pdf/pdf-viewer/src/main/java/androidx/pdf/viewer/loader/PdfLoaderCallbacksImpl.kt
@@ -23,7 +23,6 @@
 import android.os.Bundle
 import android.view.View
 import androidx.annotation.RestrictTo
-import androidx.annotation.UiThread
 import androidx.core.os.OperationCanceledException
 import androidx.fragment.app.FragmentManager
 import androidx.pdf.R
@@ -44,7 +43,6 @@
 import androidx.pdf.util.ThreadUtils
 import androidx.pdf.util.TileBoard
 import androidx.pdf.viewer.LayoutHandler
-import androidx.pdf.viewer.LoadingView
 import androidx.pdf.viewer.PageViewFactory
 import androidx.pdf.viewer.PaginatedView
 import androidx.pdf.viewer.PdfPasswordDialog
@@ -63,7 +61,6 @@
     private var fastScrollView: FastScrollView,
     private var zoomView: ZoomView,
     private var paginatedView: PaginatedView,
-    private var loadingView: LoadingView,
     private var findInFileView: FindInFileView,
     private var isTextSearchActive: Boolean,
     private var viewState: ExposedValue<ViewState>,
@@ -109,11 +106,6 @@
         onDocumentLoadFailure(thrown, true)
     }
 
-    @UiThread
-    public fun hideSpinner() {
-        loadingView.visibility = View.GONE
-    }
-
     private fun lookAtSelection(selection: SelectedMatch?) {
         if (selection == null || selection.isEmpty) {
             return
@@ -188,7 +180,6 @@
         }
 
         onDocumentLoaded()
-        hideSpinner()
 
         // Assume we see at least the first page
         paginatedView.pageRangeHandler.maxPage = 1
diff --git a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/view/GestureTracker.kt b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/view/GestureTracker.kt
new file mode 100644
index 0000000..9858e87
--- /dev/null
+++ b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/view/GestureTracker.kt
@@ -0,0 +1,397 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR 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.PointF
+import android.view.GestureDetector
+import android.view.GestureDetector.SimpleOnGestureListener
+import android.view.MotionEvent
+import android.view.ScaleGestureDetector
+import android.view.ScaleGestureDetector.OnScaleGestureListener
+import android.view.ViewConfiguration
+import androidx.pdf.view.GestureTracker.Gesture
+import androidx.pdf.view.GestureTracker.GestureHandler
+import kotlin.math.abs
+import kotlin.math.sqrt
+
+/**
+ * Processes [MotionEvent] and interprets them as [Gesture]s. Intended to be plugged in to
+ * [android.view.View.onTouchEvent]. Provides callbacks via the [GestureHandler] listener, which
+ * includes the signals from [SimpleOnGestureListener] and [OnScaleGestureListener] as well as start
+ * and end signals for all detected [Gesture]s.
+ */
+internal class GestureTracker(context: Context) {
+
+    /** Minimal identifier for a [MotionEvent]. */
+    internal class EventId(event: MotionEvent) {
+        /** Returns the [MotionEvent.getEventTime] of the event. */
+        val eventTimeMs: Long = event.eventTime
+
+        /** Returns the [MotionEvent.getAction] code for the event. */
+        val eventAction: Int = event.actionMasked
+
+        fun matches(other: MotionEvent?): Boolean {
+            return other != null &&
+                eventTimeMs == other.eventTime &&
+                eventAction == other.actionMasked
+        }
+    }
+
+    /** A recognized user gesture. */
+    internal enum class Gesture {
+        /** First touch event, usually [MotionEvent.ACTION_DOWN] */
+        TOUCH,
+
+        /** First tap, to be confirmed as [SINGLE_TAP], or superseded by another gesture */
+        FIRST_TAP,
+
+        /**
+         * [FIRST_TAP] after it's confirmed to be a single tap and not the start of a more complex
+         * gesture.
+         */
+        SINGLE_TAP,
+
+        /**
+         * Two consecutive [MotionEvent.ACTION_DOWN] events within
+         * [ViewConfiguration.getDoubleTapTimeout]
+         */
+        DOUBLE_TAP,
+
+        /** Press and hold for [ViewConfiguration.getLongPressTimeout] or longer */
+        LONG_PRESS,
+
+        /** Touch and drag, not aligned on any one axis */
+        DRAG,
+
+        /** Touch and drag along the X axis */
+        DRAG_X,
+
+        /** Touch and drag along the Y axis */
+        DRAG_Y,
+
+        /** Touch, quickly drag, and release */
+        FLING,
+
+        /** Either pinch-to-zoom or a quick scale gesture, as detected by [ScaleGestureDetector] */
+        ZOOM;
+
+        /** True if this [Gesture] is a better guess than [other] in the case of ambiguity */
+        fun supersedes(other: Gesture?): Boolean {
+            if (other == this) {
+                return false
+            }
+            if (other == null || other == TOUCH) {
+                // Every Gesture is finer than nothing or a TOUCH.
+                return true
+            }
+            if (other == FIRST_TAP) {
+                // TAP is overridden by any other Gesture except TOUCH.
+                return this != TOUCH
+            }
+            if (other == DOUBLE_TAP) {
+                // A Double tap is overridden by any drag while on the second tap, or a zoom (quick
+                // scale) gesture
+                return this == DRAG || (this == DRAG_X) || (this == DRAG_Y) || (this == ZOOM)
+            }
+            return when (this) {
+                FLING,
+                ZOOM -> true
+                else -> other == LONG_PRESS
+            }
+        }
+    }
+
+    private val doubleTapTimeout = ViewConfiguration.getDoubleTapTimeout()
+    private val moveSlop = ViewConfiguration.get(context).scaledTouchSlop
+
+    private val listener = DetectorListener()
+    private val zoomDetector = ScaleGestureDetector(context, listener)
+    private val moveDetector =
+        GestureDetector(context, listener).apply {
+            // Detection of double tap on the main detector messes up with everything else, so
+            // divert it on a secondary detector:
+            setOnDoubleTapListener(null)
+        }
+    private val doubleTapDetector =
+        GestureDetector(context, SimpleOnGestureListener()).apply {
+            setOnDoubleTapListener(listener)
+        }
+
+    var delegate: GestureHandler? = null
+
+    /**
+     * Whether we are currently tracking a gesture in progress, i.e. between the initial ACTION_DOWN
+     * and the end of the gesture.
+     */
+    private var tracking = false
+
+    private val touchDown = PointF()
+    private var lastEvent: EventId? = null
+    private var detectedGesture: Gesture? = null
+
+    /**
+     * Feed an event into this tracker. To be plugged in a [android.view.View.onTouchEvent]
+     *
+     * @param event The event.
+     * @return true if the event was recorded, false if it was discarded as a duplicate
+     */
+    fun feed(event: MotionEvent): Boolean {
+        if (lastEvent?.matches(event) == true) {
+            // We have already processed this event in this way (handling or non-handling).
+            return false
+        }
+
+        if (!tracking) {
+            initTracking(event.x, event.y)
+            delegate?.onGestureStart()
+        }
+
+        moveDetector.onTouchEvent(event)
+        if (!shouldSkipZoomDetector(event)) {
+            zoomDetector.onTouchEvent(event)
+        }
+        doubleTapDetector.onTouchEvent(event)
+
+        if (event.actionMasked == MotionEvent.ACTION_UP) {
+            if (detectedGesture == Gesture.DOUBLE_TAP && delegate != null) {
+                // Delayed from detection which happens too early.
+                delegate?.onDoubleTap(event)
+            }
+            if (detectedGesture != Gesture.FIRST_TAP) {
+                // All gestures but FIRST_TAP are final, should end gesture here.
+                endGesture()
+            }
+        }
+
+        if (event.actionMasked == MotionEvent.ACTION_CANCEL) {
+            endGesture()
+        }
+
+        lastEvent = EventId(event)
+        return true
+    }
+
+    private fun endGesture() {
+        tracking = false
+        if (delegate != null) {
+            delegate?.onGestureEnd(detectedGesture)
+        }
+    }
+
+    /** Returns whether the currently detected gesture matches any of [gestures]. */
+    fun matches(vararg gestures: Gesture): Boolean {
+        for (g in gestures) {
+            if (detectedGesture == g) {
+                return true
+            }
+        }
+        return false
+    }
+
+    private fun getDistance(event: MotionEvent, axis: Int): Float {
+        when (axis) {
+            MotionEvent.AXIS_X -> return abs(event.x - touchDown.x)
+            MotionEvent.AXIS_Y -> return abs(event.y - touchDown.y)
+            NO_AXIS -> {
+                val x = event.x - touchDown.x
+                val y = event.y - touchDown.y
+                return sqrt(x * x + y * y)
+            }
+            else -> throw IllegalArgumentException("Wrong axis value $axis")
+        }
+    }
+
+    private fun detected(gesture: Gesture) {
+        if (gesture.supersedes(detectedGesture)) {
+            detectedGesture = gesture
+        }
+    }
+
+    private fun initTracking(x: Float, y: Float) {
+        tracking = true
+        touchDown.set(x, y)
+        detectedGesture = Gesture.TOUCH
+    }
+
+    /**
+     * Returns whether to skip passing [event] to the [zoomDetector].
+     *
+     * [ScaleGestureDetector] sometimes misinterprets scroll gestures performed in quick succession
+     * for a quick scale (double-tap-and-drag to zoom) gesture. This is because [GestureDetector]'s
+     * double tap detection logic compares the position of the first [MotionEvent.ACTION_DOWN] event
+     * to the second [MotionEvent.ACTION_DOWN] event, but ignores where the first gesture's
+     * [MotionEvent.ACTION_UP] event took place. In a drag/fling gesture, the up event happens far
+     * from the down event, but if a second drag/fling has its down event near the previous
+     * gesture's down event (and occurs within [doubleTapTimeout] of the previous up event), a quick
+     * scale will be detected.
+     */
+    private fun shouldSkipZoomDetector(event: MotionEvent): Boolean {
+        if (lastEvent == null || lastEvent?.eventAction != MotionEvent.ACTION_UP) {
+            return false
+        }
+        if (!SCROLL_GESTURES.contains(detectedGesture)) {
+            return false
+        }
+        val lastEventTime = lastEvent?.eventTimeMs ?: Int.MAX_VALUE.toLong()
+        val deltaTime = event.eventTime - lastEventTime
+
+        return deltaTime < doubleTapTimeout
+    }
+
+    /** A recipient for all gesture handling. */
+    open class GestureHandler : SimpleOnGestureListener(), OnScaleGestureListener {
+        override fun onScale(detector: ScaleGestureDetector): Boolean {
+            return true
+        }
+
+        override fun onScaleBegin(detector: ScaleGestureDetector): Boolean {
+            return true
+        }
+
+        override fun onScaleEnd(detector: ScaleGestureDetector) {}
+
+        /** Called at the start of any gesture, before any other callback */
+        open fun onGestureStart() {}
+
+        /**
+         * Called at the end of any gesture, after any other callback
+         *
+         * @param gesture The detected gesture that just ended
+         */
+        open fun onGestureEnd(gesture: Gesture?) {}
+    }
+
+    /** The listener used for detecting various gestures. */
+    private inner class DetectorListener : SimpleOnGestureListener(), OnScaleGestureListener {
+        override fun onShowPress(e: MotionEvent) {
+            if (delegate != null) {
+                delegate?.onShowPress(e)
+            }
+        }
+
+        override fun onSingleTapUp(e: MotionEvent): Boolean {
+            detected(Gesture.FIRST_TAP)
+            if (delegate != null) {
+                delegate?.onSingleTapUp(e)
+            }
+            return true
+        }
+
+        override fun onSingleTapConfirmed(e: MotionEvent): Boolean {
+            detected(Gesture.SINGLE_TAP)
+            if (delegate != null) {
+                delegate?.onSingleTapConfirmed(e)
+            }
+            // This comes from a delayed call from the doubleTapDetector, not an event
+            endGesture()
+            return true
+        }
+
+        override fun onDoubleTap(e: MotionEvent): Boolean {
+            // Double-taps are only valid if the first gesture was just a FIRST_TAP, nothing else.
+            if (detectedGesture == Gesture.FIRST_TAP) {
+                detected(Gesture.DOUBLE_TAP)
+                // The delegate is called on the corresponding UP event, because we can be not
+                // handling the event yet
+            }
+            return true
+        }
+
+        override fun onScroll(
+            e1: MotionEvent?,
+            e2: MotionEvent,
+            distanceX: Float,
+            distanceY: Float,
+        ): Boolean {
+            val dx = getDistance(e2, MotionEvent.AXIS_X)
+            val dy = getDistance(e2, MotionEvent.AXIS_Y)
+            if (dx > moveSlop && dx > DRAG_X_MULTIPLIER * dy) {
+                detected(Gesture.DRAG_X)
+            } else if (dy > moveSlop && dy > DRAG_Y_MULTIPLIER * dx) {
+                detected(Gesture.DRAG_Y)
+            } else if (getDistance(e2, NO_AXIS) > moveSlop) {
+                detected(Gesture.DRAG)
+            }
+            if (delegate != null) {
+                delegate?.onScroll(e1, e2, distanceX, distanceY)
+            }
+            return false
+        }
+
+        override fun onLongPress(e: MotionEvent) {
+            detected(Gesture.LONG_PRESS)
+            if (delegate != null) {
+                delegate?.onLongPress(e)
+            }
+        }
+
+        override fun onFling(
+            e1: MotionEvent?,
+            e2: MotionEvent,
+            velocityX: Float,
+            velocityY: Float,
+        ): Boolean {
+            detected(Gesture.FLING)
+            if (delegate != null) {
+                return delegate?.onFling(e1, e2, velocityX, velocityY) != false
+            }
+            return false
+        }
+
+        override fun onScale(detector: ScaleGestureDetector): Boolean {
+            if (delegate != null) {
+                return delegate?.onScale(detector) != false
+            }
+            // Return true is required to keep the gesture detector happy (and the events flowing).
+            return true
+        }
+
+        override fun onScaleBegin(detector: ScaleGestureDetector): Boolean {
+            detected(Gesture.ZOOM)
+            if (delegate != null) {
+                return delegate?.onScaleBegin(detector) != false
+            }
+            // Return true is required to keep the gesture detector happy (and the events flowing).
+            return true
+        }
+
+        override fun onScaleEnd(detector: ScaleGestureDetector) {
+            if (delegate != null) {
+                delegate?.onScaleEnd(detector)
+            }
+        }
+    }
+}
+
+/** The set of [Gesture]s considered to constitute scrolling */
+private val SCROLL_GESTURES: Set<Gesture> =
+    setOf(Gesture.DRAG, Gesture.DRAG_X, Gesture.DRAG_Y, Gesture.FLING)
+
+/**
+ * The factor by which the swipe needs to be bigger horizontally than vertically to be considered
+ * DRAG_X.
+ */
+private const val DRAG_X_MULTIPLIER = 1f
+
+/**
+ * The factor by which the swipe needs to be bigger vertically than horizontally to be considered
+ * DRAG_Y.
+ */
+private const val DRAG_Y_MULTIPLIER = 3f
+
+private const val NO_AXIS: Int = -1
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
new file mode 100644
index 0000000..c9b3435
--- /dev/null
+++ b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/view/Page.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.pdf.view
+
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.Paint
+import android.graphics.Rect
+import android.util.Size
+import androidx.annotation.RestrictTo
+import androidx.core.view.doOnDetach
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.ensureActive
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+/** A single PDF page that knows how to render and draw itself */
+@RestrictTo(RestrictTo.Scope.LIBRARY)
+internal class Page(
+    val pageNum: Int,
+    val size: Size,
+    private val pdfView: PdfView,
+) {
+
+    init {
+        require(pageNum >= 0) { "Invalid negative page" }
+        pdfView.doOnDetach { setInvisible() }
+    }
+
+    var isVisible: Boolean = false
+        set(value) {
+            field = value
+            if (field) maybeRender() else setInvisible()
+        }
+
+    private var renderedZoom: Float? = null
+    private var renderBitmapJob: Job? = null
+    private var bitmap: Bitmap? = null
+
+    fun maybeRender() {
+        // 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(pdfView.zoom) == true &&
+                (bitmap != null || renderBitmapJob != null)
+        ) {
+            return
+        }
+        renderBitmapJob?.cancel()
+        fetchNewBitmap()
+    }
+
+    fun draw(canvas: Canvas, locationInView: Rect) {
+        bitmap?.let {
+            canvas.drawBitmap(it, /* src= */ null, locationInView, BMP_PAINT)
+            return
+        }
+        canvas.drawRect(locationInView, BLANK_PAINT)
+    }
+
+    private fun fetchNewBitmap() {
+        val bitmapSource =
+            pdfView.pdfDocument?.getPageBitmapSource(pageNum)
+                ?: throw IllegalStateException("No PDF document to render")
+        renderBitmapJob =
+            pdfView.coroutineScope.launch {
+                ensureActive()
+                val zoom = pdfView.zoom
+                val width = (size.width * zoom).toInt()
+                val height = (size.height * zoom).toInt()
+                renderedZoom = zoom
+                bitmap = bitmapSource.getBitmap(Size(width, height))
+                withContext(Dispatchers.Main) { pdfView.invalidate() }
+            }
+        renderBitmapJob?.invokeOnCompletion { renderBitmapJob = null }
+    }
+
+    private fun setInvisible() {
+        renderBitmapJob?.cancel()
+        renderBitmapJob = null
+        bitmap = null
+        renderedZoom = null
+    }
+}
+
+private val BMP_PAINT = Paint(Paint.FILTER_BITMAP_FLAG)
+private val BLANK_PAINT =
+    Paint().apply {
+        color = Color.WHITE
+        style = Paint.Style.FILL
+    }
diff --git a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/view/PaginationModel.kt b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/view/PaginationModel.kt
new file mode 100644
index 0000000..1668f42
--- /dev/null
+++ b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/view/PaginationModel.kt
@@ -0,0 +1,249 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR 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 android.graphics.Rect
+import android.os.Parcel
+import android.os.Parcelable
+import android.util.Range
+import androidx.annotation.MainThread
+import androidx.annotation.RestrictTo
+import java.util.Collections
+import kotlin.math.abs
+import kotlin.math.max
+import kotlin.math.min
+
+/**
+ * Stores the size and position of PDF pages. All dimensions and coordinates should be assumed to be
+ * content coordinates and not View coordinates, unless symbol names and / or comments indicate
+ * otherwise. Not thread safe; access only from the main thread.
+ *
+ * TODO(b/376135419) - Adapt implementation to work when page dimensions are not loaded sequentially
+ */
+@MainThread
+@Suppress("BanParcelableUsage")
+@RestrictTo(RestrictTo.Scope.LIBRARY)
+internal class PaginationModel(val pageSpacingPx: Int, val numPages: Int) : Parcelable {
+
+    init {
+        require(numPages >= 0) { "Empty PDF!" }
+        require(pageSpacingPx >= 0) { "Invalid spacing!" }
+    }
+
+    /** The estimated total height of this PDF */
+    val totalEstimatedHeight: Int
+        get() = computeEstimatedHeight()
+
+    /** The maximum width of any page known to this model */
+    var maxWidth: Int = 0
+
+    private var _reach: Int = 0
+
+    /** The last page whose dimensions are known to this model, 0-indexed */
+    val reach: Int
+        get() = _reach
+
+    /** The dimensions of all pages known to this model, as [Point] */
+    private val pages = Array(numPages) { UNKNOWN_SIZE }
+
+    /** The top position of each page known to this model */
+    private val pagePositions = IntArray(numPages) { -1 }.apply { this[0] = 0 }
+
+    /**
+     * The estimated height of any page not known to this model, i.e. the average height of all
+     * pages known to this model
+     */
+    private var averagePageHeight = 0
+
+    /** The total height, excluding page spacing, of all pages known to this model. */
+    private var accumulatedPageHeight = 0
+
+    /**
+     * Pre-allocated Rect to avoid mutating [Rect] passed to us, without allocating a new one.
+     * Notably this class is used on the drawing path and should avoid excessive allocations.
+     */
+    private val tmpVisibleArea = Rect()
+
+    /**
+     * The top position of each page known to this model, as a synthetic [List] to conveniently use
+     * with APIs like [Collections.binarySearch]
+     */
+    private val pageTops: List<Int> =
+        object : AbstractList<Int>() {
+            override val size: Int
+                get() = reach
+
+            override fun get(index: Int): Int {
+                return pagePositions[index]
+            }
+        }
+
+    /**
+     * The bottom position of each page known to this model, as a synthetic [List] to conveniently
+     * use with APIs like [Collections.binarySearch]
+     */
+    private val pageBottoms: List<Int> =
+        object : AbstractList<Int>() {
+            override val size: Int
+                get() = reach
+
+            override fun get(index: Int): Int {
+                return pagePositions[index] + pages[index].y
+            }
+        }
+
+    constructor(parcel: Parcel) : this(parcel.readInt(), parcel.readInt()) {
+        _reach = parcel.readInt()
+        averagePageHeight = parcel.readInt()
+        accumulatedPageHeight = parcel.readInt()
+        maxWidth = parcel.readInt()
+        parcel.readIntArray(pagePositions)
+        parcel.readTypedArray(pages, Point.CREATOR)
+    }
+
+    override fun writeToParcel(parcel: Parcel, flags: Int) {
+        parcel.writeInt(pageSpacingPx)
+        parcel.writeInt(numPages)
+        parcel.writeInt(_reach)
+        parcel.writeInt(averagePageHeight)
+        parcel.writeInt(accumulatedPageHeight)
+        parcel.writeInt(maxWidth)
+        parcel.writeIntArray(pagePositions)
+        parcel.writeTypedArray(pages, flags)
+    }
+
+    /** Adds [pageNum] to this model at [pageSize] */
+    fun addPage(pageNum: Int, pageSize: Point) {
+        require(pageNum in 0 until numPages) { "Page out of range" }
+        require(pageSize.y >= 0 && pageSize.x >= 0) { "Negative size page" }
+        // Edge case: missing pages. This model expects pages to be added sequentially
+        for (i in _reach until pageNum) {
+            if (pages[i] == UNKNOWN_SIZE) {
+                pages[i] = pageSize
+            }
+        }
+        if (pageSize.x > maxWidth) {
+            maxWidth = pageSize.x
+        }
+        pages[pageNum] = pageSize
+        // Defensive: never set _reach to a smaller value, if pages are loaded out of order
+        _reach = max(_reach, pageNum + 1)
+        accumulatedPageHeight += pageSize.y
+        averagePageHeight = accumulatedPageHeight / _reach
+
+        if (pageNum > 0) {
+            pagePositions[pageNum] =
+                pagePositions[pageNum - 1] + pages[pageNum - 1].y + pageSpacingPx
+        }
+    }
+
+    /** Returns the size of [pageNum] in content coordinates */
+    fun getPageSize(pageNum: Int): Point {
+        require(pageNum in 0 until numPages) { "Page out of range" }
+        return pages[pageNum]
+    }
+
+    /**
+     * Returns a [Range] between the first and last pages that should be visible between
+     * [viewportTop] and [viewportBottom], which are expected to be the top and bottom content
+     * coordinates of the viewport.
+     */
+    fun getPagesInViewport(viewportTop: Int, viewportBottom: Int): Range<Int> {
+        // If the viewport is below all pages, return an empty range at the bottom of this model
+        if (reach > 0 && viewportTop > pageBottoms[reach - 1]) {
+            return Range(min(reach, numPages - 1), min(reach, numPages - 1))
+        }
+        // If the viewport is above all pages, return an empty range at the top of this model
+        if (viewportBottom < pageTops[0]) {
+            return Range(0, 0)
+        }
+        val rangeStart = abs(Collections.binarySearch(pageBottoms, viewportTop) + 1)
+        val rangeEnd = abs(Collections.binarySearch(pageTops, viewportBottom) + 1) - 1
+
+        if (rangeEnd < rangeStart) {
+            val midPoint = Collections.binarySearch(pageTops, (viewportTop + viewportBottom) / 2)
+            val page = maxOf(abs(midPoint + 1) - 1, 0)
+            return Range(page, page)
+        }
+
+        return Range(rangeStart, rangeEnd)
+    }
+
+    /** Returns the location of the page in content coordinates */
+    fun getPageLocation(pageNum: Int, viewport: Rect): Rect {
+        // We care about the intersection between what's visible and the coordinates of this model
+        tmpVisibleArea.set(viewport)
+        tmpVisibleArea.intersect(0, 0, maxWidth, totalEstimatedHeight)
+        val page = pages[pageNum]
+        var left = 0
+        var right: Int = maxWidth
+        val top = pagePositions[pageNum]
+        val bottom = top + page.y
+        // this page is smaller than the view's width, it may slide left or right.
+        if (page.x < tmpVisibleArea.width()) {
+            // page is smaller than the view: center (but respect min left margin)
+            left = Math.max(left, tmpVisibleArea.left + (tmpVisibleArea.width() - page.x) / 2)
+        } else {
+            // page is larger than view: scroll proportionally between the margins.
+            if (tmpVisibleArea.right > right) {
+                left = right - page.x
+            } else if (tmpVisibleArea.left > left) {
+                left = tmpVisibleArea.left * (right - page.x) / (right - tmpVisibleArea.width())
+            }
+        }
+        right = left + page.x
+
+        val ret = Rect(left, top, right, bottom)
+        return ret
+    }
+
+    private fun computeEstimatedHeight(): Int {
+        return if (_reach == 0) {
+            0
+        } else if (_reach == numPages) {
+            val lastPageHeight = pages[_reach - 1].y
+            pagePositions[_reach - 1] + lastPageHeight + (pageSpacingPx)
+            // Otherwise, we have to guess
+        } else {
+            val totalKnownHeight = pagePositions[_reach - 1] + pages[_reach - 1].y
+            val estimatedRemainingHeight = (averagePageHeight + pageSpacingPx) * (numPages - _reach)
+            totalKnownHeight + estimatedRemainingHeight
+        }
+    }
+
+    companion object {
+        /** Sentinel value for the size of a page unknown to this model */
+        val UNKNOWN_SIZE = Point(-1, -1)
+
+        @JvmField
+        val CREATOR: Parcelable.Creator<PaginationModel> =
+            object : Parcelable.Creator<PaginationModel> {
+                override fun createFromParcel(parcel: Parcel): PaginationModel {
+                    return PaginationModel(parcel)
+                }
+
+                override fun newArray(size: Int): Array<PaginationModel?> {
+                    return arrayOfNulls(size)
+                }
+            }
+    }
+
+    override fun describeContents(): Int {
+        return 0
+    }
+}
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
new file mode 100644
index 0000000..fa60206
--- /dev/null
+++ b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/view/PdfView.kt
@@ -0,0 +1,339 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR 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.Canvas
+import android.graphics.Point
+import android.graphics.Rect
+import android.os.Looper
+import android.util.AttributeSet
+import android.util.Range
+import android.util.Size
+import android.util.SparseArray
+import android.view.MotionEvent
+import android.view.View
+import androidx.annotation.RestrictTo
+import androidx.core.util.keyIterator
+import androidx.pdf.PdfDocument
+import java.util.concurrent.Executors
+import kotlin.math.max
+import kotlin.math.round
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.asCoroutineDispatcher
+import kotlinx.coroutines.cancelChildren
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withContext
+
+/**
+ * A [View] for presenting PDF content, represented by [PdfDocument].
+ *
+ * This View supports zooming, scrolling, and flinging. Zooming is supported via pinch gesture,
+ * quick scale gesture, and double tap to zoom in or snap back to fitting the page width inside its
+ * bounds. Zoom can be changed using the [zoom] property, which is notably distinct from
+ * [View.getScaleX] / [View.getScaleY]. Scroll position is based on the [View.getScrollX] /
+ * [View.getScrollY] properties.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY)
+public open class PdfView
+@JvmOverloads
+constructor(context: Context, attrs: AttributeSet? = null, defStyle: Int = 0) :
+    View(context, attrs, defStyle) {
+    /** Supply a [PdfDocument] to process the PDF content for rendering */
+    public var pdfDocument: PdfDocument? = null
+        set(value) {
+            checkMainThread()
+            value?.let {
+                val reset = field != null && field?.uri != value.uri
+                field = it
+                if (reset) reset()
+                onDocumentSet()
+            }
+        }
+
+    /**
+     * The [CoroutineScope] used to make suspending calls to [PdfDocument]. The size of the fixed
+     * thread pool is arbitrary and subject to tuning.
+     */
+    internal val coroutineScope: CoroutineScope =
+        CoroutineScope(Executors.newFixedThreadPool(5).asCoroutineDispatcher())
+
+    /** The maximum scaling factor that can be applied to this View using the [zoom] property */
+    // TODO(b/376299551) - Make maxZoom configurable via XML attribute
+    public var maxZoom: Float = DEFAULT_MAX_ZOOM
+
+    /** The minimum scaling factor that can be applied to this View using the [zoom] property */
+    // TODO(b/376299551) - Make minZoom configurable via XML attribute
+    public var minZoom: Float = DEFAULT_MIN_ZOOM
+
+    /**
+     * The zoom level of this view, as a factor of the content's natural size with when 1 pixel is
+     * equal to 1 PDF point. Will always be clamped within ([minZoom], [maxZoom])
+     */
+    public var zoom: Float = DEFAULT_INIT_ZOOM
+        set(value) {
+            checkMainThread()
+            field = value
+            updateVisibleContent()
+            invalidate()
+        }
+
+    /**
+     * The radius of pages around the current viewport for which dimensions and other metadata will
+     * be loaded
+     */
+    // TODO(b/376299551) - Make page prefetch radius configurable via XML attribute
+    public var pagePrefetchRadius: Int = 1
+
+    private var visiblePages: Range<Int> = Range(0, 1)
+        set(value) {
+            // Debounce setting the range to the same value
+            if (field == value) return
+            field = value
+            onVisiblePagesChanged()
+        }
+
+    /** The first page in the viewport, including partially-visible pages. 0-indexed. */
+    public val firstVisiblePage: Int
+        get() = visiblePages.lower
+
+    /** The number of pages visible in the viewport, including partially visible pages */
+    public val visiblePagesCount: Int
+        get() = visiblePages.upper - visiblePages.lower + 1
+
+    /**
+     * [PdfDocument] is backed by a single-threaded PDF parser, so only allow one thread to access
+     * at a time
+     */
+    private var pdfDocumentMutex = Mutex()
+    private var paginationModel: PaginationModel? = null
+
+    private val pages = SparseArray<Page>()
+
+    private val gestureHandler = ZoomScrollGestureHandler(this@PdfView)
+    private val gestureTracker = GestureTracker(context).apply { delegate = gestureHandler }
+
+    // To avoid allocations during drawing
+    private val visibleAreaRect = Rect()
+
+    override fun onDraw(canvas: Canvas) {
+        super.onDraw(canvas)
+        val localPaginationModel = paginationModel ?: return
+        canvas.scale(zoom, zoom)
+        for (i in visiblePages.lower..visiblePages.upper) {
+            pages[i]?.draw(
+                canvas,
+                localPaginationModel.getPageLocation(i, getVisibleAreaInContentCoords())
+            )
+        }
+    }
+
+    override fun onTouchEvent(event: MotionEvent?): Boolean {
+        val handled = event?.let { gestureTracker.feed(it) } ?: false
+        return handled || super.onTouchEvent(event)
+    }
+
+    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
+        super.onSizeChanged(w, h, oldw, oldh)
+        updateVisibleContent()
+    }
+
+    override fun onScrollChanged(l: Int, t: Int, oldl: Int, oldt: Int) {
+        super.onScrollChanged(l, t, oldl, oldt)
+        updateVisibleContent()
+    }
+
+    /** Start using the [PdfDocument] to present PDF content */
+    private fun onDocumentSet() {
+        val localPdfDocument = pdfDocument ?: return
+        // TODO(b/376299551) - Make page margin configurable via XML attribute
+        paginationModel = PaginationModel(DEFAULT_PAGE_SPACING_PX, localPdfDocument.pageCount)
+        updateVisibleContent()
+    }
+
+    /**
+     * Compute what content is visible from the current position of this View. Generally invoked on
+     * position or size changes.
+     */
+    internal fun updateVisibleContent() {
+        val localPaginationModel = paginationModel ?: return
+
+        val contentTop = round(scrollY / zoom).toInt()
+        val contentBottom = round((height + scrollY) / zoom).toInt()
+        visiblePages = localPaginationModel.getPagesInViewport(contentTop, contentBottom)
+
+        // If scale changed, update already-visible pages so they can re-render and redraw
+        // themselves accordingly
+        if (!gestureHandler.scaleInProgress && !gestureHandler.scrollInProgress) {
+            for (i in visiblePages.lower..visiblePages.upper) {
+                pages[i]?.maybeRender()
+            }
+        }
+    }
+
+    private fun reset() {
+        scrollTo(0, 0)
+        zoom = DEFAULT_INIT_ZOOM
+        pages.clear()
+        coroutineScope.coroutineContext.cancelChildren()
+    }
+
+    /** React to a change in visible pages (load new pages and clean up old ones) */
+    private fun onVisiblePagesChanged() {
+        val localPaginationModel = paginationModel ?: return
+        val nearPages =
+            Range(
+                maxOf(0, visiblePages.lower - pagePrefetchRadius),
+                minOf(visiblePages.upper + pagePrefetchRadius, localPaginationModel.numPages - 1),
+            )
+
+        // Fetch dimensions for near pages
+        for (i in max(localPaginationModel.reach - 1, nearPages.lower)..nearPages.upper) {
+            loadPageDimensions(i)
+        }
+
+        for (i in visiblePages.lower..visiblePages.upper) {
+            pages[i]?.isVisible = true
+        }
+
+        // Clean up pages that are no longer visible
+        for (pageIndex in pages.keyIterator()) {
+            if (pageIndex < nearPages.lower || pageIndex > nearPages.upper) {
+                pages[pageIndex]?.isVisible = false
+            }
+        }
+    }
+
+    /** Loads dimensions for a single page */
+    private fun loadPageDimensions(pageNum: Int) {
+        coroutineScope.launch {
+            val pageMetadata = withPdfDocument { it.getPageInfo(pageNum) }
+            // Update mutable state on the main thread
+            withContext(Dispatchers.Main) {
+                val localPaginationModel =
+                    paginationModel ?: throw IllegalStateException("No PdfDocument")
+                localPaginationModel.addPage(
+                    pageNum,
+                    Point(pageMetadata.width, pageMetadata.height)
+                )
+                val page =
+                    Page(pageNum, Size(pageMetadata.width, pageMetadata.height), this@PdfView)
+                pages[pageNum] = page
+                if (pageNum >= visiblePages.lower && pageNum <= visiblePages.upper) {
+                    // Make the page visible if it is, so it starts to render itself
+                    page.isVisible = true
+                }
+                // Learning the dimensions of a page might affect our understanding of which pages
+                // are visible
+                updateVisibleContent()
+            }
+        }
+    }
+
+    /** Set the zoom, using the given point as a pivot point to zoom in or out of */
+    internal fun zoomTo(zoom: Float, pivotX: Float, pivotY: Float) {
+        // TODO(b/376299551) - Restore to developer-configured initial zoom value once that API is
+        // implemented
+        val newZoom = if (Float.NaN.equals(zoom)) DEFAULT_INIT_ZOOM else zoom
+        val deltaX = scrollDeltaNeededForZoomChange(this.zoom, newZoom, pivotX, scrollX)
+        val deltaY = scrollDeltaNeededForZoomChange(this.zoom, newZoom, pivotY, scrollY)
+
+        this.zoom = newZoom
+        scrollBy(deltaX, deltaY)
+    }
+
+    private fun scrollDeltaNeededForZoomChange(
+        oldZoom: Float,
+        newZoom: Float,
+        pivot: Float,
+        scroll: Int,
+    ): Int {
+        // Find where the given pivot point would move to when we change the zoom, and return the
+        // delta.
+        val contentPivot = toContentCoord(pivot, oldZoom, scroll)
+        val movedZoomViewPivot: Float = toViewCoord(contentPivot, newZoom, scroll)
+        return (movedZoomViewPivot - pivot).toInt()
+    }
+
+    /**
+     * 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 {
+        visibleAreaRect.set(
+            toContentX(-paddingLeft.toFloat()).toInt(),
+            toContentY(-paddingTop.toFloat()).toInt(),
+            toContentX(viewportWidth.toFloat() + paddingRight).toInt(),
+            toContentY(viewportHeight.toFloat() + paddingBottom).toInt(),
+        )
+        return visibleAreaRect
+    }
+
+    /** The height of the viewport, minus padding */
+    private val viewportHeight: Int
+        get() = bottom - top - paddingBottom - paddingTop
+
+    /** The width of the viewport, minus padding */
+    private val viewportWidth: Int
+        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 {
+        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 {
+        return toContentCoord(viewY, zoom, scrollY)
+    }
+
+    private fun toViewCoord(contentCoord: Float, zoom: Float, scroll: Int): Float {
+        return (contentCoord * zoom) - scroll
+    }
+
+    /**
+     * Converts a one-dimensional coordinate in View space to a one-dimensional coordinate in
+     * content space
+     */
+    private fun toContentCoord(viewCoord: Float, zoom: Float, scroll: Int): Float {
+        return (viewCoord + scroll) / zoom
+    }
+
+    /** Helper to use [PdfDocument] behind a mutex to ensure FIFO semantics for requests */
+    private suspend fun <T> withPdfDocument(block: suspend (PdfDocument) -> T): T {
+        pdfDocumentMutex.withLock {
+            val localPdfDocument = pdfDocument ?: throw IllegalStateException("No PdfDocument")
+            return block(localPdfDocument)
+        }
+    }
+
+    public companion object {
+        public const val DEFAULT_PAGE_SPACING_PX: Int = 20
+        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
+
+        private fun checkMainThread() {
+            check(Looper.myLooper() == Looper.getMainLooper()) {
+                "Property must be set on the main thread"
+            }
+        }
+    }
+}
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
new file mode 100644
index 0000000..42b4127
--- /dev/null
+++ b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/view/ZoomScrollGestureHandler.kt
@@ -0,0 +1,170 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR 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.updateVisibleContent()
+            }
+            GestureTracker.Gesture.DRAG,
+            GestureTracker.Gesture.DRAG_Y,
+            GestureTracker.Gesture.DRAG_X -> {
+                scrollInProgress = false
+                pdfView.updateVisibleContent()
+            }
+            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/test/kotlin/androidx/pdf/view/GestureTrackerTest.kt b/pdf/pdf-viewer/src/test/kotlin/androidx/pdf/view/GestureTrackerTest.kt
new file mode 100644
index 0000000..c40037c
--- /dev/null
+++ b/pdf/pdf-viewer/src/test/kotlin/androidx/pdf/view/GestureTrackerTest.kt
@@ -0,0 +1,609 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR 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.Point
+import android.graphics.PointF
+import android.view.MotionEvent
+import android.view.ViewConfiguration
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.filters.SmallTest
+import com.google.common.truth.Truth.assertThat
+import java.util.concurrent.TimeUnit
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.kotlin.any
+import org.mockito.kotlin.atLeastOnce
+import org.mockito.kotlin.eq
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.never
+import org.mockito.kotlin.times
+import org.mockito.kotlin.verify
+import org.mockito.kotlin.verifyNoMoreInteractions
+import org.mockito.kotlin.whenever
+import org.robolectric.Robolectric
+import org.robolectric.RobolectricTestRunner
+
+@SmallTest
+@RunWith(RobolectricTestRunner::class)
+class GestureTrackerTest {
+    private val gestureHandlerSpy =
+        mock<GestureTracker.GestureHandler>().apply {
+            // We must return true from these listeners or GestureDetector won't continue sending
+            // us callbacks
+            whenever(onScaleBegin(any())).thenReturn(true)
+            whenever(onScale(any())).thenReturn(true)
+        }
+
+    private val context = ApplicationProvider.getApplicationContext<Context>()
+    private lateinit var gestureTracker: GestureTracker
+
+    @Before
+    fun setup() {
+        // Initialize GestureTracker before each case to avoid state leaking between tests
+        gestureTracker = GestureTracker(context).apply { delegate = gestureHandlerSpy }
+    }
+
+    @Test
+    fun testSingleTap() {
+        gestureTracker.feed(down(PointF(50f, 50f)))
+        gestureTracker.feed(up(PointF(50f, 50f)))
+        // We expected to issue onGestureStart and onSingleTapUp callbacks, and we expect the
+        // current detected gesture to be FIRST_TAP (single tap that still may become a double tap)
+        verify(gestureHandlerSpy).onGestureStart()
+        verify(gestureHandlerSpy).onSingleTapUp(any())
+        assertThat(gestureTracker.matches(GestureTracker.Gesture.FIRST_TAP)).isTrue()
+
+        // Advance time by the double tap timeout
+        Robolectric.getForegroundThreadScheduler()
+            .advanceBy(ViewConfiguration.getDoubleTapTimeout().toLong(), TimeUnit.MILLISECONDS)
+
+        // We expect to issue onSingleTapConfirmed and onGestureEnd callbacks, and we expect the
+        // current detect gesture to be a SINGLE_TAP (i.e. confirmed *not* to be a double tap)
+        verify(gestureHandlerSpy).onSingleTapConfirmed(any())
+        verify(gestureHandlerSpy).onGestureEnd(eq(GestureTracker.Gesture.SINGLE_TAP))
+        assertThat(gestureTracker.matches(GestureTracker.Gesture.SINGLE_TAP)).isTrue()
+
+        verifyNoMoreInteractions(gestureHandlerSpy)
+    }
+
+    @Test
+    fun testDoubleTap() {
+        val downTime = Robolectric.getForegroundThreadScheduler().currentTime
+        gestureTracker.feed(down(PointF(50f, 50f), time = downTime))
+        gestureTracker.feed(up(PointF(50f, 50f), downTime = downTime))
+        // We expected to issue onGestureStart and onSingleTapUp callbacks, and we expect the
+        // current detected gesture to be FIRST_TAP (single tap that still may become a double tap)
+        verify(gestureHandlerSpy).onGestureStart()
+        verify(gestureHandlerSpy).onSingleTapUp(any())
+        assertThat(gestureTracker.matches(GestureTracker.Gesture.FIRST_TAP)).isTrue()
+
+        // Advance time by less than the double tap timeout, and issue another up / down sequence.
+        // The minimum time between down events to detect a double tap is a hidden API in
+        // ViewConfiguration, so use half the maximum time.
+        Robolectric.getForegroundThreadScheduler()
+            .advanceBy(ViewConfiguration.getDoubleTapTimeout().toLong() / 2, TimeUnit.MILLISECONDS)
+        gestureTracker.feed(down(PointF(50f, 50f)))
+        gestureTracker.feed(up(PointF(50f, 50f), downTime = downTime))
+
+        // We expect to issue onDoubleTap and onGestureEnd callbacks; we expect to *not* have issued
+        // an onSingleTapConfirmed callback; and we expect the current detected gesture to be a
+        // DOUBLE_TAP
+        verify(gestureHandlerSpy, times(2)).onSingleTapUp(any())
+        verify(gestureHandlerSpy, never()).onSingleTapConfirmed(any())
+        verify(gestureHandlerSpy).onDoubleTap(any())
+        verify(gestureHandlerSpy).onGestureEnd(eq(GestureTracker.Gesture.DOUBLE_TAP))
+        assertThat(gestureTracker.matches(GestureTracker.Gesture.DOUBLE_TAP)).isTrue()
+
+        verifyNoMoreInteractions(gestureHandlerSpy)
+    }
+
+    @Test
+    fun testLongPress() {
+        val downTime = Robolectric.getForegroundThreadScheduler().currentTime
+        gestureTracker.feed(down(PointF(50f, 50f), time = downTime))
+        // We expected to issue an onGestureStart and onSingleTapUp callback, and we expect the
+        // current detected gesture to be TOUCH (Down with no Up yet)
+        verify(gestureHandlerSpy).onGestureStart()
+        assertThat(gestureTracker.matches(GestureTracker.Gesture.TOUCH)).isTrue()
+
+        // Advance time by the long press timeout
+        Robolectric.getForegroundThreadScheduler()
+            .advanceBy(ViewConfiguration.getLongPressTimeout().toLong() + 1, TimeUnit.MILLISECONDS)
+        gestureTracker.feed(up(PointF(50f, 50f), downTime = downTime))
+
+        // We shouldn't have issued these callbacks
+        verify(gestureHandlerSpy, never()).onSingleTapConfirmed(any())
+        verify(gestureHandlerSpy, never()).onSingleTapUp(any())
+        // We expect to have issued onShowPress, onLongPress, and onGestureEnd callbacks, and we
+        // expect the current detected gesture to be a LONG_PRESS
+        verify(gestureHandlerSpy).onShowPress(any())
+        verify(gestureHandlerSpy).onLongPress(any())
+        verify(gestureHandlerSpy).onGestureEnd(eq(GestureTracker.Gesture.LONG_PRESS))
+        assertThat(gestureTracker.matches(GestureTracker.Gesture.LONG_PRESS)).isTrue()
+
+        verifyNoMoreInteractions(gestureHandlerSpy)
+    }
+
+    @Test
+    fun testDragX() {
+        for (event in
+            oneFingerDrag(
+                start = PointF(50f, 50f),
+                velocity = Point(ViewConfiguration.get(context).scaledMinimumFlingVelocity / 2, 0)
+            )) {
+            gestureTracker.feed(event)
+        }
+
+        verify(gestureHandlerSpy).onGestureStart()
+        verify(gestureHandlerSpy, atLeastOnce()).onScroll(any(), any(), any(), any())
+        verify(gestureHandlerSpy).onGestureEnd(eq(GestureTracker.Gesture.DRAG_X))
+        assertThat(gestureTracker.matches(GestureTracker.Gesture.DRAG_X)).isTrue()
+
+        verifyNoMoreInteractions(gestureHandlerSpy)
+    }
+
+    @Test
+    fun testDragY() {
+        for (event in
+            oneFingerDrag(
+                start = PointF(50f, 50f),
+                velocity = Point(0, ViewConfiguration.get(context).scaledMinimumFlingVelocity / 2),
+            )) {
+            gestureTracker.feed(event)
+        }
+
+        verify(gestureHandlerSpy).onGestureStart()
+        verify(gestureHandlerSpy, atLeastOnce()).onScroll(any(), any(), any(), any())
+        verify(gestureHandlerSpy).onGestureEnd(eq(GestureTracker.Gesture.DRAG_Y))
+        assertThat(gestureTracker.matches(GestureTracker.Gesture.DRAG_Y)).isTrue()
+
+        verifyNoMoreInteractions(gestureHandlerSpy)
+    }
+
+    @Test
+    fun testDrag() {
+        val velocity = ViewConfiguration.get(context).scaledMinimumFlingVelocity / 4
+        for (event in
+            oneFingerDrag(
+                start = PointF(50f, 50f),
+                velocity = Point(velocity, velocity),
+            )) {
+            gestureTracker.feed(event)
+        }
+
+        verify(gestureHandlerSpy).onGestureStart()
+        verify(gestureHandlerSpy, atLeastOnce()).onScroll(any(), any(), any(), any())
+        verify(gestureHandlerSpy).onGestureEnd(eq(GestureTracker.Gesture.DRAG))
+        assertThat(gestureTracker.matches(GestureTracker.Gesture.DRAG)).isTrue()
+
+        verifyNoMoreInteractions(gestureHandlerSpy)
+    }
+
+    @Test
+    fun testFling() {
+        val velocity = ViewConfiguration.get(context).scaledMinimumFlingVelocity * 2
+        for (event in
+            oneFingerDrag(
+                start = PointF(50f, 50f),
+                velocity = Point(velocity, velocity),
+            )) {
+            gestureTracker.feed(event)
+        }
+
+        verify(gestureHandlerSpy).onGestureStart()
+        verify(gestureHandlerSpy, atLeastOnce()).onFling(any(), any(), any(), any())
+        verify(gestureHandlerSpy, atLeastOnce()).onScroll(any(), any(), any(), any())
+        verify(gestureHandlerSpy).onGestureEnd(eq(GestureTracker.Gesture.FLING))
+        assertThat(gestureTracker.matches(GestureTracker.Gesture.FLING)).isTrue()
+
+        verifyNoMoreInteractions(gestureHandlerSpy)
+    }
+
+    @Test
+    fun testZoomIn_pinch() {
+        // Drag pointer 1 in the positive X direction from (50, 50) at the same time as dragging
+        // pointer 2 in the negative X direction from (500, 500)
+        val velocity = ViewConfiguration.get(context).scaledMinimumFlingVelocity / 2
+        val start1 = PointF(50f, 50f)
+        val start2 = PointF(500f, 500f)
+        val velocity1 = Point(velocity, 0)
+        val velocity2 = Point(-velocity, 0)
+        for (event in twoFingerDrag(start1, start2, velocity1, velocity2)) {
+            gestureTracker.feed(event)
+        }
+
+        verify(gestureHandlerSpy).onGestureStart()
+        verify(gestureHandlerSpy, atLeastOnce()).onScaleBegin(any())
+        verify(gestureHandlerSpy, atLeastOnce()).onScale(any())
+        verify(gestureHandlerSpy, atLeastOnce()).onScaleEnd(any())
+        assertThat(gestureTracker.matches(GestureTracker.Gesture.ZOOM)).isTrue()
+
+        verifyNoMoreInteractions(gestureHandlerSpy)
+    }
+
+    @Test
+    fun testZoomOut_pinch() {
+        // Drag pointer 1 in the negative Y direction from (500, 500) at the same time as dragging
+        // pointer 2 in the positive Y direction from (500, 500)
+        val velocity = ViewConfiguration.get(context).scaledMinimumFlingVelocity / 2
+        val start1 = PointF(500f, 500f)
+        val start2 = PointF(500f, 500f)
+        val velocity1 = Point(0, -velocity)
+        val velocity2 = Point(0, velocity)
+        for (event in twoFingerDrag(start1, start2, velocity1, velocity2)) {
+            gestureTracker.feed(event)
+        }
+
+        verify(gestureHandlerSpy).onGestureStart()
+        verify(gestureHandlerSpy, atLeastOnce()).onScaleBegin(any())
+        verify(gestureHandlerSpy, atLeastOnce()).onScale(any())
+        verify(gestureHandlerSpy, atLeastOnce()).onScaleEnd(any())
+        assertThat(gestureTracker.matches(GestureTracker.Gesture.ZOOM)).isTrue()
+
+        verifyNoMoreInteractions(gestureHandlerSpy)
+    }
+
+    @Test
+    fun testZoom_quickScale() {
+        // First, send a single tap
+        val startPoint = PointF(50f, 50f)
+        val downTime = Robolectric.getForegroundThreadScheduler().currentTime
+        gestureTracker.feed(down(startPoint, time = downTime))
+        gestureTracker.feed(up(startPoint, downTime = downTime))
+        // Then, advance time by less than the double tap timeout, and tap again, but don't release
+        // the pointer
+        // The minimum time between down events to detect a double tap is a hidden API in
+        // ViewConfiguration, so use a fraction of the maximum time.
+        Robolectric.getForegroundThreadScheduler()
+            .advanceBy(ViewConfiguration.getDoubleTapTimeout().toLong() / 5, TimeUnit.MILLISECONDS)
+        gestureTracker.feed(down(startPoint))
+        // Finally, tap and drag in the +y direction from same point
+        val velocity = ViewConfiguration.get(context).scaledMinimumFlingVelocity / 2
+        for (event in oneFingerDrag(startPoint, Point(0, velocity), skipDown = true)) {
+            gestureTracker.feed(event)
+        }
+
+        // These are intermediate callbacks we expect to receive while the quick scale gesture is
+        // being formed
+        verify(gestureHandlerSpy).onSingleTapUp(any())
+        verify(gestureHandlerSpy).onShowPress(any())
+        verify(gestureHandlerSpy).onLongPress(any())
+        // These are the callbacks we really want to receive after the quick scale gesture is made
+        verify(gestureHandlerSpy).onGestureStart()
+        verify(gestureHandlerSpy, atLeastOnce()).onScaleBegin(any())
+        verify(gestureHandlerSpy, atLeastOnce()).onScale(any())
+        verify(gestureHandlerSpy, atLeastOnce()).onScaleEnd(any())
+        verify(gestureHandlerSpy).onGestureEnd(eq(GestureTracker.Gesture.ZOOM))
+        assertThat(gestureTracker.matches(GestureTracker.Gesture.ZOOM)).isTrue()
+
+        verifyNoMoreInteractions(gestureHandlerSpy)
+    }
+
+    @Test
+    fun testTap_thenDrag_noDoubleTap() {
+        // First, issue a single tap and release the pointer
+        val point = PointF(50f, 50f)
+        gestureTracker.feed(down(point))
+        gestureTracker.feed(up(point))
+        // We expected to issue onGestureStart and onSingleTapUp callbacks, and we expect the
+        // current detected gesture to be FIRST_TAP (single tap that still may become a double tap)
+        verify(gestureHandlerSpy).onGestureStart()
+        verify(gestureHandlerSpy).onSingleTapUp(any())
+        assertThat(gestureTracker.matches(GestureTracker.Gesture.FIRST_TAP)).isTrue()
+
+        // Then, drag from the same point
+        for (event in
+            oneFingerDrag(
+                point,
+                velocity = Point(ViewConfiguration.get(context).scaledMinimumFlingVelocity / 2, 0)
+            )) {
+            gestureTracker.feed(event)
+        }
+
+        // These are the terminal callbacks from the completion of the single tap
+        verify(gestureHandlerSpy).onSingleTapConfirmed(any())
+        verify(gestureHandlerSpy).onGestureEnd(eq(GestureTracker.Gesture.SINGLE_TAP))
+        // These are the callbacks we expect to receive as part of the scroll gesture
+        verify(gestureHandlerSpy, times(2)).onGestureStart()
+        verify(gestureHandlerSpy, atLeastOnce()).onScroll(any(), any(), any(), any())
+        verify(gestureHandlerSpy).onGestureEnd(GestureTracker.Gesture.DRAG_X)
+        assertThat(gestureTracker.matches(GestureTracker.Gesture.DRAG_X)).isTrue()
+        // And we should never have detected a double tap
+        verify(gestureHandlerSpy, never()).onDoubleTap(any())
+
+        verifyNoMoreInteractions(gestureHandlerSpy)
+    }
+
+    @Test
+    fun testDrag_thenTap_noDoubleTap() {
+        val point = PointF(50f, 50f)
+        // First, drag
+        for (event in
+            oneFingerDrag(
+                point,
+                velocity = Point(ViewConfiguration.get(context).scaledMinimumFlingVelocity / 2, 0)
+            )) {
+            gestureTracker.feed(event)
+        }
+        // These are the callbacks we expect to receive as part of the scroll
+        verify(gestureHandlerSpy).onGestureStart()
+        verify(gestureHandlerSpy, atLeastOnce()).onScroll(any(), any(), any(), any())
+        verify(gestureHandlerSpy).onGestureEnd(GestureTracker.Gesture.DRAG_X)
+        assertThat(gestureTracker.matches(GestureTracker.Gesture.DRAG_X)).isTrue()
+
+        // Then, issue a single tap from the same point
+        gestureTracker.feed(down(point))
+        gestureTracker.feed(up(point))
+        // Advance time by the double tap timeout
+        Robolectric.getForegroundThreadScheduler()
+            .advanceBy(ViewConfiguration.getDoubleTapTimeout().toLong(), TimeUnit.MILLISECONDS)
+
+        // These are the callbacks we expect to receive as part of the single tap
+        verify(gestureHandlerSpy, times(2)).onGestureStart()
+        verify(gestureHandlerSpy).onSingleTapUp(any())
+        verify(gestureHandlerSpy).onSingleTapConfirmed(any())
+        verify(gestureHandlerSpy).onGestureEnd(eq(GestureTracker.Gesture.SINGLE_TAP))
+        assertThat(gestureTracker.matches(GestureTracker.Gesture.SINGLE_TAP)).isTrue()
+        // And we should never have detected a double tap
+        verify(gestureHandlerSpy, never()).onDoubleTap(any())
+
+        verifyNoMoreInteractions(gestureHandlerSpy)
+    }
+
+    @Test
+    fun testTwoQuickDrags_sameDownPosition_noQuickScale() {
+        // Drag once
+        val point = PointF(50f, 50f)
+        for (event in
+            oneFingerDrag(
+                start = point,
+                velocity = Point(ViewConfiguration.get(context).scaledMinimumFlingVelocity / 2, 0)
+            )) {
+            gestureTracker.feed(event)
+        }
+        // Make sure we detect one set of drag / scroll callbacks
+        verify(gestureHandlerSpy).onGestureStart()
+        verify(gestureHandlerSpy, atLeastOnce()).onScroll(any(), any(), any(), any())
+        verify(gestureHandlerSpy).onGestureEnd(eq(GestureTracker.Gesture.DRAG_X))
+        assertThat(gestureTracker.matches(GestureTracker.Gesture.DRAG_X)).isTrue()
+
+        // Then, advance time by less than the double tap timeout
+        Robolectric.getForegroundThreadScheduler()
+            .advanceBy(ViewConfiguration.getDoubleTapTimeout().toLong() / 2, TimeUnit.MILLISECONDS)
+        // Finally, drag again, from the same point
+        for (event in
+            oneFingerDrag(
+                start = point,
+                velocity = Point(ViewConfiguration.get(context).scaledMinimumFlingVelocity / 2, 0)
+            )) {
+            gestureTracker.feed(event)
+        }
+
+        // Make sure we detected a second set of drag callbacks
+        verify(gestureHandlerSpy, times(2)).onGestureStart()
+        verify(gestureHandlerSpy, atLeastOnce()).onScroll(any(), any(), any(), any())
+        verify(gestureHandlerSpy, times(2)).onGestureEnd(eq(GestureTracker.Gesture.DRAG_X))
+        assertThat(gestureTracker.matches(GestureTracker.Gesture.DRAG_X)).isTrue()
+        // And that we never detected a zoom / quick scale
+        verify(gestureHandlerSpy, never()).onScale(any())
+
+        verifyNoMoreInteractions(gestureHandlerSpy)
+    }
+}
+
+/**
+ * Returns a [MotionEvent] with [action] at [downTime] and [location], and with [eventTime] as the
+ * time the corresponding [MotionEvent.ACTION_DOWN] was issued
+ */
+private fun motionEvent(
+    action: Int,
+    location: PointF,
+    downTime: Long = Robolectric.getForegroundThreadScheduler().currentTime,
+    eventTime: Long = Robolectric.getForegroundThreadScheduler().currentTime,
+) = MotionEvent.obtain(downTime, eventTime, action, location.x, location.y, 0)
+
+/** Returns a [MotionEvent] with [MotionEvent.ACTION_DOWN] at [time] and [location] */
+private fun down(
+    location: PointF,
+    time: Long = Robolectric.getForegroundThreadScheduler().currentTime,
+) = motionEvent(MotionEvent.ACTION_DOWN, location, downTime = time, eventTime = time)
+
+/**
+ * Returns a [MotionEvent] with [MotionEvent.ACTION_MOVE] at [eventTime] and [location] and with
+ * [downTime] as the time the corresponding [MotionEvent.ACTION_DOWN] was issued
+ */
+private fun move(
+    location: PointF,
+    downTime: Long = Robolectric.getForegroundThreadScheduler().currentTime,
+    eventTime: Long = Robolectric.getForegroundThreadScheduler().currentTime,
+) = motionEvent(MotionEvent.ACTION_MOVE, location, downTime, eventTime)
+
+/**
+ * Returns a [MotionEvent] with [MotionEvent.ACTION_UP] at [eventTime] and [location] and with
+ * [downTime] as the time the corresponding [MotionEvent.ACTION_DOWN] was issued
+ */
+private fun up(
+    location: PointF,
+    downTime: Long = Robolectric.getForegroundThreadScheduler().currentTime,
+    eventTime: Long = Robolectric.getForegroundThreadScheduler().currentTime,
+) = motionEvent(MotionEvent.ACTION_UP, location, downTime, eventTime)
+
+/**
+ * Returns a [List] of [MotionEvent] simulating a user dragging one pointers, from [start] at
+ * [velocity]
+ */
+private fun oneFingerDrag(
+    start: PointF,
+    velocity: Point,
+    downTime: Long = Robolectric.getForegroundThreadScheduler().currentTime,
+    skipDown: Boolean = false
+): List<MotionEvent> {
+    val sequence = mutableListOf<MotionEvent>()
+    if (!skipDown) sequence.add(down(start, time = downTime))
+    var x = start.x
+    var y = start.y
+    // Record 1 move event every 10ms
+    for (i in 0..1000 step 10) {
+        // Pixels per second, 10ms step
+        x += 0.01f * velocity.x
+        y += 0.01f * velocity.y
+        Robolectric.getForegroundThreadScheduler().advanceBy(10, TimeUnit.MILLISECONDS)
+        sequence.add(move(PointF(x, y), downTime = downTime))
+    }
+    sequence.add(up(PointF(x, y), downTime = downTime))
+    return sequence.toList()
+}
+
+/**
+ * Returns a [List] of [MotionEvent] simulating a user dragging 2 pointers simultaneously, from
+ * [start1] and [start2] at [velocity1] and [velocity2], for 1 second
+ */
+private fun twoFingerDrag(
+    start1: PointF,
+    start2: PointF,
+    velocity1: Point,
+    velocity2: Point
+): List<MotionEvent> {
+    // Specify the touch properties for the finger events.
+    val pp1 = MotionEvent.PointerProperties()
+    pp1.id = 0
+    pp1.toolType = MotionEvent.TOOL_TYPE_FINGER
+    val pp2 = MotionEvent.PointerProperties()
+    pp2.id = 1
+    pp2.toolType = MotionEvent.TOOL_TYPE_FINGER
+    val pointerProperties = arrayOf(pp1, pp2)
+
+    // Specify the motion properties of the two touch points.
+    val pc1 = MotionEvent.PointerCoords()
+    pc1.x = start1.x
+    pc1.y = start1.y
+    pc1.pressure = 1f
+    pc1.size = 1f
+    val pc2 = MotionEvent.PointerCoords()
+    pc2.x = start2.x
+    pc2.y = start2.y
+    pc2.pressure = 1f
+    pc2.size = 1f
+    val pointerCoords = arrayOf(pc1, pc2)
+
+    // Two down events, 1 for each pointer
+    val downTime = Robolectric.getForegroundThreadScheduler().currentTime
+    val firstFingerEvent =
+        MotionEvent.obtain(
+            downTime,
+            Robolectric.getForegroundThreadScheduler().currentTime,
+            MotionEvent.ACTION_DOWN,
+            1,
+            pointerProperties,
+            pointerCoords,
+            0,
+            0,
+            1f,
+            1f,
+            0,
+            0,
+            0,
+            0
+        )
+    val secondFingerEvent =
+        MotionEvent.obtain(
+            downTime,
+            Robolectric.getForegroundThreadScheduler().currentTime,
+            MotionEvent.ACTION_POINTER_DOWN + (pp2.id shl MotionEvent.ACTION_POINTER_INDEX_SHIFT),
+            2,
+            pointerProperties,
+            pointerCoords,
+            0,
+            0,
+            1f,
+            1f,
+            0,
+            0,
+            0,
+            0
+        )
+    val sequence = mutableListOf(firstFingerEvent, secondFingerEvent)
+    // Compute a series of ACTION_MOVE events with interpolated coordinates for each pointer
+    for (i in 0..1000 step 10) {
+        pc1.x += 0.01f * velocity1.x
+        pc1.y += 0.01f * velocity1.y
+        pc2.x += 0.01f * velocity2.x
+        pc2.y += 0.01f * velocity2.y
+
+        Robolectric.getForegroundThreadScheduler().advanceBy(10, TimeUnit.MILLISECONDS)
+        val twoPointerMove =
+            MotionEvent.obtain(
+                downTime,
+                Robolectric.getForegroundThreadScheduler().currentTime,
+                MotionEvent.ACTION_MOVE,
+                2,
+                pointerProperties,
+                pointerCoords,
+                0,
+                0,
+                1f,
+                1f,
+                0,
+                0,
+                0,
+                0
+            )
+        sequence.add(twoPointerMove)
+    }
+    // Send 2 up events, one for each pointer
+    val secondFingerUpEvent =
+        MotionEvent.obtain(
+            downTime,
+            Robolectric.getForegroundThreadScheduler().currentTime,
+            MotionEvent.ACTION_POINTER_UP,
+            2,
+            pointerProperties,
+            pointerCoords,
+            0,
+            0,
+            1f,
+            1f,
+            0,
+            0,
+            0,
+            0
+        )
+    val firstFingerUpEvent =
+        MotionEvent.obtain(
+            downTime,
+            Robolectric.getForegroundThreadScheduler().currentTime,
+            MotionEvent.ACTION_POINTER_UP,
+            1,
+            pointerProperties,
+            pointerCoords,
+            0,
+            0,
+            1f,
+            1f,
+            0,
+            0,
+            0,
+            0
+        )
+    sequence.add(secondFingerUpEvent)
+    sequence.add(firstFingerUpEvent)
+
+    return sequence.toList()
+}
diff --git a/pdf/pdf-viewer/src/test/kotlin/androidx/pdf/view/PaginationModelTest.kt b/pdf/pdf-viewer/src/test/kotlin/androidx/pdf/view/PaginationModelTest.kt
new file mode 100644
index 0000000..2d98b58
--- /dev/null
+++ b/pdf/pdf-viewer/src/test/kotlin/androidx/pdf/view/PaginationModelTest.kt
@@ -0,0 +1,329 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR 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 android.graphics.Rect
+import android.os.Parcel
+import androidx.test.filters.SmallTest
+import com.google.common.truth.Truth.assertThat
+import kotlin.math.max
+import kotlin.random.Random
+import org.junit.Assert.assertThrows
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+@SmallTest
+@RunWith(RobolectricTestRunner::class)
+class PaginationModelTest {
+    private val NUM_PAGES = 250
+    private val PAGE_SPACING_PX = 5
+    private lateinit var paginationModel: PaginationModel
+
+    @Before
+    fun setup() {
+        paginationModel = PaginationModel(pageSpacingPx = PAGE_SPACING_PX, numPages = NUM_PAGES)
+    }
+
+    @Test
+    fun invalidConstructorArguments() {
+        assertThrows(IllegalArgumentException::class.java) {
+            PaginationModel(pageSpacingPx = -1, numPages = 10)
+        }
+        assertThrows(IllegalArgumentException::class.java) {
+            PaginationModel(pageSpacingPx = 10, numPages = -1)
+        }
+    }
+
+    @Test
+    fun propertyDefaults_withNoPagesAdded() {
+        assertThat(paginationModel.reach).isEqualTo(0)
+        assertThat(paginationModel.maxWidth).isEqualTo(0)
+        assertThat(paginationModel.totalEstimatedHeight).isEqualTo(0)
+    }
+
+    @Test
+    fun propertyValues_withSomePagesAdded() {
+        var totalHeight = 0
+        var maxWidth = 0
+        val rng = Random(System.currentTimeMillis())
+        val knownPages = NUM_PAGES / 2
+
+        for (i in 0 until knownPages) {
+            val pageSize = Point(rng.nextInt(50, 100), rng.nextInt(100, 200))
+            maxWidth = max(maxWidth, pageSize.x)
+            totalHeight += pageSize.y
+            paginationModel.addPage(i, pageSize)
+        }
+
+        assertThat(paginationModel.reach).isEqualTo(knownPages)
+        // Accumulated height of all added pages, plus 2 x page spacing * known pages
+        val totalKnownHeight = totalHeight + PAGE_SPACING_PX * (knownPages - 1)
+        // (Average height of all known pages + 2 x page spacing) * unknown pages
+        val estimatedRemainingHeight =
+            ((totalHeight / knownPages) + PAGE_SPACING_PX) * (NUM_PAGES - knownPages)
+        assertThat(paginationModel.totalEstimatedHeight)
+            .isEqualTo(estimatedRemainingHeight + totalKnownHeight)
+        assertThat(paginationModel.maxWidth).isEqualTo(maxWidth)
+    }
+
+    @Test
+    fun propertyValues_withAllPagesAdded() {
+        var totalHeight = 0
+        var maxWidth = 0
+        val rng = Random(System.currentTimeMillis())
+        for (i in 0 until NUM_PAGES) {
+            val pageSize = Point(rng.nextInt(50, 100), rng.nextInt(100, 200))
+            maxWidth = max(maxWidth, pageSize.x)
+            totalHeight += pageSize.y
+            paginationModel.addPage(i, pageSize)
+        }
+
+        assertThat(paginationModel.reach).isEqualTo(NUM_PAGES)
+        assertThat(paginationModel.totalEstimatedHeight)
+            .isEqualTo(totalHeight + PAGE_SPACING_PX * NUM_PAGES)
+        assertThat(paginationModel.maxWidth).isEqualTo(maxWidth)
+    }
+
+    @Test
+    fun rejectInvalidPage() {
+        assertThrows(IllegalArgumentException::class.java) {
+            paginationModel.addPage(0, Point(100, -1))
+        }
+        assertThrows(IllegalArgumentException::class.java) {
+            paginationModel.addPage(0, Point(-1, 100))
+        }
+        assertThrows(IllegalArgumentException::class.java) {
+            paginationModel.addPage(-1, Point(100, 200))
+        }
+        assertThrows(IllegalArgumentException::class.java) {
+            paginationModel.addPage(NUM_PAGES + 10, Point(100, 200))
+        }
+    }
+
+    @Test
+    fun getPageSize() {
+        val sizeRng = Random(System.currentTimeMillis())
+        val sizes =
+            List(size = 3) { _ -> Point(sizeRng.nextInt(100, 200), sizeRng.nextInt(100, 200)) }
+
+        sizes.forEachIndexed { pageNum, size -> paginationModel.addPage(pageNum, size) }
+
+        sizes.forEachIndexed { pageNum, size ->
+            assertThat(paginationModel.getPageSize(pageNum)).isEqualTo(size)
+        }
+    }
+
+    @Test
+    fun getPageSize_invalidPageNum() {
+        assertThrows(IllegalArgumentException::class.java) { paginationModel.getPageSize(-1) }
+        assertThrows(IllegalArgumentException::class.java) {
+            paginationModel.getPageSize(NUM_PAGES + 10)
+        }
+    }
+
+    @Test
+    fun getPagesInViewport_viewportAboveAllPages() {
+        val pageSize = Point(100, 200)
+        paginationModel.addPage(0, pageSize)
+        paginationModel.addPage(1, pageSize)
+        paginationModel.addPage(2, pageSize)
+
+        val visiblePages =
+            paginationModel.getPagesInViewport(viewportTop = -100, viewportBottom = 0)
+
+        // When the viewport is above the top of this model, we expect an empty range at the
+        // beginning of this model
+        assertThat(visiblePages.upper).isEqualTo(0)
+        assertThat(visiblePages.lower).isEqualTo(0)
+    }
+
+    @Test
+    fun getPagesInViewport_viewportBelowAllPages() {
+        val pageSize = Point(100, 200)
+        paginationModel.addPage(0, pageSize)
+        paginationModel.addPage(1, pageSize)
+        paginationModel.addPage(2, pageSize)
+        val contentBottom = pageSize.y * 3 + PAGE_SPACING_PX * 5
+
+        val visiblePages =
+            paginationModel.getPagesInViewport(
+                viewportTop = contentBottom + 10,
+                viewportBottom = contentBottom + 100
+            )
+
+        // When the viewport is below the end of this model, we expect an empty range just past
+        // the last known page
+        assertThat(visiblePages.upper).isEqualTo(3)
+        assertThat(visiblePages.lower).isEqualTo(3)
+    }
+
+    @Test
+    fun getPagesInViewport_allPagesVisible() {
+        val pageSize = Point(100, 200)
+        paginationModel.addPage(0, pageSize)
+        paginationModel.addPage(1, pageSize)
+        paginationModel.addPage(2, pageSize)
+        val contentBottom = pageSize.y * 3 + PAGE_SPACING_PX * 5
+
+        val visiblePages =
+            paginationModel.getPagesInViewport(viewportTop = 0, viewportBottom = contentBottom + 10)
+
+        assertThat(visiblePages.upper).isEqualTo(2)
+        assertThat(visiblePages.lower).isEqualTo(0)
+    }
+
+    @Test
+    fun getPagesInViewport_onePagePartiallyVisible() {
+        val pageSize = Point(100, 200)
+        paginationModel.addPage(0, pageSize)
+        paginationModel.addPage(1, pageSize)
+        paginationModel.addPage(2, pageSize)
+
+        val visiblePages =
+            paginationModel.getPagesInViewport(viewportTop = 235, viewportBottom = 335)
+
+        assertThat(visiblePages.upper).isEqualTo(1)
+        assertThat(visiblePages.lower).isEqualTo(1)
+    }
+
+    @Test
+    fun getPagesInViewport_twoPagesPartiallyVisible() {
+        val pageSize = Point(100, 200)
+        paginationModel.addPage(0, pageSize)
+        paginationModel.addPage(1, pageSize)
+        paginationModel.addPage(2, pageSize)
+
+        val visiblePages =
+            paginationModel.getPagesInViewport(viewportTop = 235, viewportBottom = 455)
+
+        assertThat(visiblePages.upper).isEqualTo(2)
+        assertThat(visiblePages.lower).isEqualTo(1)
+    }
+
+    @Test
+    fun getPagesInViewport_multiplePagesVisible() {
+        val pageSize = Point(100, 200)
+        paginationModel.addPage(0, pageSize)
+        paginationModel.addPage(1, pageSize)
+        paginationModel.addPage(2, pageSize)
+        paginationModel.addPage(3, pageSize)
+
+        val visiblePages =
+            paginationModel.getPagesInViewport(viewportTop = 210, viewportBottom = 840)
+
+        assertThat(visiblePages.upper).isEqualTo(3)
+        assertThat(visiblePages.lower).isEqualTo(1)
+    }
+
+    /**
+     * Add 3 pages of differing sizes to the model. Set the visible area to cover the whole model.
+     * Largest page should span (0, model width). Smaller pages should be placed in the middle of
+     * the model horizontally. Pages should get consistent vertical spacing.
+     */
+    @Test
+    fun getPageLocation_viewportCoversAllPages() {
+        val smallSize = Point(200, 100)
+        val mediumSize = Point(400, 200)
+        val largeSize = Point(800, 400)
+        paginationModel.addPage(0, smallSize)
+        paginationModel.addPage(1, mediumSize)
+        paginationModel.addPage(2, largeSize)
+        val viewport = Rect(0, 0, 800, 800)
+
+        val expectedSmLocation = Rect(300, 0, 500, 100)
+        assertThat(paginationModel.getPageLocation(0, viewport)).isEqualTo(expectedSmLocation)
+
+        val expectedMdLocation = Rect(200, 100 + PAGE_SPACING_PX, 600, 300 + PAGE_SPACING_PX)
+        assertThat(paginationModel.getPageLocation(1, viewport)).isEqualTo(expectedMdLocation)
+
+        val expectedLgLocation =
+            Rect(0, 300 + (PAGE_SPACING_PX * 2), 800, 700 + (PAGE_SPACING_PX * 2))
+        assertThat(paginationModel.getPageLocation(2, viewport)).isEqualTo(expectedLgLocation)
+    }
+
+    /**
+     * Add 3 pages of differing sizes to the model. Set the visible area to the bottom left corner
+     * of this model. Page 0 is not visible, page 1 should shift left to fit the maximum amount of
+     * content on-screen, and page 2 should span [0, model width]
+     */
+    @Test
+    fun getPageLocation_shiftPagesLargerThanViewportLeft() {
+        val smallSize = Point(200, 100)
+        val mediumSize = Point(400, 200)
+        val largeSize = Point(800, 400)
+        paginationModel.addPage(0, smallSize)
+        paginationModel.addPage(1, mediumSize)
+        paginationModel.addPage(2, largeSize)
+        // A 300x200 section in the bottom-left corner of this model
+        val viewport = Rect(0, 250, 200, 800)
+
+        val expectedMdLocation = Rect(0, 100 + PAGE_SPACING_PX, 400, 300 + PAGE_SPACING_PX)
+        assertThat(paginationModel.getPageLocation(1, viewport)).isEqualTo(expectedMdLocation)
+
+        val expectedLgLocation =
+            Rect(0, 300 + (PAGE_SPACING_PX * 2), 800, 700 + (PAGE_SPACING_PX * 2))
+        assertThat(paginationModel.getPageLocation(2, viewport)).isEqualTo(expectedLgLocation)
+    }
+
+    /**
+     * Add 3 pages of differing sizes to the model. Set the visible area to the bottom right corner
+     * of this model. Page 0 is not visible, page 1 should shift right to fit the maximum amount of
+     * content on-screen, and page 2 should span [0, model width]
+     */
+    @Test
+    fun getPageLocation_shiftPagesLargerThanViewportRight() {
+        val smallSize = Point(200, 100)
+        val mediumSize = Point(400, 200)
+        val largeSize = Point(800, 400)
+        paginationModel.addPage(0, smallSize)
+        paginationModel.addPage(1, mediumSize)
+        paginationModel.addPage(2, largeSize)
+        // A 300x200 section in the bottom-right corner of this model
+        val viewport = Rect(600, 250, 800, 800)
+
+        val expectedMdLocation = Rect(400, 100 + PAGE_SPACING_PX, 800, 300 + PAGE_SPACING_PX)
+        assertThat(paginationModel.getPageLocation(1, viewport)).isEqualTo(expectedMdLocation)
+
+        val expectedLgLocation =
+            Rect(0, 300 + (PAGE_SPACING_PX * 2), 800, 700 + (PAGE_SPACING_PX * 2))
+        assertThat(paginationModel.getPageLocation(2, viewport)).isEqualTo(expectedLgLocation)
+    }
+
+    @Test
+    fun parcelable() {
+        val sizeRng = Random(System.currentTimeMillis())
+        val sizes =
+            List(size = 3) { _ -> Point(sizeRng.nextInt(100, 200), sizeRng.nextInt(100, 200)) }
+        sizes.forEachIndexed { pageNum, size -> paginationModel.addPage(pageNum, size) }
+
+        val parcel = Parcel.obtain()
+        paginationModel.writeToParcel(parcel, 0)
+        parcel.setDataPosition(0)
+        val newPaginationModel = PaginationModel.CREATOR.createFromParcel(parcel)
+
+        assertThat(newPaginationModel.totalEstimatedHeight)
+            .isEqualTo(paginationModel.totalEstimatedHeight)
+        assertThat(newPaginationModel.reach).isEqualTo(paginationModel.reach)
+        assertThat(newPaginationModel.maxWidth).isEqualTo(paginationModel.maxWidth)
+
+        for (i in 0 until paginationModel.reach) {
+            assertThat(newPaginationModel.getPageSize(i)).isEqualTo(paginationModel.getPageSize(i))
+        }
+    }
+}
diff --git a/privacysandbox/ui/ui-provider/src/androidTest/kotlin/androidx/privacysandbox/ui/provider/test/BinderAdapterDelegateTest.kt b/privacysandbox/ui/ui-provider/src/androidTest/kotlin/androidx/privacysandbox/ui/provider/test/BinderAdapterDelegateTest.kt
index 8dcfa86..caec996 100644
--- a/privacysandbox/ui/ui-provider/src/androidTest/kotlin/androidx/privacysandbox/ui/provider/test/BinderAdapterDelegateTest.kt
+++ b/privacysandbox/ui/ui-provider/src/androidTest/kotlin/androidx/privacysandbox/ui/provider/test/BinderAdapterDelegateTest.kt
@@ -23,8 +23,8 @@
 import android.view.Display
 import android.view.MotionEvent
 import android.view.SurfaceControlViewHost
-import android.view.SurfaceView
 import android.view.View
+import android.widget.LinearLayout
 import androidx.privacysandbox.ui.provider.TouchFocusTransferringView
 import androidx.test.espresso.Espresso.onView
 import androidx.test.espresso.action.ViewActions.click
@@ -32,6 +32,7 @@
 import androidx.test.espresso.action.ViewActions.swipeLeft
 import androidx.test.espresso.action.ViewActions.swipeUp
 import androidx.test.espresso.matcher.ViewMatchers.withId
+import androidx.test.espresso.matcher.ViewMatchers.withParent
 import androidx.test.ext.junit.rules.ActivityScenarioRule
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.LargeTest
@@ -45,7 +46,6 @@
 import java.util.concurrent.CountDownLatch
 import java.util.concurrent.TimeUnit
 import org.junit.Before
-import org.junit.Ignore
 import org.junit.Rule
 import org.junit.Test
 import org.junit.runner.RunWith
@@ -58,7 +58,7 @@
         const val TIMEOUT_MILLIS: Long = 2000
         const val WIDTH = 500
         const val HEIGHT = 500
-        const val SURFACE_VIEW_RES = "androidx.privacysandbox.ui.provider.test:id/surface_view"
+        const val MAIN_LAYOUT_RES = "androidx.privacysandbox.ui.provider.test:id/main_layout"
     }
 
     @get:Rule val activityScenarioRule = ActivityScenarioRule(MainActivity::class.java)
@@ -70,8 +70,7 @@
         val context = InstrumentationRegistry.getInstrumentation().targetContext
         val activity = activityScenarioRule.withActivity { this }
         UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
-        activity.runOnUiThread {
-            val surfaceView = activity.findViewById<SurfaceView>(R.id.surface_view)
+        activityScenarioRule.withActivity {
             val surfaceControlViewHost =
                 GestureTransferringSurfaceControlViewHost(
                     activity,
@@ -82,48 +81,49 @@
             val touchFocusTransferringView =
                 TouchFocusTransferringView(context, surfaceControlViewHost)
             touchFocusTransferringView.addView(TestView(context))
-            surfaceControlViewHost.setView(touchFocusTransferringView, WIDTH, HEIGHT)
-            surfaceView.setChildSurfacePackage(surfaceControlViewHost.surfacePackage!!)
-            surfaceView.setZOrderOnTop(true)
+            activity
+                .findViewById<LinearLayout>(R.id.main_layout)
+                .addView(touchFocusTransferringView, WIDTH, HEIGHT)
         }
     }
 
-    @Ignore // b/328282434
     @Test
     fun touchFocusTransferredForSwipeUp() {
-        onView(withId(R.id.surface_view)).perform(swipeUp())
+        onView(withParent(withId(R.id.main_layout))).perform(swipeUp())
         assertThat(transferTouchFocusLatch.await(TIMEOUT_MILLIS, TimeUnit.MILLISECONDS)).isTrue()
     }
 
     @Test
     fun touchFocusNotTransferredForSwipeLeft() {
-        onView(withId(R.id.surface_view)).perform(swipeLeft())
+        onView(withParent(withId(R.id.main_layout))).perform(swipeLeft())
         assertThat(transferTouchFocusLatch.await(TIMEOUT_MILLIS, TimeUnit.MILLISECONDS)).isFalse()
     }
 
     @Test
     fun touchFocusNotTransferredForSlowSwipeLeft() {
-        onView(withId(R.id.surface_view)).perform(slowSwipeLeft())
+        onView(withParent(withId(R.id.main_layout))).perform(slowSwipeLeft())
         assertThat(transferTouchFocusLatch.await(TIMEOUT_MILLIS, TimeUnit.MILLISECONDS)).isFalse()
     }
 
     @Test
     fun touchFocusNotTransferredForClicks() {
-        onView(withId(R.id.surface_view)).perform(click())
+        onView(withParent(withId(R.id.main_layout))).perform(click())
         assertThat(transferTouchFocusLatch.await(TIMEOUT_MILLIS, TimeUnit.MILLISECONDS)).isFalse()
     }
 
     @Test
     fun touchFocusTransferredForFlingForward() {
-        val scrollView = UiScrollable(UiSelector().resourceId(SURFACE_VIEW_RES))
-        scrollView.flingForward()
+        val parentSelector = UiSelector().resourceId(MAIN_LAYOUT_RES)
+        val testView = UiScrollable(parentSelector.childSelector(UiSelector().index(0)))
+        testView.flingForward()
         assertThat(transferTouchFocusLatch.await(TIMEOUT_MILLIS, TimeUnit.MILLISECONDS)).isTrue()
     }
 
     @Test
     fun touchFocusTransferredForFlingBackward() {
-        val scrollView = UiScrollable(UiSelector().resourceId(SURFACE_VIEW_RES))
-        scrollView.flingBackward()
+        val parentSelector = UiSelector().resourceId(MAIN_LAYOUT_RES)
+        val testView = UiScrollable(parentSelector.childSelector(UiSelector().index(0)))
+        testView.flingBackward()
         assertThat(transferTouchFocusLatch.await(TIMEOUT_MILLIS, TimeUnit.MILLISECONDS)).isTrue()
     }
 
diff --git a/privacysandbox/ui/ui-provider/src/androidTest/res/layout/activity_main.xml b/privacysandbox/ui/ui-provider/src/androidTest/res/layout/activity_main.xml
index 74929d1..ddb420c 100644
--- a/privacysandbox/ui/ui-provider/src/androidTest/res/layout/activity_main.xml
+++ b/privacysandbox/ui/ui-provider/src/androidTest/res/layout/activity_main.xml
@@ -19,8 +19,4 @@
     android:layout_height="match_parent"
     android:orientation="vertical"
     android:id="@+id/main_layout">
-    <SurfaceView
-        android:layout_width="500px"
-        android:layout_height="500px"
-        android:id="@+id/surface_view"/>
 </LinearLayout>
\ No newline at end of file
diff --git a/recyclerview/recyclerview-benchmark/src/androidTest/java/androidx/recyclerview/benchmark/ScrollBenchmark.kt b/recyclerview/recyclerview-benchmark/src/androidTest/java/androidx/recyclerview/benchmark/ScrollBenchmark.kt
index 0234d42..e87711c 100644
--- a/recyclerview/recyclerview-benchmark/src/androidTest/java/androidx/recyclerview/benchmark/ScrollBenchmark.kt
+++ b/recyclerview/recyclerview-benchmark/src/androidTest/java/androidx/recyclerview/benchmark/ScrollBenchmark.kt
@@ -22,10 +22,9 @@
 import android.view.ViewGroup
 import android.widget.LinearLayout
 import androidx.benchmark.junit4.BenchmarkRule
-import androidx.benchmark.junit4.measureRepeated
+import androidx.benchmark.junit4.measureRepeatedOnMainThread
 import androidx.recyclerview.benchmark.test.R
 import androidx.recyclerview.widget.RecyclerView
-import androidx.test.annotation.UiThreadTest
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.LargeTest
 import org.junit.Before
@@ -58,23 +57,21 @@
         }
     }
 
-    @UiThreadTest
     @Test
     fun offset() {
         val rv = activityRule.activity.recyclerView
         var offset = 10
-        benchmarkRule.measureRepeated {
+        benchmarkRule.measureRepeatedOnMainThread {
             // keep scrolling up and down - no new item should be revealed
             rv.scrollBy(0, offset)
             offset *= -1
         }
     }
 
-    @UiThreadTest
     @Test
     fun bindOffset() {
         val rv = activityRule.activity.recyclerView
-        benchmarkRule.measureRepeated {
+        benchmarkRule.measureRepeatedOnMainThread {
             // each scroll should reveal a new item
             rv.scrollBy(0, 100)
         }
@@ -88,7 +85,6 @@
         }
     }
 
-    @UiThreadTest
     @Test
     fun createBindOffset() {
         forceInflate {
@@ -97,24 +93,22 @@
             }
         }
         val rv = activityRule.activity.recyclerView
-        benchmarkRule.measureRepeated {
+        benchmarkRule.measureRepeatedOnMainThread {
             // each scroll should reveal a new item that must be inflated
             rv.scrollBy(0, 100)
         }
     }
 
-    @UiThreadTest
     @Test
     fun inflateBindOffset() {
         forceInflate()
         val rv = activityRule.activity.recyclerView
-        benchmarkRule.measureRepeated {
+        benchmarkRule.measureRepeatedOnMainThread {
             // each scroll should reveal a new item that must be inflated
             rv.scrollBy(0, 100)
         }
     }
 
-    @UiThreadTest
     @Test
     fun complexItems() {
 
@@ -139,7 +133,7 @@
         }
 
         val rv = activityRule.activity.recyclerView
-        benchmarkRule.measureRepeated {
+        benchmarkRule.measureRepeatedOnMainThread {
             // each scroll should reveal a new item that must be inflated
             rv.scrollBy(0, 500)
         }
diff --git a/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/XParameterSpec.kt b/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/XParameterSpec.kt
index c6021ea..a4dc87c 100644
--- a/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/XParameterSpec.kt
+++ b/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/XParameterSpec.kt
@@ -22,11 +22,12 @@
 import androidx.room.compiler.codegen.java.NULLABLE_ANNOTATION
 import androidx.room.compiler.codegen.kotlin.KotlinParameterSpec
 import androidx.room.compiler.processing.XNullability
-import javax.lang.model.element.Modifier
 
 interface XParameterSpec {
     val name: String
 
+    val type: XTypeName
+
     interface Builder {
         fun addAnnotation(annotation: XAnnotationSpec): Builder
 
@@ -38,19 +39,18 @@
     }
 
     companion object {
-        @JvmStatic
-        fun of(name: String, typeName: XTypeName) = builder(XName.of(name), typeName).build()
-
-        @JvmStatic fun of(name: XName, typeName: XTypeName) = builder(name, typeName).build()
+        @JvmStatic fun of(name: String, typeName: XTypeName) = builder(name, typeName).build()
 
         @JvmStatic
-        fun builder(name: String, typeName: XTypeName) = builder(XName.of(name), typeName)
-
-        @JvmStatic
-        fun builder(name: XName, typeName: XTypeName): Builder {
+        fun builder(name: String, typeName: XTypeName): Builder {
             return XParameterSpecImpl.Builder(
+                name,
+                typeName,
                 JavaParameterSpec.Builder(
-                    JParameterSpec.builder(typeName.java, name.java, Modifier.FINAL).apply {
+                    name,
+                    typeName,
+                    JParameterSpec.builder(typeName.java, name).apply {
+                        addModifiers(JModifier.FINAL)
                         // Adding nullability annotation to primitive parameters is redundant as
                         // primitives can never be null.
                         if (!typeName.isPrimitive) {
@@ -62,7 +62,11 @@
                         }
                     }
                 ),
-                KotlinParameterSpec.Builder(KParameterSpec.builder(name.kotlin, typeName.kotlin))
+                KotlinParameterSpec.Builder(
+                    name,
+                    typeName,
+                    KParameterSpec.builder(name, typeName.kotlin)
+                )
             )
         }
     }
diff --git a/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/XPropertySpec.kt b/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/XPropertySpec.kt
index fb991b4..bef31732 100644
--- a/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/XPropertySpec.kt
+++ b/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/XPropertySpec.kt
@@ -29,6 +29,8 @@
 
     val name: String
 
+    val type: XTypeName
+
     interface Builder {
         fun addAnnotation(annotation: XAnnotationSpec): Builder
 
@@ -66,10 +68,15 @@
             isMutable: Boolean = false,
         ): Builder =
             XPropertySpecImpl.Builder(
+                name,
+                typeName,
                 JavaPropertySpec.Builder(
+                    name,
+                    typeName,
                     JPropertySpec.builder(typeName.java, name).apply {
                         val visibilityModifier = visibility.toJavaVisibilityModifier()
-                        // TODO(b/247242374) Add nullability annotations for non-private fields
+                        addModifiers(visibilityModifier)
+                        // TODO(b/247242374) Add nullability annotations for private fields
                         if (visibilityModifier != JModifier.PRIVATE) {
                             if (typeName.nullability == XNullability.NULLABLE) {
                                 addAnnotation(NULLABLE_ANNOTATION)
@@ -77,13 +84,14 @@
                                 addAnnotation(NONNULL_ANNOTATION)
                             }
                         }
-                        addModifiers(visibilityModifier)
                         if (!isMutable) {
                             addModifiers(JModifier.FINAL)
                         }
                     }
                 ),
                 KotlinPropertySpec.Builder(
+                    name,
+                    typeName,
                     KPropertySpec.builder(name, typeName.kotlin).apply {
                         mutable(isMutable)
                         addModifiers(visibility.toKotlinVisibilityModifier())
diff --git a/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/compat/XConverters.kt b/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/compat/XConverters.kt
index f858dac..e506c6c 100644
--- a/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/compat/XConverters.kt
+++ b/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/compat/XConverters.kt
@@ -24,9 +24,7 @@
 import androidx.room.compiler.codegen.JFileSpecBuilder
 import androidx.room.compiler.codegen.JFunSpec
 import androidx.room.compiler.codegen.JFunSpecBuilder
-import androidx.room.compiler.codegen.JParameterSpec
 import androidx.room.compiler.codegen.JParameterSpecBuilder
-import androidx.room.compiler.codegen.JPropertySpec
 import androidx.room.compiler.codegen.JPropertySpecBuilder
 import androidx.room.compiler.codegen.JTypeSpecBuilder
 import androidx.room.compiler.codegen.KAnnotationSpecBuilder
@@ -36,9 +34,7 @@
 import androidx.room.compiler.codegen.KFileSpecBuilder
 import androidx.room.compiler.codegen.KFunSpec
 import androidx.room.compiler.codegen.KFunSpecBuilder
-import androidx.room.compiler.codegen.KParameterSpec
 import androidx.room.compiler.codegen.KParameterSpecBuilder
-import androidx.room.compiler.codegen.KPropertySpec
 import androidx.room.compiler.codegen.KPropertySpecBuilder
 import androidx.room.compiler.codegen.KTypeSpecBuilder
 import androidx.room.compiler.codegen.XAnnotationSpec
@@ -391,34 +387,6 @@
         )
 
     @JvmStatic
-    fun toXPoet(jParameterSpec: JParameterSpec, kParameterSpec: KParameterSpec): XParameterSpec =
-        XParameterSpecImpl(JavaParameterSpec(jParameterSpec), KotlinParameterSpec(kParameterSpec))
-
-    @JvmStatic
-    fun toXPoet(
-        jParameterSpecBuilder: JParameterSpecBuilder,
-        kParameterSpecBuilder: KParameterSpecBuilder
-    ): XParameterSpec.Builder =
-        XParameterSpecImpl.Builder(
-            JavaParameterSpec.Builder(jParameterSpecBuilder),
-            KotlinParameterSpec.Builder(kParameterSpecBuilder)
-        )
-
-    @JvmStatic
-    fun toXPoet(jPropertySpec: JPropertySpec, kPropertySpec: KPropertySpec): XPropertySpec =
-        XPropertySpecImpl(JavaPropertySpec(jPropertySpec), KotlinPropertySpec(kPropertySpec))
-
-    @JvmStatic
-    fun toXPoet(
-        jPropertySpecBuilder: JPropertySpecBuilder,
-        kPropertySpecBuilder: KPropertySpecBuilder
-    ): XPropertySpec.Builder =
-        XPropertySpecImpl.Builder(
-            JavaPropertySpec.Builder(jPropertySpecBuilder),
-            KotlinPropertySpec.Builder(kPropertySpecBuilder)
-        )
-
-    @JvmStatic
     fun toXPoet(jTypeSpec: JTypeSpec, kTypeSpec: KTypeSpec): XTypeSpec =
         XTypeSpecImpl(JavaTypeSpec(jTypeSpec), KotlinTypeSpec(kTypeSpec))
 
diff --git a/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/impl/XParameterSpecImpl.kt b/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/impl/XParameterSpecImpl.kt
index ff65bc9..dfb58c4 100644
--- a/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/impl/XParameterSpecImpl.kt
+++ b/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/impl/XParameterSpecImpl.kt
@@ -19,22 +19,22 @@
 import androidx.room.compiler.codegen.XAnnotationSpec
 import androidx.room.compiler.codegen.XParameterSpec
 import androidx.room.compiler.codegen.XSpec
+import androidx.room.compiler.codegen.XTypeName
 import androidx.room.compiler.codegen.java.JavaParameterSpec
 import androidx.room.compiler.codegen.kotlin.KotlinParameterSpec
 
 internal class XParameterSpecImpl(
-    val java: JavaParameterSpec,
-    val kotlin: KotlinParameterSpec,
+    override val name: String,
+    override val type: XTypeName,
+    internal val java: JavaParameterSpec,
+    internal val kotlin: KotlinParameterSpec,
 ) : XSpec(), XParameterSpec {
 
-    override val name: String by lazy {
-        check(java.name == kotlin.name)
-        java.name
-    }
-
     internal class Builder(
-        val java: JavaParameterSpec.Builder,
-        val kotlin: KotlinParameterSpec.Builder,
+        private val name: String,
+        private val type: XTypeName,
+        internal val java: JavaParameterSpec.Builder,
+        internal val kotlin: KotlinParameterSpec.Builder
     ) : XSpec.Builder(), XParameterSpec.Builder {
         private val delegates: List<XParameterSpec.Builder> = listOf(java, kotlin)
 
@@ -42,6 +42,6 @@
             delegates.forEach { it.addAnnotation(annotation) }
         }
 
-        override fun build() = XParameterSpecImpl(java.build(), kotlin.build())
+        override fun build() = XParameterSpecImpl(name, type, java.build(), kotlin.build())
     }
 }
diff --git a/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/impl/XPropertySpecImpl.kt b/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/impl/XPropertySpecImpl.kt
index 45c4e65..da1f0dc 100644
--- a/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/impl/XPropertySpecImpl.kt
+++ b/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/impl/XPropertySpecImpl.kt
@@ -20,22 +20,22 @@
 import androidx.room.compiler.codegen.XCodeBlock
 import androidx.room.compiler.codegen.XPropertySpec
 import androidx.room.compiler.codegen.XSpec
+import androidx.room.compiler.codegen.XTypeName
 import androidx.room.compiler.codegen.java.JavaPropertySpec
 import androidx.room.compiler.codegen.kotlin.KotlinPropertySpec
 
 internal class XPropertySpecImpl(
-    val java: JavaPropertySpec,
-    val kotlin: KotlinPropertySpec,
+    override val name: String,
+    override val type: XTypeName,
+    internal val java: JavaPropertySpec,
+    internal val kotlin: KotlinPropertySpec,
 ) : XSpec(), XPropertySpec {
 
-    override val name: String by lazy {
-        check(java.name == kotlin.name)
-        java.name
-    }
-
     internal class Builder(
-        val java: JavaPropertySpec.Builder,
-        val kotlin: KotlinPropertySpec.Builder,
+        private val name: String,
+        private val type: XTypeName,
+        internal val java: JavaPropertySpec.Builder,
+        internal val kotlin: KotlinPropertySpec.Builder
     ) : XSpec.Builder(), XPropertySpec.Builder {
         private val delegates: List<XPropertySpec.Builder> = listOf(java, kotlin)
 
@@ -47,6 +47,6 @@
             delegates.forEach { it.initializer(initExpr) }
         }
 
-        override fun build() = XPropertySpecImpl(java.build(), kotlin.build())
+        override fun build() = XPropertySpecImpl(name, type, java.build(), kotlin.build())
     }
 }
diff --git a/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/java/JavaParameterSpec.kt b/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/java/JavaParameterSpec.kt
index e240a0d..3b7c6c5 100644
--- a/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/java/JavaParameterSpec.kt
+++ b/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/java/JavaParameterSpec.kt
@@ -21,18 +21,26 @@
 import androidx.room.compiler.codegen.XAnnotationSpec
 import androidx.room.compiler.codegen.XParameterSpec
 import androidx.room.compiler.codegen.XSpec
+import androidx.room.compiler.codegen.XTypeName
 import androidx.room.compiler.codegen.impl.XAnnotationSpecImpl
 
-internal class JavaParameterSpec(internal val actual: JParameterSpec) : XSpec(), XParameterSpec {
-    override val name = actual.name
+internal class JavaParameterSpec(
+    override val name: String,
+    override val type: XTypeName,
+    internal val actual: JParameterSpec
+) : XSpec(), XParameterSpec {
 
-    internal class Builder(internal val actual: JParameterSpecBuilder) :
-        XSpec.Builder(), XParameterSpec.Builder {
+    internal class Builder(
+        private val name: String,
+        private val type: XTypeName,
+        internal val actual: JParameterSpecBuilder = JParameterSpec.builder(type.java, name)
+    ) : XSpec.Builder(), XParameterSpec.Builder {
+
         override fun addAnnotation(annotation: XAnnotationSpec) = apply {
             require(annotation is XAnnotationSpecImpl)
             actual.addAnnotation(annotation.java.actual)
         }
 
-        override fun build() = JavaParameterSpec(actual.build())
+        override fun build() = JavaParameterSpec(name, type, actual.build())
     }
 }
diff --git a/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/java/JavaPropertySpec.kt b/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/java/JavaPropertySpec.kt
index cb739b9..aa21856 100644
--- a/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/java/JavaPropertySpec.kt
+++ b/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/java/JavaPropertySpec.kt
@@ -22,15 +22,21 @@
 import androidx.room.compiler.codegen.XCodeBlock
 import androidx.room.compiler.codegen.XPropertySpec
 import androidx.room.compiler.codegen.XSpec
+import androidx.room.compiler.codegen.XTypeName
 import androidx.room.compiler.codegen.impl.XAnnotationSpecImpl
 import androidx.room.compiler.codegen.impl.XCodeBlockImpl
 
-internal class JavaPropertySpec(internal val actual: JPropertySpec) : XSpec(), XPropertySpec {
+internal class JavaPropertySpec(
+    override val name: String,
+    override val type: XTypeName,
+    internal val actual: JPropertySpec
+) : XSpec(), XPropertySpec {
 
-    override val name = actual.name
-
-    internal class Builder(internal val actual: JPropertySpecBuilder) :
-        XSpec.Builder(), XPropertySpec.Builder {
+    internal class Builder(
+        private val name: String,
+        private val type: XTypeName,
+        internal val actual: JPropertySpecBuilder
+    ) : XSpec.Builder(), XPropertySpec.Builder {
 
         override fun addAnnotation(annotation: XAnnotationSpec) = apply {
             require(annotation is XAnnotationSpecImpl)
@@ -42,6 +48,6 @@
             actual.initializer(initExpr.java.actual)
         }
 
-        override fun build() = JavaPropertySpec(actual.build())
+        override fun build() = JavaPropertySpec(name, type, actual.build())
     }
 }
diff --git a/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/java/JavaTypeSpec.kt b/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/java/JavaTypeSpec.kt
index fe1c35a..8e6ae0e 100644
--- a/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/java/JavaTypeSpec.kt
+++ b/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/java/JavaTypeSpec.kt
@@ -36,6 +36,7 @@
 import javax.lang.model.element.Modifier
 
 internal class JavaTypeSpec(internal val actual: JTypeSpec) : XSpec(), XTypeSpec {
+
     override val name = actual.name?.let { XName.of(it) }
 
     internal class Builder(internal val actual: JTypeSpecBuilder) :
diff --git a/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/kotlin/KotlinFunSpec.kt b/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/kotlin/KotlinFunSpec.kt
index bd628dc..9d6a905 100644
--- a/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/kotlin/KotlinFunSpec.kt
+++ b/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/kotlin/KotlinFunSpec.kt
@@ -33,6 +33,7 @@
 import com.squareup.kotlinpoet.javapoet.KTypeVariableName
 
 internal class KotlinFunSpec(internal val actual: KFunSpec) : XSpec(), XFunSpec {
+
     override val name: XName = XName.of(actual.name)
 
     internal class Builder(internal val actual: KFunSpecBuilder) :
diff --git a/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/kotlin/KotlinParameterSpec.kt b/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/kotlin/KotlinParameterSpec.kt
index 3ca970a..3d39a48 100644
--- a/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/kotlin/KotlinParameterSpec.kt
+++ b/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/kotlin/KotlinParameterSpec.kt
@@ -21,18 +21,26 @@
 import androidx.room.compiler.codegen.XAnnotationSpec
 import androidx.room.compiler.codegen.XParameterSpec
 import androidx.room.compiler.codegen.XSpec
+import androidx.room.compiler.codegen.XTypeName
 import androidx.room.compiler.codegen.impl.XAnnotationSpecImpl
 
-internal class KotlinParameterSpec(internal val actual: KParameterSpec) : XSpec(), XParameterSpec {
-    override val name = actual.name
+internal class KotlinParameterSpec(
+    override val name: String,
+    override val type: XTypeName,
+    internal val actual: KParameterSpec
+) : XSpec(), XParameterSpec {
 
-    internal class Builder(internal val actual: KParameterSpecBuilder) :
-        XSpec.Builder(), XParameterSpec.Builder {
+    internal class Builder(
+        private val name: String,
+        private val type: XTypeName,
+        internal val actual: KParameterSpecBuilder
+    ) : XSpec.Builder(), XParameterSpec.Builder {
+
         override fun addAnnotation(annotation: XAnnotationSpec) = apply {
             require(annotation is XAnnotationSpecImpl)
             actual.addAnnotation(annotation.kotlin.actual)
         }
 
-        override fun build() = KotlinParameterSpec(actual.build())
+        override fun build() = KotlinParameterSpec(name, type, actual.build())
     }
 }
diff --git a/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/kotlin/KotlinPropertySpec.kt b/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/kotlin/KotlinPropertySpec.kt
index 1ffd824..1e5a3dd 100644
--- a/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/kotlin/KotlinPropertySpec.kt
+++ b/room/room-compiler-processing/src/main/java/androidx/room/compiler/codegen/kotlin/KotlinPropertySpec.kt
@@ -22,15 +22,21 @@
 import androidx.room.compiler.codegen.XCodeBlock
 import androidx.room.compiler.codegen.XPropertySpec
 import androidx.room.compiler.codegen.XSpec
+import androidx.room.compiler.codegen.XTypeName
 import androidx.room.compiler.codegen.impl.XAnnotationSpecImpl
 import androidx.room.compiler.codegen.impl.XCodeBlockImpl
 
-internal class KotlinPropertySpec(internal val actual: KPropertySpec) : XSpec(), XPropertySpec {
+internal class KotlinPropertySpec(
+    override val name: String,
+    override val type: XTypeName,
+    internal val actual: KPropertySpec
+) : XSpec(), XPropertySpec {
 
-    override val name: String = actual.name
-
-    internal class Builder(internal val actual: KPropertySpecBuilder) :
-        XSpec.Builder(), XPropertySpec.Builder {
+    internal class Builder(
+        private val name: String,
+        private val type: XTypeName,
+        internal val actual: KPropertySpecBuilder
+    ) : XSpec.Builder(), XPropertySpec.Builder {
 
         override fun addAnnotation(annotation: XAnnotationSpec) = apply {
             require(annotation is XAnnotationSpecImpl)
@@ -42,6 +48,6 @@
             actual.initializer(initExpr.kotlin.actual)
         }
 
-        override fun build() = KotlinPropertySpec(actual.build())
+        override fun build() = KotlinPropertySpec(name, type, actual.build())
     }
 }
diff --git a/savedstate/savedstate/api/current.txt b/savedstate/savedstate/api/current.txt
index 3e3d6f9..c1e51e3 100644
--- a/savedstate/savedstate/api/current.txt
+++ b/savedstate/savedstate/api/current.txt
@@ -11,6 +11,8 @@
     method public inline operator boolean contains(String key);
     method public boolean contentDeepEquals(android.os.Bundle other);
     method public int contentDeepHashCode();
+    method public inline android.os.IBinder getBinder(String key);
+    method public inline android.os.IBinder getBinderOrElse(String key, kotlin.jvm.functions.Function0<? extends android.os.IBinder> defaultValue);
     method public inline boolean getBoolean(String key);
     method public inline boolean[] getBooleanArray(String key);
     method public inline boolean[] getBooleanArrayOrElse(String key, kotlin.jvm.functions.Function0<boolean[]> defaultValue);
@@ -19,6 +21,12 @@
     method public inline char[] getCharArray(String key);
     method public inline char[] getCharArrayOrElse(String key, kotlin.jvm.functions.Function0<char[]> defaultValue);
     method public inline char getCharOrElse(String key, kotlin.jvm.functions.Function0<java.lang.Character> defaultValue);
+    method public inline CharSequence getCharSequence(String key);
+    method public inline CharSequence[] getCharSequenceArray(String key);
+    method public inline CharSequence[] getCharSequenceArrayOrElse(String key, kotlin.jvm.functions.Function0<java.lang.CharSequence[]> defaultValue);
+    method public inline java.util.List<java.lang.CharSequence> getCharSequenceList(String key);
+    method public inline java.util.List<java.lang.CharSequence> getCharSequenceListOrElse(String key, kotlin.jvm.functions.Function0<? extends java.util.List<? extends java.lang.CharSequence>> defaultValue);
+    method public inline CharSequence getCharSequenceOrElse(String key, kotlin.jvm.functions.Function0<? extends java.lang.CharSequence> defaultValue);
     method public inline double getDouble(String key);
     method public inline double[] getDoubleArray(String key);
     method public inline double[] getDoubleArrayOrElse(String key, kotlin.jvm.functions.Function0<double[]> defaultValue);
@@ -38,11 +46,21 @@
     method public inline long[] getLongArrayOrElse(String key, kotlin.jvm.functions.Function0<long[]> defaultValue);
     method public inline long getLongOrElse(String key, kotlin.jvm.functions.Function0<java.lang.Long> defaultValue);
     method public inline <reified T extends android.os.Parcelable> T getParcelable(String key);
+    method public inline <reified T extends android.os.Parcelable> T[] getParcelableArray(String key);
+    method public inline <reified T extends android.os.Parcelable> T[] getParcelableArrayOrElse(String key, kotlin.jvm.functions.Function0<T[]> defaultValue);
     method public inline <reified T extends android.os.Parcelable> java.util.List<T> getParcelableList(String key);
     method public inline <reified T extends android.os.Parcelable> java.util.List<T> getParcelableListOrElse(String key, kotlin.jvm.functions.Function0<? extends java.util.List<? extends T>> defaultValue);
     method public inline <reified T extends android.os.Parcelable> T getParcelableOrElse(String key, kotlin.jvm.functions.Function0<? extends T> defaultValue);
     method public inline android.os.Bundle getSavedState(String key);
     method public inline android.os.Bundle getSavedStateOrElse(String key, kotlin.jvm.functions.Function0<android.os.Bundle> defaultValue);
+    method public inline <reified T extends java.io.Serializable> T getSerializable(String key);
+    method public inline <reified T extends java.io.Serializable> T getSerializableOrElse(String key, kotlin.jvm.functions.Function0<? extends T> defaultValue);
+    method public inline android.util.Size getSize(String key);
+    method public inline android.util.SizeF getSizeF(String key);
+    method public inline android.util.SizeF getSizeFOrElse(String key, kotlin.jvm.functions.Function0<android.util.SizeF> defaultValue);
+    method public inline android.util.Size getSizeOrElse(String key, kotlin.jvm.functions.Function0<android.util.Size> defaultValue);
+    method public inline <reified T extends android.os.Parcelable> android.util.SparseArray<T> getSparseParcelableArray(String key);
+    method public inline <reified T extends android.os.Parcelable> android.util.SparseArray<T> getSparseParcelableArrayOrElse(String key, kotlin.jvm.functions.Function0<? extends android.util.SparseArray<T>> defaultValue);
     method public inline String getString(String key);
     method public inline String[] getStringArray(String key);
     method public inline String[] getStringArrayOrElse(String key, kotlin.jvm.functions.Function0<java.lang.String[]> defaultValue);
@@ -95,10 +113,14 @@
   @kotlin.jvm.JvmInline public final value class SavedStateWriter {
     method public inline void clear();
     method public inline void putAll(android.os.Bundle values);
+    method public inline void putBinder(String key, android.os.IBinder value);
     method public inline void putBoolean(String key, boolean value);
     method public inline void putBooleanArray(String key, boolean[] values);
     method public inline void putChar(String key, char value);
     method public inline void putCharArray(String key, char[] values);
+    method public inline void putCharSequence(String key, CharSequence value);
+    method public inline void putCharSequenceArray(String key, CharSequence[] values);
+    method public inline void putCharSequenceList(String key, java.util.List<? extends java.lang.CharSequence> values);
     method public inline void putDouble(String key, double value);
     method public inline void putDoubleArray(String key, double[] values);
     method public inline void putFloat(String key, float value);
@@ -110,8 +132,13 @@
     method public inline void putLongArray(String key, long[] values);
     method public inline void putNull(String key);
     method public inline <reified T extends android.os.Parcelable> void putParcelable(String key, T value);
+    method public inline <reified T extends android.os.Parcelable> void putParcelableArray(String key, T[] values);
     method public inline <reified T extends android.os.Parcelable> void putParcelableList(String key, java.util.List<? extends T> values);
     method public inline void putSavedState(String key, android.os.Bundle value);
+    method public inline <reified T extends java.io.Serializable> void putSerializable(String key, T value);
+    method public inline void putSize(String key, android.util.Size value);
+    method public inline void putSizeF(String key, android.util.SizeF value);
+    method public inline <reified T extends android.os.Parcelable> void putSparseParcelableArray(String key, android.util.SparseArray<T> values);
     method public inline void putString(String key, String value);
     method public inline void putStringArray(String key, String[] values);
     method public inline void putStringList(String key, java.util.List<java.lang.String> values);
diff --git a/savedstate/savedstate/api/restricted_current.txt b/savedstate/savedstate/api/restricted_current.txt
index 94ea74b..eb9ae61 100644
--- a/savedstate/savedstate/api/restricted_current.txt
+++ b/savedstate/savedstate/api/restricted_current.txt
@@ -12,6 +12,8 @@
     method public inline operator boolean contains(String key);
     method public boolean contentDeepEquals(android.os.Bundle other);
     method public int contentDeepHashCode();
+    method public inline android.os.IBinder getBinder(String key);
+    method public inline android.os.IBinder getBinderOrElse(String key, kotlin.jvm.functions.Function0<? extends android.os.IBinder> defaultValue);
     method public inline boolean getBoolean(String key);
     method public inline boolean[] getBooleanArray(String key);
     method public inline boolean[] getBooleanArrayOrElse(String key, kotlin.jvm.functions.Function0<boolean[]> defaultValue);
@@ -20,6 +22,12 @@
     method public inline char[] getCharArray(String key);
     method public inline char[] getCharArrayOrElse(String key, kotlin.jvm.functions.Function0<char[]> defaultValue);
     method public inline char getCharOrElse(String key, kotlin.jvm.functions.Function0<java.lang.Character> defaultValue);
+    method public inline CharSequence getCharSequence(String key);
+    method public inline CharSequence[] getCharSequenceArray(String key);
+    method public inline CharSequence[] getCharSequenceArrayOrElse(String key, kotlin.jvm.functions.Function0<java.lang.CharSequence[]> defaultValue);
+    method public inline java.util.List<java.lang.CharSequence> getCharSequenceList(String key);
+    method public inline java.util.List<java.lang.CharSequence> getCharSequenceListOrElse(String key, kotlin.jvm.functions.Function0<? extends java.util.List<? extends java.lang.CharSequence>> defaultValue);
+    method public inline CharSequence getCharSequenceOrElse(String key, kotlin.jvm.functions.Function0<? extends java.lang.CharSequence> defaultValue);
     method public inline double getDouble(String key);
     method public inline double[] getDoubleArray(String key);
     method public inline double[] getDoubleArrayOrElse(String key, kotlin.jvm.functions.Function0<double[]> defaultValue);
@@ -39,11 +47,21 @@
     method public inline long[] getLongArrayOrElse(String key, kotlin.jvm.functions.Function0<long[]> defaultValue);
     method public inline long getLongOrElse(String key, kotlin.jvm.functions.Function0<java.lang.Long> defaultValue);
     method public inline <reified T extends android.os.Parcelable> T getParcelable(String key);
+    method public inline <reified T extends android.os.Parcelable> T[] getParcelableArray(String key);
+    method public inline <reified T extends android.os.Parcelable> T[] getParcelableArrayOrElse(String key, kotlin.jvm.functions.Function0<T[]> defaultValue);
     method public inline <reified T extends android.os.Parcelable> java.util.List<T> getParcelableList(String key);
     method public inline <reified T extends android.os.Parcelable> java.util.List<T> getParcelableListOrElse(String key, kotlin.jvm.functions.Function0<? extends java.util.List<? extends T>> defaultValue);
     method public inline <reified T extends android.os.Parcelable> T getParcelableOrElse(String key, kotlin.jvm.functions.Function0<? extends T> defaultValue);
     method public inline android.os.Bundle getSavedState(String key);
     method public inline android.os.Bundle getSavedStateOrElse(String key, kotlin.jvm.functions.Function0<android.os.Bundle> defaultValue);
+    method public inline <reified T extends java.io.Serializable> T getSerializable(String key);
+    method public inline <reified T extends java.io.Serializable> T getSerializableOrElse(String key, kotlin.jvm.functions.Function0<? extends T> defaultValue);
+    method public inline android.util.Size getSize(String key);
+    method public inline android.util.SizeF getSizeF(String key);
+    method public inline android.util.SizeF getSizeFOrElse(String key, kotlin.jvm.functions.Function0<android.util.SizeF> defaultValue);
+    method public inline android.util.Size getSizeOrElse(String key, kotlin.jvm.functions.Function0<android.util.Size> defaultValue);
+    method public inline <reified T extends android.os.Parcelable> android.util.SparseArray<T> getSparseParcelableArray(String key);
+    method public inline <reified T extends android.os.Parcelable> android.util.SparseArray<T> getSparseParcelableArrayOrElse(String key, kotlin.jvm.functions.Function0<? extends android.util.SparseArray<T>> defaultValue);
     method public inline String getString(String key);
     method public inline String[] getStringArray(String key);
     method public inline String[] getStringArrayOrElse(String key, kotlin.jvm.functions.Function0<java.lang.String[]> defaultValue);
@@ -115,10 +133,14 @@
     ctor @kotlin.PublishedApi internal SavedStateWriter(@kotlin.PublishedApi android.os.Bundle source);
     method public inline void clear();
     method public inline void putAll(android.os.Bundle values);
+    method public inline void putBinder(String key, android.os.IBinder value);
     method public inline void putBoolean(String key, boolean value);
     method public inline void putBooleanArray(String key, boolean[] values);
     method public inline void putChar(String key, char value);
     method public inline void putCharArray(String key, char[] values);
+    method public inline void putCharSequence(String key, CharSequence value);
+    method public inline void putCharSequenceArray(String key, CharSequence[] values);
+    method public inline void putCharSequenceList(String key, java.util.List<? extends java.lang.CharSequence> values);
     method public inline void putDouble(String key, double value);
     method public inline void putDoubleArray(String key, double[] values);
     method public inline void putFloat(String key, float value);
@@ -130,8 +152,13 @@
     method public inline void putLongArray(String key, long[] values);
     method public inline void putNull(String key);
     method public inline <reified T extends android.os.Parcelable> void putParcelable(String key, T value);
+    method public inline <reified T extends android.os.Parcelable> void putParcelableArray(String key, T[] values);
     method public inline <reified T extends android.os.Parcelable> void putParcelableList(String key, java.util.List<? extends T> values);
     method public inline void putSavedState(String key, android.os.Bundle value);
+    method public inline <reified T extends java.io.Serializable> void putSerializable(String key, T value);
+    method public inline void putSize(String key, android.util.Size value);
+    method public inline void putSizeF(String key, android.util.SizeF value);
+    method public inline <reified T extends android.os.Parcelable> void putSparseParcelableArray(String key, android.util.SparseArray<T> values);
     method public inline void putString(String key, String value);
     method public inline void putStringArray(String key, String[] values);
     method public inline void putStringList(String key, java.util.List<java.lang.String> values);
diff --git a/savedstate/savedstate/bcv/native/current.txt b/savedstate/savedstate/bcv/native/current.txt
index e9f880e..2491285 100644
--- a/savedstate/savedstate/bcv/native/current.txt
+++ b/savedstate/savedstate/bcv/native/current.txt
@@ -66,6 +66,12 @@
     final inline fun getCharArray(kotlin/String): kotlin/CharArray // androidx.savedstate/SavedStateReader.getCharArray|getCharArray(kotlin.String){}[0]
     final inline fun getCharArrayOrElse(kotlin/String, kotlin/Function0<kotlin/CharArray>): kotlin/CharArray // androidx.savedstate/SavedStateReader.getCharArrayOrElse|getCharArrayOrElse(kotlin.String;kotlin.Function0<kotlin.CharArray>){}[0]
     final inline fun getCharOrElse(kotlin/String, kotlin/Function0<kotlin/Char>): kotlin/Char // androidx.savedstate/SavedStateReader.getCharOrElse|getCharOrElse(kotlin.String;kotlin.Function0<kotlin.Char>){}[0]
+    final inline fun getCharSequence(kotlin/String): kotlin/CharSequence // androidx.savedstate/SavedStateReader.getCharSequence|getCharSequence(kotlin.String){}[0]
+    final inline fun getCharSequenceArray(kotlin/String): kotlin/Array<kotlin/CharSequence> // androidx.savedstate/SavedStateReader.getCharSequenceArray|getCharSequenceArray(kotlin.String){}[0]
+    final inline fun getCharSequenceArrayOrElse(kotlin/String, kotlin/Function0<kotlin/Array<kotlin/CharSequence>>): kotlin/Array<kotlin/CharSequence> // androidx.savedstate/SavedStateReader.getCharSequenceArrayOrElse|getCharSequenceArrayOrElse(kotlin.String;kotlin.Function0<kotlin.Array<kotlin.CharSequence>>){}[0]
+    final inline fun getCharSequenceList(kotlin/String): kotlin.collections/List<kotlin/CharSequence> // androidx.savedstate/SavedStateReader.getCharSequenceList|getCharSequenceList(kotlin.String){}[0]
+    final inline fun getCharSequenceListOrElse(kotlin/String, kotlin/Function0<kotlin.collections/List<kotlin/CharSequence>>): kotlin.collections/List<kotlin/CharSequence> // androidx.savedstate/SavedStateReader.getCharSequenceListOrElse|getCharSequenceListOrElse(kotlin.String;kotlin.Function0<kotlin.collections.List<kotlin.CharSequence>>){}[0]
+    final inline fun getCharSequenceOrElse(kotlin/String, kotlin/Function0<kotlin/CharSequence>): kotlin/CharSequence // androidx.savedstate/SavedStateReader.getCharSequenceOrElse|getCharSequenceOrElse(kotlin.String;kotlin.Function0<kotlin.CharSequence>){}[0]
     final inline fun getDouble(kotlin/String): kotlin/Double // androidx.savedstate/SavedStateReader.getDouble|getDouble(kotlin.String){}[0]
     final inline fun getDoubleArray(kotlin/String): kotlin/DoubleArray // androidx.savedstate/SavedStateReader.getDoubleArray|getDoubleArray(kotlin.String){}[0]
     final inline fun getDoubleArrayOrElse(kotlin/String, kotlin/Function0<kotlin/DoubleArray>): kotlin/DoubleArray // androidx.savedstate/SavedStateReader.getDoubleArrayOrElse|getDoubleArrayOrElse(kotlin.String;kotlin.Function0<kotlin.DoubleArray>){}[0]
@@ -112,6 +118,9 @@
     final inline fun putBooleanArray(kotlin/String, kotlin/BooleanArray) // androidx.savedstate/SavedStateWriter.putBooleanArray|putBooleanArray(kotlin.String;kotlin.BooleanArray){}[0]
     final inline fun putChar(kotlin/String, kotlin/Char) // androidx.savedstate/SavedStateWriter.putChar|putChar(kotlin.String;kotlin.Char){}[0]
     final inline fun putCharArray(kotlin/String, kotlin/CharArray) // androidx.savedstate/SavedStateWriter.putCharArray|putCharArray(kotlin.String;kotlin.CharArray){}[0]
+    final inline fun putCharSequence(kotlin/String, kotlin/CharSequence) // androidx.savedstate/SavedStateWriter.putCharSequence|putCharSequence(kotlin.String;kotlin.CharSequence){}[0]
+    final inline fun putCharSequenceArray(kotlin/String, kotlin/Array<kotlin/CharSequence>) // androidx.savedstate/SavedStateWriter.putCharSequenceArray|putCharSequenceArray(kotlin.String;kotlin.Array<kotlin.CharSequence>){}[0]
+    final inline fun putCharSequenceList(kotlin/String, kotlin.collections/List<kotlin/CharSequence>) // androidx.savedstate/SavedStateWriter.putCharSequenceList|putCharSequenceList(kotlin.String;kotlin.collections.List<kotlin.CharSequence>){}[0]
     final inline fun putDouble(kotlin/String, kotlin/Double) // androidx.savedstate/SavedStateWriter.putDouble|putDouble(kotlin.String;kotlin.Double){}[0]
     final inline fun putDoubleArray(kotlin/String, kotlin/DoubleArray) // androidx.savedstate/SavedStateWriter.putDoubleArray|putDoubleArray(kotlin.String;kotlin.DoubleArray){}[0]
     final inline fun putFloat(kotlin/String, kotlin/Float) // androidx.savedstate/SavedStateWriter.putFloat|putFloat(kotlin.String;kotlin.Float){}[0]
diff --git a/savedstate/savedstate/src/androidMain/kotlin/androidx/savedstate/SavedStateReader.android.kt b/savedstate/savedstate/src/androidMain/kotlin/androidx/savedstate/SavedStateReader.android.kt
index b291dac..d3d59e0 100644
--- a/savedstate/savedstate/src/androidMain/kotlin/androidx/savedstate/SavedStateReader.android.kt
+++ b/savedstate/savedstate/src/androidMain/kotlin/androidx/savedstate/SavedStateReader.android.kt
@@ -20,9 +20,17 @@
 
 package androidx.savedstate
 
+import android.os.IBinder
 import android.os.Parcelable
+import android.util.Size
+import android.util.SizeF
+import android.util.SparseArray
 import androidx.core.os.BundleCompat.getParcelable
+import androidx.core.os.BundleCompat.getParcelableArray
 import androidx.core.os.BundleCompat.getParcelableArrayList
+import androidx.core.os.BundleCompat.getSerializable
+import androidx.core.os.BundleCompat.getSparseParcelableArray
+import java.io.Serializable
 
 @JvmInline
 actual value class SavedStateReader
@@ -31,6 +39,33 @@
     @PublishedApi internal actual val source: SavedState,
 ) {
 
+    /**
+     * Retrieves a [IBinder] object associated with the specified key. Throws an
+     * [IllegalStateException] if the key doesn't exist.
+     *
+     * @param key The key to retrieve the value for.
+     * @return The [IBinder] object associated with the key.
+     * @throws IllegalStateException If the key is not found.
+     */
+    inline fun getBinder(key: String): IBinder {
+        if (key !in this) keyNotFoundError(key)
+        return source.getBinder(key) ?: valueNotFoundError(key)
+    }
+
+    /**
+     * Retrieves a [IBinder] object associated with the specified key, or a default value if the key
+     * doesn't exist.
+     *
+     * @param key The key to retrieve the value for.
+     * @param defaultValue A function providing the default [IBinder] if the key is not found.
+     * @return The [IBinder] object associated with the key, or the default value if the key is not
+     *   found.
+     */
+    inline fun getBinderOrElse(key: String, defaultValue: () -> IBinder): IBinder {
+        if (key !in this) defaultValue()
+        return source.getBinder(key) ?: defaultValue()
+    }
+
     actual inline fun getBoolean(key: String): Boolean {
         if (key !in this) keyNotFoundError(key)
         return source.getBoolean(key, DEFAULT_BOOLEAN)
@@ -46,6 +81,19 @@
         return source.getChar(key, DEFAULT_CHAR)
     }
 
+    actual inline fun getCharSequence(key: String): CharSequence {
+        if (key !in this) keyNotFoundError(key)
+        return source.getCharSequence(key) ?: valueNotFoundError(key)
+    }
+
+    actual inline fun getCharSequenceOrElse(
+        key: String,
+        defaultValue: () -> CharSequence
+    ): CharSequence {
+        if (key !in this) defaultValue()
+        return source.getCharSequence(key) ?: defaultValue()
+    }
+
     actual inline fun getCharOrElse(key: String, defaultValue: () -> Char): Char {
         if (key !in this) defaultValue()
         return source.getChar(key, defaultValue())
@@ -118,6 +166,90 @@
         return getParcelable(source, key, T::class.java) ?: defaultValue()
     }
 
+    /**
+     * Retrieves a [Serializable] object associated with the specified key. Throws an
+     * [IllegalStateException] if the key doesn't exist.
+     *
+     * @param key The key to retrieve the value for.
+     * @return The [Serializable] object associated with the key.
+     * @throws IllegalStateException If the key is not found.
+     */
+    inline fun <reified T : Serializable> getSerializable(key: String): T {
+        if (key !in this) keyNotFoundError(key)
+        return getSerializable(source, key, T::class.java) ?: valueNotFoundError(key)
+    }
+
+    /**
+     * Retrieves a [Serializable] object associated with the specified key, or a default value if
+     * the key doesn't exist.
+     *
+     * @param key The key to retrieve the value for.
+     * @param defaultValue A function providing the default [Serializable] if the key is not found.
+     * @return The [Serializable] object associated with the key, or the default value if the key is
+     *   not found.
+     */
+    inline fun <reified T : Serializable> getSerializableOrElse(
+        key: String,
+        defaultValue: () -> T
+    ): T {
+        if (key !in this) defaultValue()
+        return getSerializable(source, key, T::class.java) ?: defaultValue()
+    }
+
+    /**
+     * Retrieves a [Size] object associated with the specified key. Throws an
+     * [IllegalStateException] if the key doesn't exist.
+     *
+     * @param key The key to retrieve the value for.
+     * @return The [Size] object associated with the key.
+     * @throws IllegalStateException If the key is not found.
+     */
+    inline fun getSize(key: String): Size {
+        if (key !in this) keyNotFoundError(key)
+        return source.getSize(key) ?: valueNotFoundError(key)
+    }
+
+    /**
+     * Retrieves a [Size] object associated with the specified key, or a default value if the key
+     * doesn't exist.
+     *
+     * @param key The key to retrieve the value for.
+     * @param defaultValue A function providing the default [Size] if the key is not found.
+     * @return The [Size] object associated with the key, or the default value if the key is not
+     *   found.
+     */
+    inline fun getSizeOrElse(key: String, defaultValue: () -> Size): Size {
+        if (key !in this) defaultValue()
+        return source.getSize(key) ?: defaultValue()
+    }
+
+    /**
+     * Retrieves a [SizeF] object associated with the specified key. Throws an
+     * [IllegalStateException] if the key doesn't exist.
+     *
+     * @param key The key to retrieve the value for.
+     * @return The [SizeF] object associated with the key.
+     * @throws IllegalStateException If the key is not found.
+     */
+    inline fun getSizeF(key: String): SizeF {
+        if (key !in this) keyNotFoundError(key)
+        return source.getSizeF(key) ?: valueNotFoundError(key)
+    }
+
+    /**
+     * Retrieves a [SizeF] object associated with the specified key, or a default value if the key
+     * doesn't exist.
+     *
+     * @param key The key to retrieve the value for.
+     * @param defaultValue A function providing the default [SizeF] if the key is not found.
+     * @return The [SizeF] object associated with the key, or the default value if the key is not
+     *   found.
+     */
+    inline fun getSizeFOrElse(key: String, defaultValue: () -> SizeF): SizeF {
+        if (key !in this) defaultValue()
+        return source.getSizeF(key) ?: defaultValue()
+    }
+
     actual inline fun getString(key: String): String {
         if (key !in this) keyNotFoundError(key)
         return source.getString(key) ?: valueNotFoundError(key)
@@ -138,6 +270,19 @@
         return source.getIntegerArrayList(key) ?: defaultValue()
     }
 
+    actual inline fun getCharSequenceList(key: String): List<CharSequence> {
+        if (key !in this) keyNotFoundError(key)
+        return source.getCharSequenceArrayList(key) ?: valueNotFoundError(key)
+    }
+
+    actual inline fun getCharSequenceListOrElse(
+        key: String,
+        defaultValue: () -> List<CharSequence>
+    ): List<CharSequence> {
+        if (key !in this) defaultValue()
+        return source.getCharSequenceArrayList(key) ?: defaultValue()
+    }
+
     actual inline fun getStringList(key: String): List<String> {
         if (key !in this) keyNotFoundError(key)
         return source.getStringArrayList(key) ?: valueNotFoundError(key)
@@ -170,7 +315,7 @@
      *
      * @param key The [key] to retrieve the value for.
      * @param defaultValue A function providing the default value if the [key] is not found or the
-     *   retrieved value is not a list of [Parcelable].
+     *   retrieved value is not a [List] of [Parcelable].
      * @return The list of elements of [Parcelable] associated with the [key], or the default value
      *   if the [key] is not found.
      */
@@ -205,6 +350,21 @@
         return source.getCharArray(key) ?: defaultValue()
     }
 
+    @Suppress("ArrayReturn")
+    actual inline fun getCharSequenceArray(key: String): Array<CharSequence> {
+        if (key !in this) keyNotFoundError(key)
+        return source.getCharSequenceArray(key) ?: valueNotFoundError(key)
+    }
+
+    @Suppress("ArrayReturn")
+    actual inline fun getCharSequenceArrayOrElse(
+        key: String,
+        defaultValue: () -> Array<CharSequence>
+    ): Array<CharSequence> {
+        if (key !in this) defaultValue()
+        return source.getCharSequenceArray(key) ?: defaultValue()
+    }
+
     actual inline fun getDoubleArray(key: String): DoubleArray {
         if (key !in this) keyNotFoundError(key)
         return source.getDoubleArray(key) ?: valueNotFoundError(key)
@@ -261,6 +421,75 @@
         return source.getStringArray(key) ?: defaultValue()
     }
 
+    /**
+     * Retrieves an [Array] of elements of [Parcelable] associated with the specified [key]. Throws
+     * an [IllegalStateException] if the [key] doesn't exist.
+     *
+     * @param key The [key] to retrieve the value for.
+     * @return The [Array] of elements of [Parcelable] associated with the [key].
+     * @throws IllegalStateException If the [key] is not found.
+     */
+    @Suppress("ArrayReturn")
+    inline fun <reified T : Parcelable> getParcelableArray(key: String): Array<T> {
+        if (key !in this) keyNotFoundError(key)
+        @Suppress("UNCHECKED_CAST")
+        return getParcelableArray(source, key, T::class.java) as? Array<T>
+            ?: valueNotFoundError(key)
+    }
+
+    /**
+     * Retrieves a [Array] of elements of [Parcelable] associated with the specified [key], or a
+     * default value if the [key] doesn't exist.
+     *
+     * @param key The [key] to retrieve the value for.
+     * @param defaultValue A function providing the default value if the [key] is not found or the
+     *   retrieved value is not a [Array] of [Parcelable].
+     * @return The [Array] of elements of [Parcelable] associated with the [key], or the default
+     *   value if the [key] is not found.
+     */
+    @Suppress("ArrayReturn")
+    inline fun <reified T : Parcelable> getParcelableArrayOrElse(
+        key: String,
+        defaultValue: () -> Array<T>
+    ): Array<T> {
+        if (key !in this) defaultValue()
+        @Suppress("UNCHECKED_CAST")
+        return getParcelableArray(source, key, T::class.java) as? Array<T> ?: defaultValue()
+    }
+
+    /**
+     * Retrieves an [SparseArray] of elements of [Parcelable] associated with the specified [key].
+     * Throws an [IllegalStateException] if the [key] doesn't exist.
+     *
+     * @param key The [key] to retrieve the value for.
+     * @return The [SparseArray] of elements of [Parcelable] associated with the [key].
+     * @throws IllegalStateException If the [key] is not found.
+     */
+    inline fun <reified T : Parcelable> getSparseParcelableArray(key: String): SparseArray<T> {
+        if (key !in this) keyNotFoundError(key)
+        return getSparseParcelableArray(source, key, T::class.java) as? SparseArray<T>
+            ?: valueNotFoundError(key)
+    }
+
+    /**
+     * Retrieves a [SparseArray] of elements of [Parcelable] associated with the specified [key], or
+     * a default value if the [key] doesn't exist.
+     *
+     * @param key The [key] to retrieve the value for.
+     * @param defaultValue A function providing the default value if the [key] is not found or the
+     *   retrieved value is not a [SparseArray] of [Parcelable].
+     * @return The [SparseArray] of elements of [Parcelable] associated with the [key], or the
+     *   default value if the [key] is not found.
+     */
+    inline fun <reified T : Parcelable> getSparseParcelableArrayOrElse(
+        key: String,
+        defaultValue: () -> SparseArray<T>
+    ): SparseArray<T> {
+        if (key !in this) defaultValue()
+        return getSparseParcelableArray(source, key, T::class.java) as? SparseArray<T>
+            ?: defaultValue()
+    }
+
     actual inline fun getSavedState(key: String): SavedState {
         if (key !in this) keyNotFoundError(key)
         return source.getBundle(key) ?: valueNotFoundError(key)
diff --git a/savedstate/savedstate/src/androidMain/kotlin/androidx/savedstate/SavedStateWriter.android.kt b/savedstate/savedstate/src/androidMain/kotlin/androidx/savedstate/SavedStateWriter.android.kt
index db7d120..a2795d0 100644
--- a/savedstate/savedstate/src/androidMain/kotlin/androidx/savedstate/SavedStateWriter.android.kt
+++ b/savedstate/savedstate/src/androidMain/kotlin/androidx/savedstate/SavedStateWriter.android.kt
@@ -20,7 +20,12 @@
 
 package androidx.savedstate
 
+import android.os.IBinder
 import android.os.Parcelable
+import android.util.Size
+import android.util.SizeF
+import android.util.SparseArray
+import java.io.Serializable
 
 @JvmInline
 actual value class SavedStateWriter
@@ -29,6 +34,16 @@
     @PublishedApi internal actual val source: SavedState,
 ) {
 
+    /**
+     * Stores an [IBinder] value associated with the specified key in the [IBinder].
+     *
+     * @param key The key to associate the value with.
+     * @param value The [IBinder] value to store.
+     */
+    inline fun putBinder(key: String, value: IBinder) {
+        source.putBinder(key, value)
+    }
+
     actual inline fun putBoolean(key: String, value: Boolean) {
         source.putBoolean(key, value)
     }
@@ -37,6 +52,10 @@
         source.putChar(key, value)
     }
 
+    actual inline fun putCharSequence(key: String, value: CharSequence) {
+        source.putCharSequence(key, value)
+    }
+
     actual inline fun putDouble(key: String, value: Double) {
         source.putDouble(key, value)
     }
@@ -67,6 +86,36 @@
         source.putParcelable(key, value)
     }
 
+    /**
+     * Stores an [Serializable] value associated with the specified key in the [Serializable].
+     *
+     * @param key The key to associate the value with.
+     * @param value The [Serializable] value to store.
+     */
+    inline fun <reified T : Serializable> putSerializable(key: String, value: T) {
+        source.putSerializable(key, value)
+    }
+
+    /**
+     * Stores an [Size] value associated with the specified key in the [Size].
+     *
+     * @param key The key to associate the value with.
+     * @param value The [Size] value to store.
+     */
+    inline fun putSize(key: String, value: Size) {
+        source.putSize(key, value)
+    }
+
+    /**
+     * Stores an [SizeF] value associated with the specified key in the [SizeF].
+     *
+     * @param key The key to associate the value with.
+     * @param value The [SizeF] value to store.
+     */
+    inline fun putSizeF(key: String, value: SizeF) {
+        source.putSizeF(key, value)
+    }
+
     actual inline fun putString(key: String, value: String) {
         source.putString(key, value)
     }
@@ -75,16 +124,20 @@
         source.putIntegerArrayList(key, values.toArrayListUnsafe())
     }
 
+    actual inline fun putCharSequenceList(key: String, values: List<CharSequence>) {
+        source.putCharSequenceArrayList(key, values.toArrayListUnsafe())
+    }
+
     actual inline fun putStringList(key: String, values: List<String>) {
         source.putStringArrayList(key, values.toArrayListUnsafe())
     }
 
     /**
-     * Stores a list of elements of [Parcelable] associated with the specified key in the
+     * Stores a [List] of elements of [Parcelable] associated with the specified key in the
      * [SavedState].
      *
      * @param key The key to associate the value with.
-     * @param values The list of elements to store.
+     * @param values The [List] of elements to store.
      */
     inline fun <reified T : Parcelable> putParcelableList(key: String, values: List<T>) {
         source.putParcelableArrayList(key, values.toArrayListUnsafe())
@@ -98,6 +151,13 @@
         source.putCharArray(key, values)
     }
 
+    actual inline fun putCharSequenceArray(
+        key: String,
+        @Suppress("ArrayReturn") values: Array<CharSequence>
+    ) {
+        source.putCharSequenceArray(key, values)
+    }
+
     actual inline fun putDoubleArray(key: String, values: DoubleArray) {
         source.putDoubleArray(key, values)
     }
@@ -118,6 +178,34 @@
         source.putStringArray(key, values)
     }
 
+    /**
+     * Stores a [Array] of elements of [Parcelable] associated with the specified key in the
+     * [SavedState].
+     *
+     * @param key The key to associate the value with.
+     * @param values The [Array] of elements to store.
+     */
+    inline fun <reified T : Parcelable> putParcelableArray(
+        key: String,
+        @Suppress("ArrayReturn") values: Array<T>
+    ) {
+        source.putParcelableArray(key, values)
+    }
+
+    /**
+     * Stores a [SparseArray] of elements of [Parcelable] associated with the specified key in the
+     * [SavedState].
+     *
+     * @param key The key to associate the value with.
+     * @param values The [SparseArray] of elements to store.
+     */
+    inline fun <reified T : Parcelable> putSparseParcelableArray(
+        key: String,
+        values: SparseArray<T>
+    ) {
+        source.putSparseParcelableArray(key, values)
+    }
+
     actual inline fun putSavedState(key: String, value: SavedState) {
         source.putBundle(key, value)
     }
diff --git a/savedstate/savedstate/src/androidUnitTest/kotlin/androidx/savedstate/ParcelableSavedStateTest.android.kt b/savedstate/savedstate/src/androidUnitTest/kotlin/androidx/savedstate/ParcelableSavedStateTest.android.kt
deleted file mode 100644
index 0a0dd72..0000000
--- a/savedstate/savedstate/src/androidUnitTest/kotlin/androidx/savedstate/ParcelableSavedStateTest.android.kt
+++ /dev/null
@@ -1,153 +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.savedstate
-
-import android.os.Parcel
-import android.os.Parcelable
-import androidx.kruth.assertThat
-import androidx.kruth.assertThrows
-import kotlin.test.Test
-
-internal class ParcelableSavedStateTest : RobolectricTest() {
-
-    @Test
-    fun getParcelable_whenSet_returns() {
-        val underTest = savedState { putParcelable(KEY_1, PARCELABLE_VALUE_1) }
-        val actual = underTest.read { getParcelable<TestParcelable>(KEY_1) }
-
-        assertThat(actual).isEqualTo(PARCELABLE_VALUE_1)
-    }
-
-    @Test
-    fun getParcelable_whenNotSet_throws() {
-        assertThrows<IllegalArgumentException> {
-            savedState().read { getParcelable<TestParcelable>(KEY_1) }
-        }
-    }
-
-    @Test
-    fun getParcelable_whenSet_differentType_returnsDefault() {
-        val underTest = savedState { putInt(KEY_1, Int.MAX_VALUE) }
-
-        assertThrows<IllegalStateException> {
-            underTest.read { getParcelable<TestParcelable>(KEY_1) }
-        }
-    }
-
-    @Test
-    fun getParcelableOrElse_whenSet_returns() {
-        val underTest = savedState { putParcelable(KEY_1, PARCELABLE_VALUE_1) }
-        val actual = underTest.read { getParcelableOrElse(KEY_1) { PARCELABLE_VALUE_2 } }
-
-        assertThat(actual).isEqualTo(PARCELABLE_VALUE_1)
-    }
-
-    @Test
-    fun getParcelableOrElse_whenNotSet_returnsElse() {
-        val actual = savedState().read { getParcelableOrElse(KEY_1) { PARCELABLE_VALUE_1 } }
-
-        assertThat(actual).isEqualTo(PARCELABLE_VALUE_1)
-    }
-
-    @Test
-    fun getParcelableOrElse_whenSet_differentType_returnsDefault() {
-        val underTest = savedState { putInt(KEY_1, Int.MAX_VALUE) }
-        val actual = underTest.read { getParcelableOrElse(KEY_1) { PARCELABLE_VALUE_1 } }
-
-        assertThat(actual).isEqualTo(PARCELABLE_VALUE_1)
-    }
-
-    @Test
-    fun getParcelableList_whenSet_returns() {
-        val expected = List(size = 5) { idx -> TestParcelable(idx) }
-
-        val underTest = savedState { putParcelableList(KEY_1, expected) }
-        val actual = underTest.read { getParcelableList<TestParcelable>(KEY_1) }
-
-        assertThat(actual).isEqualTo(expected)
-    }
-
-    @Test
-    fun getList_ofParcelable_whenNotSet_throws() {
-        assertThrows<IllegalArgumentException> {
-            savedState().read { getParcelableList<TestParcelable>(KEY_1) }
-        }
-    }
-
-    @Test
-    fun getList_whenSet_differentType_throws() {
-        val underTest = savedState { putInt(KEY_1, Int.MAX_VALUE) }
-
-        assertThrows<IllegalStateException> {
-            underTest.read { getParcelableList<TestParcelable>(KEY_1) }
-        }
-    }
-
-    @Test
-    fun getListOrElse_ofParcelable_whenSet_returns() {
-        val expected = List(size = 5) { idx -> TestParcelable(idx) }
-
-        val underTest = savedState { putParcelableList(KEY_1, expected) }
-        val actual =
-            underTest.read { getParcelableListOrElse<TestParcelable>(KEY_1) { emptyList() } }
-
-        assertThat(actual).isEqualTo(expected)
-    }
-
-    @Test
-    fun getListOrElse_ofParcelable_whenNotSet_returnsElse() {
-        val actual =
-            savedState().read { getParcelableListOrElse<TestParcelable>(KEY_1) { emptyList() } }
-
-        assertThat(actual).isEqualTo(emptyList<TestParcelable>())
-    }
-
-    @Test
-    fun getListOrElse_whenSet_differentType_throws() {
-        val underTest = savedState { putInt(KEY_1, Int.MAX_VALUE) }
-        val actual = underTest.read { getParcelableListOrElse(KEY_1) { emptyList() } }
-
-        assertThat(actual).isEqualTo(emptyList<Parcelable>())
-    }
-
-    private companion object {
-        const val KEY_1 = "KEY_1"
-        val PARCELABLE_VALUE_1 = TestParcelable(value = Int.MIN_VALUE)
-        val PARCELABLE_VALUE_2 = TestParcelable(value = Int.MAX_VALUE)
-    }
-
-    internal data class TestParcelable(val value: Int) : Parcelable {
-
-        override fun describeContents(): Int = 0
-
-        override fun writeToParcel(dest: Parcel, flags: Int) {
-            dest.writeInt(value)
-        }
-
-        companion object {
-            @Suppress("unused")
-            @JvmField
-            val CREATOR =
-                object : Parcelable.Creator<TestParcelable> {
-                    override fun createFromParcel(source: Parcel) =
-                        TestParcelable(value = source.readInt())
-
-                    override fun newArray(size: Int) = arrayOfNulls<TestParcelable>(size)
-                }
-        }
-    }
-}
diff --git a/savedstate/savedstate/src/androidUnitTest/kotlin/androidx/savedstate/SavedStateAndroidTest.android.kt b/savedstate/savedstate/src/androidUnitTest/kotlin/androidx/savedstate/SavedStateAndroidTest.android.kt
new file mode 100644
index 0000000..91f3395
--- /dev/null
+++ b/savedstate/savedstate/src/androidUnitTest/kotlin/androidx/savedstate/SavedStateAndroidTest.android.kt
@@ -0,0 +1,483 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.savedstate
+
+import android.os.IBinder
+import android.os.Parcel
+import android.os.Parcelable
+import android.util.Size
+import android.util.SizeF
+import android.util.SparseArray
+import androidx.kruth.assertThat
+import androidx.kruth.assertThrows
+import java.io.FileDescriptor
+import java.io.Serializable
+import kotlin.test.Test
+
+internal class ParcelableSavedStateTest : RobolectricTest() {
+
+    @Test
+    fun getBinder_whenSet_returns() {
+        val underTest = savedState { putBinder(KEY_1, BINDER_VALUE_1) }
+        val actual = underTest.read { getBinder(KEY_1) }
+
+        assertThat(actual).isEqualTo(BINDER_VALUE_1)
+    }
+
+    @Test
+    fun getBinder_whenNotSet_throws() {
+        assertThrows<IllegalArgumentException> { savedState().read { getBinder(KEY_1) } }
+    }
+
+    @Test
+    fun getBinder_whenSet_differentType_returnsDefault() {
+        val underTest = savedState { putInt(KEY_1, Int.MAX_VALUE) }
+
+        assertThrows<IllegalStateException> { underTest.read { getBinder(KEY_1) } }
+    }
+
+    @Test
+    fun getBinderOrElse_whenSet_returns() {
+        val underTest = savedState { putBinder(KEY_1, BINDER_VALUE_1) }
+        val actual = underTest.read { getBinderOrElse(KEY_1) { BINDER_VALUE_2 } }
+
+        assertThat(actual).isEqualTo(BINDER_VALUE_1)
+    }
+
+    @Test
+    fun getBinderOrElse_whenNotSet_returnsElse() {
+        val actual = savedState().read { getBinderOrElse(KEY_1) { BINDER_VALUE_2 } }
+
+        assertThat(actual).isEqualTo(BINDER_VALUE_2)
+    }
+
+    @Test
+    fun getBinderOrElse_whenSet_differentType_returnsDefault() {
+        val underTest = savedState { putInt(KEY_1, Int.MAX_VALUE) }
+        val actual = underTest.read { getBinderOrElse(KEY_1) { BINDER_VALUE_2 } }
+
+        assertThat(actual).isEqualTo(BINDER_VALUE_2)
+    }
+
+    @Test
+    fun getSize_whenSet_returns() {
+        val underTest = savedState { putSize(KEY_1, SIZE_IN_PIXEL_VALUE_1) }
+        val actual = underTest.read { getSize(KEY_1) }
+
+        assertThat(actual).isEqualTo(SIZE_IN_PIXEL_VALUE_1)
+    }
+
+    @Test
+    fun getSize_whenNotSet_throws() {
+        assertThrows<IllegalArgumentException> { savedState().read { getSize(KEY_1) } }
+    }
+
+    @Test
+    fun getSize_whenSet_differentType_throws() {
+        val underTest = savedState { putInt(KEY_1, Int.MAX_VALUE) }
+
+        assertThrows<IllegalStateException> { underTest.read { getSize(KEY_1) } }
+    }
+
+    @Test
+    fun getSizeOrElse_whenSet_returns() {
+        val underTest = savedState { putSize(KEY_1, SIZE_IN_PIXEL_VALUE_1) }
+        val actual = underTest.read { getSizeOrElse(KEY_1) { SIZE_IN_PIXEL_VALUE_2 } }
+
+        assertThat(actual).isEqualTo(SIZE_IN_PIXEL_VALUE_1)
+    }
+
+    @Test
+    fun getSizeOrElse_whenNotSet_returnsElse() {
+        val actual = savedState().read { getSizeOrElse(KEY_1) { SIZE_IN_PIXEL_VALUE_2 } }
+
+        assertThat(actual).isEqualTo(SIZE_IN_PIXEL_VALUE_2)
+    }
+
+    @Test
+    fun getSizeOrElse_whenSet_differentType_returnsDefault() {
+        val underTest = savedState { putInt(KEY_1, Int.MAX_VALUE) }
+        val actual = underTest.read { getSizeOrElse(KEY_1) { SIZE_IN_PIXEL_VALUE_2 } }
+
+        assertThat(actual).isEqualTo(SIZE_IN_PIXEL_VALUE_2)
+    }
+
+    @Test
+    fun getSizeF_whenSet_returns() {
+        val underTest = savedState { putSizeF(KEY_1, SIZE_IN_FLOAT_VALUE_1) }
+        val actual = underTest.read { getSizeF(KEY_1) }
+
+        assertThat(actual).isEqualTo(SIZE_IN_FLOAT_VALUE_1)
+    }
+
+    @Test
+    fun getSizeF_whenNotSet_throws() {
+        assertThrows<IllegalArgumentException> { savedState().read { getSizeF(KEY_1) } }
+    }
+
+    @Test
+    fun getSizeF_whenSet_differentType_throws() {
+        val underTest = savedState { putInt(KEY_1, Int.MAX_VALUE) }
+
+        assertThrows<IllegalStateException> { underTest.read { getSizeF(KEY_1) } }
+    }
+
+    @Test
+    fun getSizeFOrElse_whenSet_returns() {
+        val underTest = savedState { putSizeF(KEY_1, SIZE_IN_FLOAT_VALUE_1) }
+        val actual = underTest.read { getSizeFOrElse(KEY_1) { SIZE_IN_FLOAT_VALUE_2 } }
+
+        assertThat(actual).isEqualTo(SIZE_IN_FLOAT_VALUE_1)
+    }
+
+    @Test
+    fun getSizeFOrElse_whenNotSet_returnsElse() {
+        val actual = savedState().read { getSizeFOrElse(KEY_1) { SIZE_IN_FLOAT_VALUE_2 } }
+
+        assertThat(actual).isEqualTo(SIZE_IN_FLOAT_VALUE_2)
+    }
+
+    @Test
+    fun getSizeFOrElse_whenSet_differentType_returnsDefault() {
+        val underTest = savedState { putInt(KEY_1, Int.MAX_VALUE) }
+        val actual = underTest.read { getSizeFOrElse(KEY_1) { SIZE_IN_FLOAT_VALUE_2 } }
+
+        assertThat(actual).isEqualTo(SIZE_IN_FLOAT_VALUE_2)
+    }
+
+    @Test
+    fun getParcelable_whenSet_returns() {
+        val underTest = savedState { putParcelable(KEY_1, PARCELABLE_VALUE_1) }
+        val actual = underTest.read { getParcelable<TestParcelable>(KEY_1) }
+
+        assertThat(actual).isEqualTo(PARCELABLE_VALUE_1)
+    }
+
+    @Test
+    fun getParcelable_whenNotSet_throws() {
+        assertThrows<IllegalArgumentException> {
+            savedState().read { getParcelable<TestParcelable>(KEY_1) }
+        }
+    }
+
+    @Test
+    fun getParcelable_whenSet_differentType_throws() {
+        val underTest = savedState { putInt(KEY_1, Int.MAX_VALUE) }
+
+        assertThrows<IllegalStateException> {
+            underTest.read { getParcelable<TestParcelable>(KEY_1) }
+        }
+    }
+
+    @Test
+    fun getParcelableOrElse_whenSet_returns() {
+        val underTest = savedState { putParcelable(KEY_1, PARCELABLE_VALUE_1) }
+        val actual = underTest.read { getParcelableOrElse(KEY_1) { PARCELABLE_VALUE_2 } }
+
+        assertThat(actual).isEqualTo(PARCELABLE_VALUE_1)
+    }
+
+    @Test
+    fun getParcelableOrElse_whenNotSet_returnsElse() {
+        val actual = savedState().read { getParcelableOrElse(KEY_1) { PARCELABLE_VALUE_1 } }
+
+        assertThat(actual).isEqualTo(PARCELABLE_VALUE_1)
+    }
+
+    @Test
+    fun getParcelableOrElse_whenSet_differentType_returnsDefault() {
+        val underTest = savedState { putInt(KEY_1, Int.MAX_VALUE) }
+        val actual = underTest.read { getParcelableOrElse(KEY_1) { PARCELABLE_VALUE_1 } }
+
+        assertThat(actual).isEqualTo(PARCELABLE_VALUE_1)
+    }
+
+    @Test
+    fun getParcelableList_whenSet_returns() {
+        val expected = List(size = 5) { idx -> TestParcelable(idx) }
+
+        val underTest = savedState { putParcelableList(KEY_1, expected) }
+        val actual = underTest.read { getParcelableList<TestParcelable>(KEY_1) }
+
+        assertThat(actual).isEqualTo(expected)
+    }
+
+    @Test
+    fun getParcelableList_whenNotSet_throws() {
+        assertThrows<IllegalArgumentException> {
+            savedState().read { getParcelableList<TestParcelable>(KEY_1) }
+        }
+    }
+
+    @Test
+    fun getParcelableList_whenSet_differentType_throws() {
+        val underTest = savedState { putInt(KEY_1, Int.MAX_VALUE) }
+
+        assertThrows<IllegalStateException> {
+            underTest.read { getParcelableList<TestParcelable>(KEY_1) }
+        }
+    }
+
+    @Test
+    fun getParcelableListOrElse_whenSet_returns() {
+        val expected = List(size = 5) { idx -> TestParcelable(idx) }
+
+        val underTest = savedState { putParcelableList(KEY_1, expected) }
+        val actual =
+            underTest.read { getParcelableListOrElse<TestParcelable>(KEY_1) { emptyList() } }
+
+        assertThat(actual).isEqualTo(expected)
+    }
+
+    @Test
+    fun getParcelableListOrElse_whenNotSet_returnsElse() {
+        val actual =
+            savedState().read { getParcelableListOrElse<TestParcelable>(KEY_1) { emptyList() } }
+
+        assertThat(actual).isEqualTo(emptyList<TestParcelable>())
+    }
+
+    @Test
+    fun getParcelableListOrElse_whenSet_differentType_throws() {
+        val underTest = savedState { putInt(KEY_1, Int.MAX_VALUE) }
+        val actual = underTest.read { getParcelableListOrElse(KEY_1) { emptyList() } }
+
+        assertThat(actual).isEqualTo(emptyList<Parcelable>())
+    }
+
+    @Test
+    fun getParcelableArray_whenSet_returns() {
+        val expected = Array(size = 5) { idx -> TestParcelable(idx) }
+
+        val underTest = savedState { putParcelableArray(KEY_1, expected) }
+        val actual = underTest.read { getParcelableArray<TestParcelable>(KEY_1) }
+
+        assertThat(actual).isEqualTo(expected)
+    }
+
+    @Test
+    fun getParcelableArray_ofParcelable_whenNotSet_throws() {
+        assertThrows<IllegalArgumentException> {
+            savedState().read { getParcelableArray<TestParcelable>(KEY_1) }
+        }
+    }
+
+    @Test
+    fun getParcelableArray_whenSet_differentType_throws() {
+        val underTest = savedState { putInt(KEY_1, Int.MAX_VALUE) }
+
+        assertThrows<IllegalStateException> {
+            underTest.read { getParcelableArray<TestParcelable>(KEY_1) }
+        }
+    }
+
+    @Test
+    fun getParcelableArrayOrElse_whenSet_returns() {
+        val expected = Array(size = 5) { idx -> TestParcelable(idx) }
+
+        val underTest = savedState { putParcelableArray(KEY_1, expected) }
+        val actual =
+            underTest.read { getParcelableArrayOrElse<TestParcelable>(KEY_1) { emptyArray() } }
+
+        assertThat(actual).isEqualTo(expected)
+    }
+
+    @Test
+    fun getParcelableArrayOrElse_ofParcelable_whenNotSet_returnsElse() {
+        val actual =
+            savedState().read { getParcelableArrayOrElse<TestParcelable>(KEY_1) { emptyArray() } }
+
+        assertThat(actual).isEqualTo(emptyArray<TestParcelable>())
+    }
+
+    @Test
+    fun getParcelableArrayOrElse_whenSet_differentType_throws() {
+        val underTest = savedState { putInt(KEY_1, Int.MAX_VALUE) }
+        val actual = underTest.read { getParcelableArrayOrElse(KEY_1) { emptyArray() } }
+
+        assertThat(actual).isEqualTo(emptyArray<Parcelable>())
+    }
+
+    @Test
+    fun getSparseParcelableArray_whenSet_returns() {
+        val expected = SPARSE_PARCELABLE_ARRAY
+
+        val underTest = savedState { putSparseParcelableArray(KEY_1, expected) }
+        val actual = underTest.read { getSparseParcelableArray<TestParcelable>(KEY_1) }
+
+        assertThat(actual).isEqualTo(expected)
+    }
+
+    @Test
+    fun getSparseParcelableArray_ofParcelable_whenNotSet_throws() {
+        assertThrows<IllegalArgumentException> {
+            savedState().read { getSparseParcelableArray<TestParcelable>(KEY_1) }
+        }
+    }
+
+    @Test
+    fun getSparseParcelableArray_whenSet_differentType_throws() {
+        val underTest = savedState { putInt(KEY_1, Int.MAX_VALUE) }
+
+        assertThrows<IllegalStateException> {
+            underTest.read { getSparseParcelableArray<TestParcelable>(KEY_1) }
+        }
+    }
+
+    @Test
+    fun getSparseParcelableArrayOrElse_whenSet_returns() {
+        val expected = SPARSE_PARCELABLE_ARRAY
+
+        val underTest = savedState { putSparseParcelableArray(KEY_1, expected) }
+        val actual =
+            underTest.read {
+                getSparseParcelableArrayOrElse<TestParcelable>(KEY_1) { SparseArray() }
+            }
+
+        assertThat(actual).isEqualTo(expected)
+    }
+
+    @Test
+    fun getSparseParcelableArrayOrElse_ofParcelable_whenNotSet_returnsElse() {
+        val expected = SPARSE_PARCELABLE_ARRAY
+
+        val actual =
+            savedState().read { getSparseParcelableArrayOrElse<TestParcelable>(KEY_1) { expected } }
+
+        assertThat(actual).isEqualTo(expected)
+    }
+
+    @Test
+    fun getSparseParcelableArrayOrElse_whenSet_differentType_throws() {
+        val expected = SPARSE_PARCELABLE_ARRAY
+
+        val underTest = savedState { putInt(KEY_1, Int.MAX_VALUE) }
+        val actual = underTest.read { getSparseParcelableArrayOrElse(KEY_1) { expected } }
+
+        assertThat(actual).isEqualTo(expected)
+    }
+
+    @Test
+    fun getSerializable_whenSet_returns() {
+        val underTest = savedState { putSerializable(KEY_1, SERIALIZABLE_VALUE_1) }
+        val actual = underTest.read { getSerializable<TestSerializable>(KEY_1) }
+
+        assertThat(actual).isEqualTo(SERIALIZABLE_VALUE_1)
+    }
+
+    @Test
+    fun getSerializable_whenNotSet_throws() {
+        assertThrows<IllegalArgumentException> {
+            savedState().read { getSerializable<TestSerializable>(KEY_1) }
+        }
+    }
+
+    @Test
+    fun getSerializable_whenSet_differentType_throws() {
+        val underTest = savedState { putInt(KEY_1, Int.MAX_VALUE) }
+
+        assertThrows<IllegalStateException> {
+            underTest.read { getSerializable<TestSerializable>(KEY_1) }
+        }
+    }
+
+    @Test
+    fun getSerializableOrElse_whenSet_returns() {
+        val underTest = savedState { putSerializable(KEY_1, SERIALIZABLE_VALUE_1) }
+        val actual = underTest.read { getSerializableOrElse(KEY_1) { SERIALIZABLE_VALUE_2 } }
+
+        assertThat(actual).isEqualTo(SERIALIZABLE_VALUE_1)
+    }
+
+    @Test
+    fun getSerializableOrElse_whenNotSet_returnsElse() {
+        val actual = savedState().read { getSerializableOrElse(KEY_1) { SERIALIZABLE_VALUE_2 } }
+
+        assertThat(actual).isEqualTo(SERIALIZABLE_VALUE_2)
+    }
+
+    @Test
+    fun getSerializableOrElse_whenSet_differentType_returnsDefault() {
+        val underTest = savedState { putInt(KEY_1, Int.MAX_VALUE) }
+        val actual = underTest.read { getSerializableOrElse(KEY_1) { SERIALIZABLE_VALUE_1 } }
+
+        assertThat(actual).isEqualTo(SERIALIZABLE_VALUE_1)
+    }
+
+    private companion object {
+        const val KEY_1 = "KEY_1"
+        val SIZE_IN_PIXEL_VALUE_1 = Size(/* width= */ Int.MIN_VALUE, /* height */ Int.MIN_VALUE)
+        val SIZE_IN_PIXEL_VALUE_2 = Size(/* width= */ Int.MAX_VALUE, /* height */ Int.MAX_VALUE)
+        val SIZE_IN_FLOAT_VALUE_1 =
+            SizeF(/* width= */ Float.MIN_VALUE, /* height */ Float.MIN_VALUE)
+        val SIZE_IN_FLOAT_VALUE_2 =
+            SizeF(/* width= */ Float.MAX_VALUE, /* height */ Float.MAX_VALUE)
+        val BINDER_VALUE_1 = TestBinder(value = Int.MIN_VALUE)
+        val BINDER_VALUE_2 = TestBinder(value = Int.MAX_VALUE)
+        val PARCELABLE_VALUE_1 = TestParcelable(value = Int.MIN_VALUE)
+        val PARCELABLE_VALUE_2 = TestParcelable(value = Int.MAX_VALUE)
+        val SERIALIZABLE_VALUE_1 = TestSerializable(value = Int.MIN_VALUE)
+        val SERIALIZABLE_VALUE_2 = TestSerializable(value = Int.MAX_VALUE)
+        val SPARSE_PARCELABLE_ARRAY =
+            SparseArray<TestParcelable>(/* initialCapacity= */ 5).apply {
+                repeat(times = 5) { idx -> put(idx, TestParcelable(idx)) }
+            }
+    }
+
+    internal data class TestBinder(val value: Int) : IBinder {
+        override fun getInterfaceDescriptor() = error("")
+
+        override fun pingBinder() = error("")
+
+        override fun isBinderAlive() = error("")
+
+        override fun queryLocalInterface(descriptor: String) = error("")
+
+        override fun dump(fd: FileDescriptor, args: Array<out String>?) = error("")
+
+        override fun dumpAsync(fd: FileDescriptor, args: Array<out String>?) = error("")
+
+        override fun transact(code: Int, data: Parcel, reply: Parcel?, flags: Int) = error("")
+
+        override fun linkToDeath(recipient: IBinder.DeathRecipient, flags: Int) = error("")
+
+        override fun unlinkToDeath(recipient: IBinder.DeathRecipient, flags: Int) = error("")
+    }
+
+    internal data class TestParcelable(val value: Int) : Parcelable {
+
+        override fun describeContents(): Int = 0
+
+        override fun writeToParcel(dest: Parcel, flags: Int) {
+            dest.writeInt(value)
+        }
+
+        companion object {
+            @Suppress("unused")
+            @JvmField
+            val CREATOR =
+                object : Parcelable.Creator<TestParcelable> {
+                    override fun createFromParcel(source: Parcel) =
+                        TestParcelable(value = source.readInt())
+
+                    override fun newArray(size: Int) = arrayOfNulls<TestParcelable>(size)
+                }
+        }
+    }
+
+    internal data class TestSerializable(val value: Int) : Serializable
+}
diff --git a/savedstate/savedstate/src/commonMain/kotlin/androidx/savedstate/SavedStateReader.kt b/savedstate/savedstate/src/commonMain/kotlin/androidx/savedstate/SavedStateReader.kt
index 5249fa0..a18e93e 100644
--- a/savedstate/savedstate/src/commonMain/kotlin/androidx/savedstate/SavedStateReader.kt
+++ b/savedstate/savedstate/src/commonMain/kotlin/androidx/savedstate/SavedStateReader.kt
@@ -25,10 +25,15 @@
 import kotlin.jvm.JvmName
 
 @PublishedApi internal const val DEFAULT_BOOLEAN: Boolean = false
+
 @PublishedApi internal const val DEFAULT_CHAR: Char = 0.toChar()
+
 @PublishedApi internal const val DEFAULT_FLOAT: Float = 0F
+
 @PublishedApi internal const val DEFAULT_DOUBLE: Double = 0.0
+
 @PublishedApi internal const val DEFAULT_INT: Int = 0
+
 @PublishedApi internal const val DEFAULT_LONG: Long = 0L
 
 /**
@@ -87,6 +92,30 @@
     public inline fun getCharOrElse(key: String, defaultValue: () -> Char): Char
 
     /**
+     * Retrieves a [CharSequence] value associated with the specified [key]. Throws an
+     * [IllegalStateException] if the [key] doesn't exist.
+     *
+     * @param key The [key] to retrieve the value for.
+     * @return The [CharSequence] value associated with the [key].
+     * @throws IllegalStateException If the [key] is not found.
+     */
+    public inline fun getCharSequence(key: String): CharSequence
+
+    /**
+     * Retrieves a [CharSequence] value associated with the specified [key], or a default value if
+     * the [key] doesn't exist.
+     *
+     * @param key The [key] to retrieve the value for.
+     * @param defaultValue A function providing the default value if the [key] is not found.
+     * @return The [CharSequence] value associated with the [key], or the default value if the [key]
+     *   is not found.
+     */
+    public inline fun getCharSequenceOrElse(
+        key: String,
+        defaultValue: () -> CharSequence
+    ): CharSequence
+
+    /**
      * Retrieves a [Double] value associated with the specified [key]. Throws an
      * [IllegalStateException] if the [key] doesn't exist.
      *
@@ -239,6 +268,31 @@
     ): List<String>
 
     /**
+     * Retrieves a [List] of elements of [CharArray] associated with the specified [key]. Throws an
+     * [IllegalStateException] if the [key] doesn't exist.
+     *
+     * @param key The [key] to retrieve the value for.
+     * @return The [List] of elements of [CharArray] associated with the [key].
+     * @throws IllegalStateException If the [key] is not found.
+     */
+    public inline fun getCharSequenceList(key: String): List<CharSequence>
+
+    /**
+     * Retrieves a [List] of elements of [CharSequence] associated with the specified [key], or a
+     * default value if the [key] doesn't exist.
+     *
+     * @param key The [key] to retrieve the value for.
+     * @param defaultValue A function providing the default value if the [key] is not found or the
+     *   retrieved value is not a list of [CharSequence].
+     * @return The list of elements of [CharSequence] associated with the [key], or the default
+     *   value if the [key] is not found.
+     */
+    public inline fun getCharSequenceListOrElse(
+        key: String,
+        defaultValue: () -> List<CharSequence>
+    ): List<CharSequence>
+
+    /**
      * Retrieves a [BooleanArray] value associated with the specified [key]. Throws an
      * [IllegalStateException] if the [key] doesn't exist.
      *
@@ -284,6 +338,30 @@
     public inline fun getCharArrayOrElse(key: String, defaultValue: () -> CharArray): CharArray
 
     /**
+     * Retrieves a [CharArray] value associated with the specified [key]. Throws an
+     * [IllegalStateException] if the [key] doesn't exist.
+     *
+     * @param key The [key] to retrieve the value for.
+     * @return The [CharArray] value associated with the [key].
+     * @throws IllegalStateException If the [key] is not found.
+     */
+    public inline fun getCharSequenceArray(key: String): Array<CharSequence>
+
+    /**
+     * Retrieves a [CharArray] value associated with the specified [key], or a default value if the
+     * [key] doesn't exist.
+     *
+     * @param key The [key] to retrieve the value for.
+     * @param defaultValue A function providing the default value if the [key] is not found.
+     * @return The [CharArray] value associated with the [key], or the default value if the [key] is
+     *   not found.
+     */
+    public inline fun getCharSequenceArrayOrElse(
+        key: String,
+        defaultValue: () -> Array<CharSequence>
+    ): Array<CharSequence>
+
+    /**
      * Retrieves a [DoubleArray] value associated with the specified [key]. Throws an
      * [IllegalStateException] if the [key] doesn't exist.
      *
diff --git a/savedstate/savedstate/src/commonMain/kotlin/androidx/savedstate/SavedStateWriter.kt b/savedstate/savedstate/src/commonMain/kotlin/androidx/savedstate/SavedStateWriter.kt
index 13b5c8f..11cd15c04 100644
--- a/savedstate/savedstate/src/commonMain/kotlin/androidx/savedstate/SavedStateWriter.kt
+++ b/savedstate/savedstate/src/commonMain/kotlin/androidx/savedstate/SavedStateWriter.kt
@@ -45,9 +45,23 @@
      */
     public inline fun putBoolean(key: String, value: Boolean)
 
+    /**
+     * Stores a char value associated with the specified key in the [SavedState].
+     *
+     * @param key The key to associate the value with.
+     * @param value The char value to store.
+     */
     public inline fun putChar(key: String, value: Char)
 
     /**
+     * Stores a char sequence value associated with the specified key in the [SavedState].
+     *
+     * @param key The key to associate the value with.
+     * @param value The char sequence value to store.
+     */
+    public inline fun putCharSequence(key: String, value: CharSequence)
+
+    /**
      * Stores a double value associated with the specified key in the [SavedState].
      *
      * @param key The key to associate the value with.
@@ -103,6 +117,15 @@
     public inline fun putIntList(key: String, values: List<Int>)
 
     /**
+     * Stores a list of elements of [CharSequence] associated with the specified key in the
+     * [SavedState].
+     *
+     * @param key The key to associate the value with.
+     * @param values The list of elements to store.
+     */
+    public inline fun putCharSequenceList(key: String, values: List<CharSequence>)
+
+    /**
      * Stores a list of elements of [String] associated with the specified key in the [SavedState].
      *
      * @param key The key to associate the value with.
@@ -129,6 +152,15 @@
     public inline fun putCharArray(key: String, values: CharArray)
 
     /**
+     * Stores an [Array] of elements of [CharSequence] associated with the specified key in the
+     * [SavedState].
+     *
+     * @param key The key to associate the value with.
+     * @param values The array of elements to store.
+     */
+    public inline fun putCharSequenceArray(key: String, values: Array<CharSequence>)
+
+    /**
      * Stores an [Array] of elements of [Double] associated with the specified key in the
      * [SavedState].
      *
diff --git a/savedstate/savedstate/src/commonTest/kotlin/androidx/savedstate/SavedStateTest.kt b/savedstate/savedstate/src/commonTest/kotlin/androidx/savedstate/SavedStateTest.kt
index fc57a73..d19506c 100644
--- a/savedstate/savedstate/src/commonTest/kotlin/androidx/savedstate/SavedStateTest.kt
+++ b/savedstate/savedstate/src/commonTest/kotlin/androidx/savedstate/SavedStateTest.kt
@@ -332,6 +332,49 @@
     }
 
     @Test
+    fun getCharSequence_whenSet_returns() {
+        val underTest = savedState { putCharSequence(KEY_1, CHAR_SEQUENCE_VALUE_1) }
+        val actual = underTest.read { getCharSequence(KEY_1) }
+
+        assertThat(actual).isEqualTo(CHAR_SEQUENCE_VALUE_1)
+    }
+
+    @Test
+    fun getCharSequence_whenNotSet_throws() {
+        assertThrows<IllegalArgumentException> { savedState().read { getCharSequence(KEY_1) } }
+    }
+
+    @Test
+    fun getCharSequence_whenSet_differentType_throws() {
+        val underTest = savedState { putInt(KEY_1, Int.MAX_VALUE) }
+
+        assertThrows<IllegalStateException> { underTest.read { getString(KEY_1) } }
+    }
+
+    @Test
+    fun getCharSequenceOrElse_whenSet_returns() {
+        val underTest = savedState { putCharSequence(KEY_1, CHAR_SEQUENCE_VALUE_1) }
+        val actual = underTest.read { getCharSequenceOrElse(KEY_1) { CHAR_SEQUENCE_VALUE_2 } }
+
+        assertThat(actual).isEqualTo(CHAR_SEQUENCE_VALUE_1)
+    }
+
+    @Test
+    fun getCharSequenceOrElse_whenNotSet_returnsElse() {
+        val actual = savedState().read { getCharSequenceOrElse(KEY_1) { CHAR_SEQUENCE_VALUE_2 } }
+
+        assertThat(actual).isEqualTo(CHAR_SEQUENCE_VALUE_2)
+    }
+
+    @Test
+    fun getCharSequenceOrElse_whenSet_differentType_returnsElse() {
+        val underTest = savedState { putInt(KEY_1, Int.MAX_VALUE) }
+        val actual = underTest.read { getCharSequenceOrElse(KEY_1) { CHAR_SEQUENCE_VALUE_2 } }
+
+        assertThat(actual).isEqualTo(CHAR_SEQUENCE_VALUE_2)
+    }
+
+    @Test
     fun getDouble_whenSet_returns() {
         val underTest = savedState { putDouble(KEY_1, Double.MAX_VALUE) }
         val actual = underTest.read { getDouble(KEY_1) }
@@ -625,6 +668,53 @@
     }
 
     @Test
+    fun getCharSequenceList_whenSet_returns() {
+        val underTest = savedState { putCharSequenceList(KEY_1, CHAR_SEQUENCE_LIST) }
+        val actual = underTest.read { getCharSequenceList(KEY_1) }
+
+        assertThat(actual).isEqualTo(CHAR_SEQUENCE_LIST)
+    }
+
+    @Test
+    fun getCharSequenceList_whenNotSet_throws() {
+        assertThrows<IllegalArgumentException> { savedState().read { getCharSequenceList(KEY_1) } }
+    }
+
+    @Test
+    fun getCharSequenceList_whenSet_differentType_throws() {
+        val expected = Int.MAX_VALUE
+
+        val underTest = savedState { putInt(KEY_1, expected) }
+
+        assertThrows<IllegalStateException> { underTest.read { getCharSequenceList(KEY_1) } }
+    }
+
+    @Test
+    fun getCharSequenceListOrElse_whenSet_returns() {
+        val underTest = savedState { putCharSequenceList(KEY_1, CHAR_SEQUENCE_LIST) }
+        val actual = underTest.read { getCharSequenceListOrElse(KEY_1) { emptyList() } }
+
+        assertThat(actual).isEqualTo(CHAR_SEQUENCE_LIST)
+    }
+
+    @Test
+    fun getCharSequenceListOrElse_whenNotSet_returnsElse() {
+        val actual = savedState().read { getCharSequenceListOrElse(KEY_1) { emptyList() } }
+
+        assertThat(actual).isEqualTo(emptyList<CharSequence>())
+    }
+
+    @Test
+    fun getCharSequenceListOrElse_whenSet_differentType_returnsElse() {
+        val expected = Int.MAX_VALUE
+
+        val underTest = savedState { putInt(KEY_1, expected) }
+        val actual = underTest.read { getCharSequenceListOrElse(KEY_1) { emptyList() } }
+
+        assertThat(actual).isEqualTo(emptyList<CharSequence>())
+    }
+
+    @Test
     fun getStringList_whenSet_returns() {
         val underTest = savedState { putStringList(KEY_1, LIST_STRING_VALUE) }
         val actual = underTest.read { getStringList(KEY_1) }
@@ -778,6 +868,59 @@
     }
 
     @Test
+    fun getCharSequenceArray_whenSet_returns() {
+        val expected = Array<CharSequence>(size = 5) { idx -> idx.toString() }
+
+        val underTest = savedState { putCharSequenceArray(KEY_1, expected) }
+        val actual = underTest.read { getCharSequenceArray(KEY_1) }
+
+        assertThat(actual).isEqualTo(expected)
+    }
+
+    @Test
+    fun getCharSequenceArray_whenNotSet_throws() {
+        assertThrows<IllegalArgumentException> { savedState().read { getCharSequenceArray(KEY_1) } }
+    }
+
+    @Test
+    fun getCharSequenceArray_whenSet_differentType_throws() {
+        val expected = Int.MAX_VALUE
+
+        val underTest = savedState { putInt(KEY_1, expected) }
+
+        assertThrows<IllegalStateException> { underTest.read { getCharSequenceArray(KEY_1) } }
+    }
+
+    @Test
+    fun getCharSequenceArrayOrElse_whenSet_returns() {
+        val expected = CHAR_SEQUENCE_ARRAY
+
+        val underTest = savedState { putCharSequenceArray(KEY_1, expected) }
+        val actual = underTest.read { getCharSequenceArrayOrElse(KEY_1) { emptyArray() } }
+
+        assertThat(actual).isEqualTo(expected)
+    }
+
+    @Test
+    fun getCharSequenceArrayOrElse_whenNotSet_returnsElse() {
+        val expected = CHAR_SEQUENCE_ARRAY
+
+        val actual = savedState().read { getCharSequenceArrayOrElse(KEY_1) { expected } }
+
+        assertThat(actual).isEqualTo(expected)
+    }
+
+    @Test
+    fun getCharSequenceArrayOrElse_whenSet_differentType_returnsElse() {
+        val expected = CHAR_SEQUENCE_ARRAY
+
+        val underTest = savedState { putInt(KEY_1, Int.MAX_VALUE) }
+        val actual = underTest.read { getCharSequenceArrayOrElse(KEY_1) { expected } }
+
+        assertThat(actual).isEqualTo(expected)
+    }
+
+    @Test
     fun getDoubleArray_whenSet_returns() {
         val expected = DoubleArray(size = 5) { idx -> idx.toDouble() }
 
@@ -1109,6 +1252,10 @@
         val LIST_INT_VALUE = List(size = 5) { idx -> idx }
         val LIST_STRING_VALUE = List(size = 5) { idx -> "index=$idx" }
         val SAVED_STATE_VALUE = savedState()
+        val CHAR_SEQUENCE_VALUE_1: CharSequence = Int.MIN_VALUE.toString()
+        val CHAR_SEQUENCE_VALUE_2: CharSequence = Int.MAX_VALUE.toString()
+        val CHAR_SEQUENCE_ARRAY = Array<CharSequence>(size = 5) { idx -> "index=$idx" }
+        val CHAR_SEQUENCE_LIST = List<CharSequence>(size = 5) { idx -> "index=$idx" }
 
         private fun createDefaultSavedState(): SavedState {
             var key = 0
diff --git a/savedstate/savedstate/src/nonAndroidMain/kotlin/androidx/savedstate/SavedStateReader.nonAndroid.kt b/savedstate/savedstate/src/nonAndroidMain/kotlin/androidx/savedstate/SavedStateReader.nonAndroid.kt
index 5a36f36..b18561a 100644
--- a/savedstate/savedstate/src/nonAndroidMain/kotlin/androidx/savedstate/SavedStateReader.nonAndroid.kt
+++ b/savedstate/savedstate/src/nonAndroidMain/kotlin/androidx/savedstate/SavedStateReader.nonAndroid.kt
@@ -51,6 +51,19 @@
         return source.map[key] as? Char ?: defaultValue()
     }
 
+    actual inline fun getCharSequence(key: String): CharSequence {
+        if (key !in this) keyNotFoundError(key)
+        return source.map[key] as? CharSequence ?: valueNotFoundError(key)
+    }
+
+    actual inline fun getCharSequenceOrElse(
+        key: String,
+        defaultValue: () -> CharSequence
+    ): CharSequence {
+        if (key !in this) defaultValue()
+        return source.map[key] as? CharSequence ?: defaultValue()
+    }
+
     actual inline fun getDouble(key: String): Double {
         if (key !in this) keyNotFoundError(key)
         return source.map[key] as? Double ?: DEFAULT_DOUBLE
@@ -101,31 +114,42 @@
         return source.map[key] as? String ?: defaultValue()
     }
 
-    @Suppress("UNCHECKED_CAST")
+    actual inline fun getCharSequenceList(key: String): List<CharSequence> {
+        if (key !in this) keyNotFoundError(key)
+        @Suppress("UNCHECKED_CAST")
+        return source.map[key] as? List<CharSequence> ?: valueNotFoundError(key)
+    }
+
+    actual inline fun getCharSequenceListOrElse(
+        key: String,
+        defaultValue: () -> List<CharSequence>
+    ): List<CharSequence> {
+        if (key !in this) defaultValue()
+        @Suppress("UNCHECKED_CAST") return source.map[key] as? List<CharSequence> ?: defaultValue()
+    }
+
     actual inline fun getIntList(key: String): List<Int> {
         if (key !in this) keyNotFoundError(key)
-        return source.map[key] as? List<Int> ?: valueNotFoundError(key)
+        @Suppress("UNCHECKED_CAST") return source.map[key] as? List<Int> ?: valueNotFoundError(key)
     }
 
-    @Suppress("UNCHECKED_CAST")
     actual inline fun getIntListOrElse(key: String, defaultValue: () -> List<Int>): List<Int> {
         if (key !in this) defaultValue()
-        return source.map[key] as? List<Int> ?: defaultValue()
+        @Suppress("UNCHECKED_CAST") return source.map[key] as? List<Int> ?: defaultValue()
     }
 
-    @Suppress("UNCHECKED_CAST")
     actual inline fun getStringList(key: String): List<String> {
         if (key !in this) keyNotFoundError(key)
+        @Suppress("UNCHECKED_CAST")
         return source.map[key] as? List<String> ?: valueNotFoundError(key)
     }
 
-    @Suppress("UNCHECKED_CAST")
     actual inline fun getStringListOrElse(
         key: String,
         defaultValue: () -> List<String>
     ): List<String> {
         if (key !in this) defaultValue()
-        return source.map[key] as? List<String> ?: defaultValue()
+        @Suppress("UNCHECKED_CAST") return source.map[key] as? List<String> ?: defaultValue()
     }
 
     actual inline fun getCharArray(key: String): CharArray {
@@ -138,6 +162,20 @@
         return source.map[key] as? CharArray ?: defaultValue()
     }
 
+    actual inline fun getCharSequenceArray(key: String): Array<CharSequence> {
+        if (key !in this) keyNotFoundError(key)
+        @Suppress("UNCHECKED_CAST")
+        return source.map[key] as? Array<CharSequence> ?: valueNotFoundError(key)
+    }
+
+    actual inline fun getCharSequenceArrayOrElse(
+        key: String,
+        defaultValue: () -> Array<CharSequence>
+    ): Array<CharSequence> {
+        if (key !in this) defaultValue()
+        @Suppress("UNCHECKED_CAST") return source.map[key] as? Array<CharSequence> ?: defaultValue()
+    }
+
     actual inline fun getBooleanArray(key: String): BooleanArray {
         if (key !in this) keyNotFoundError(key)
         return source.map[key] as? BooleanArray ?: valueNotFoundError(key)
diff --git a/savedstate/savedstate/src/nonAndroidMain/kotlin/androidx/savedstate/SavedStateWriter.nonAndroid.kt b/savedstate/savedstate/src/nonAndroidMain/kotlin/androidx/savedstate/SavedStateWriter.nonAndroid.kt
index 95cecc4..88fac06 100644
--- a/savedstate/savedstate/src/nonAndroidMain/kotlin/androidx/savedstate/SavedStateWriter.nonAndroid.kt
+++ b/savedstate/savedstate/src/nonAndroidMain/kotlin/androidx/savedstate/SavedStateWriter.nonAndroid.kt
@@ -39,6 +39,10 @@
         source.map[key] = value
     }
 
+    actual inline fun putCharSequence(key: String, value: CharSequence) {
+        source.map[key] = value
+    }
+
     actual inline fun putDouble(key: String, value: Double) {
         source.map[key] = value
     }
@@ -63,6 +67,10 @@
         source.map[key] = value
     }
 
+    actual inline fun putCharSequenceList(key: String, values: List<CharSequence>) {
+        source.map[key] = values
+    }
+
     actual inline fun putIntList(key: String, values: List<Int>) {
         source.map[key] = values
     }
@@ -79,6 +87,10 @@
         source.map[key] = values
     }
 
+    actual inline fun putCharSequenceArray(key: String, values: Array<CharSequence>) {
+        source.map[key] = values
+    }
+
     actual inline fun putDoubleArray(key: String, values: DoubleArray) {
         source.map[key] = values
     }
diff --git a/security/security-state/build.gradle b/security/security-state/build.gradle
index 3ed2917..027680e 100644
--- a/security/security-state/build.gradle
+++ b/security/security-state/build.gradle
@@ -27,13 +27,13 @@
     id("AndroidXPlugin")
     id("com.android.library")
     id("org.jetbrains.kotlin.android")
+    alias(libs.plugins.kotlinSerialization)
 }
 
 dependencies {
     api("androidx.annotation:annotation:1.8.1")
-    api(libs.kotlinStdlib)
     api(libs.kotlinCoroutinesCore)
-    implementation("com.google.code.gson:gson:2.10.1")
+    implementation(libs.kotlinSerializationJson)
     implementation("androidx.core:core:1.12.0")
     implementation("androidx.webkit:webkit:1.11.0")
 
@@ -56,9 +56,6 @@
 android {
     compileSdk 35
     namespace "androidx.security.state"
-    defaultConfig {
-        consumerProguardFiles 'proguard-rules.pro'
-    }
 }
 
 androidx {
diff --git a/security/security-state/proguard-rules.pro b/security/security-state/proguard-rules.pro
deleted file mode 100644
index be33693..0000000
--- a/security/security-state/proguard-rules.pro
+++ /dev/null
@@ -1 +0,0 @@
--keepclassmembers class androidx.security.state.SecurityPatchState$** { *; }
\ No newline at end of file
diff --git a/security/security-state/src/main/java/androidx/security/state/SecurityPatchState.kt b/security/security-state/src/main/java/androidx/security/state/SecurityPatchState.kt
index 4496a2a..d45c24b 100644
--- a/security/security-state/src/main/java/androidx/security/state/SecurityPatchState.kt
+++ b/security/security-state/src/main/java/androidx/security/state/SecurityPatchState.kt
@@ -24,15 +24,16 @@
 import androidx.security.state.SecurityStateManager.Companion.KEY_KERNEL_VERSION
 import androidx.security.state.SecurityStateManager.Companion.KEY_SYSTEM_SPL
 import androidx.security.state.SecurityStateManager.Companion.KEY_VENDOR_SPL
-import com.google.gson.Gson
-import com.google.gson.JsonSyntaxException
-import com.google.gson.annotations.SerializedName
 import java.text.ParseException
 import java.text.SimpleDateFormat
 import java.util.Calendar
 import java.util.Date
 import java.util.Locale
 import java.util.regex.Pattern
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.SerializationException
+import kotlinx.serialization.json.Json
 
 /**
  * Provides methods to access and manage security state information for various components within a
@@ -308,20 +309,21 @@
         public fun getBuildVersion(): Int = buildVersion
     }
 
+    @Serializable
     private data class VulnerabilityReport(
         /* Key is the SPL date yyyy-MM-dd */
-        @SerializedName("vulnerabilities")
         val vulnerabilities: Map<String, List<VulnerabilityGroup>>,
 
         /* Key is the SPL date yyyy-MM-dd, values are kernel versions */
-        @SerializedName("kernel_lts_versions") val kernelLtsVersions: Map<String, List<String>>
+        @SerialName("kernel_lts_versions") val kernelLtsVersions: Map<String, List<String>>
     )
 
+    @Serializable
     private data class VulnerabilityGroup(
-        @SerializedName("cve_identifiers") val cveIdentifiers: List<String>,
-        @SerializedName("asb_identifiers") val asbIdentifiers: List<String>,
-        @SerializedName("severity") val severity: String,
-        @SerializedName("components") val components: List<String>
+        @SerialName("cve_identifiers") val cveIdentifiers: List<String>,
+        @SerialName("asb_identifiers") val asbIdentifiers: List<String>,
+        val severity: String,
+        val components: List<String>
     )
 
     /**
@@ -354,8 +356,9 @@
         val result: VulnerabilityReport
 
         try {
-            result = Gson().fromJson(jsonString, VulnerabilityReport::class.java)
-        } catch (e: JsonSyntaxException) {
+            val json = Json { ignoreUnknownKeys = true }
+            result = json.decodeFromString<VulnerabilityReport>(jsonString)
+        } catch (e: SerializationException) {
             throw IllegalArgumentException("Malformed JSON input: ${e.message}")
         }
 
@@ -794,7 +797,6 @@
 
         val updates = mutableListOf<UpdateInfo>()
         val contentResolver = context.contentResolver
-        val gson = Gson()
 
         updateInfoProviders.forEach { providerUri ->
             val cursor = contentResolver.query(providerUri, arrayOf("json"), null, null, null)
@@ -802,7 +804,9 @@
                 while (it.moveToNext()) {
                     val json = it.getString(it.getColumnIndexOrThrow("json"))
                     try {
-                        val updateInfo = gson.fromJson(json, UpdateInfo::class.java) ?: continue
+                        val serializableUpdateInfo =
+                            Json.decodeFromString<SerializableUpdateInfo>(json)
+                        val updateInfo: UpdateInfo = serializableUpdateInfo.toUpdateInfo()
                         val component = updateInfo.component
                         val deviceSpl = getDeviceSecurityPatchLevel(component)
 
diff --git a/security/security-state/src/main/java/androidx/security/state/UpdateInfo.kt b/security/security-state/src/main/java/androidx/security/state/UpdateInfo.kt
index 8ef484f..49b29b45 100644
--- a/security/security-state/src/main/java/androidx/security/state/UpdateInfo.kt
+++ b/security/security-state/src/main/java/androidx/security/state/UpdateInfo.kt
@@ -16,8 +16,39 @@
 
 package androidx.security.state
 
+import java.text.SimpleDateFormat
 import java.util.Date
 import java.util.Objects
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.descriptors.PrimitiveKind
+import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
+import kotlinx.serialization.descriptors.SerialDescriptor
+import kotlinx.serialization.encoding.Decoder
+import kotlinx.serialization.encoding.Encoder
+
+private object DateSerializer : KSerializer<Date> {
+    override val descriptor: SerialDescriptor =
+        PrimitiveSerialDescriptor("Date", PrimitiveKind.STRING)
+
+    private val dateFormat: SimpleDateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'")
+
+    override fun serialize(encoder: Encoder, value: Date): Unit =
+        encoder.encodeString(dateFormat.format(value))
+
+    override fun deserialize(decoder: Decoder): Date = dateFormat.parse(decoder.decodeString())!!
+}
+
+@Serializable
+internal class SerializableUpdateInfo(
+    private val uri: String,
+    private val component: String,
+    private val securityPatchLevel: String,
+    @Serializable(with = DateSerializer::class) private val publishedDate: Date
+) {
+    internal fun toUpdateInfo(): UpdateInfo =
+        UpdateInfo(uri, component, securityPatchLevel, publishedDate)
+}
 
 /** Represents information about an available update for a component. */
 public class UpdateInfo(
@@ -33,6 +64,10 @@
     /** Date when the available update was published. */
     public val publishedDate: Date
 ) {
+
+    internal fun toSerializableUpdateInfo(): SerializableUpdateInfo =
+        SerializableUpdateInfo(uri, component, securityPatchLevel, publishedDate)
+
     /**
      * Returns a string representation of the update information.
      *
diff --git a/security/security-state/src/main/java/androidx/security/state/UpdateInfoProvider.kt b/security/security-state/src/main/java/androidx/security/state/UpdateInfoProvider.kt
index 7dec3e4..1c6c6e6 100644
--- a/security/security-state/src/main/java/androidx/security/state/UpdateInfoProvider.kt
+++ b/security/security-state/src/main/java/androidx/security/state/UpdateInfoProvider.kt
@@ -22,7 +22,7 @@
 import android.database.Cursor
 import android.database.MatrixCursor
 import android.net.Uri
-import com.google.gson.Gson
+import kotlinx.serialization.json.Json
 
 /**
  * A content provider for managing and serving update information for system components. This class
@@ -179,7 +179,11 @@
         val sharedPreferences = context.getSharedPreferences(updateInfoPrefs, Context.MODE_PRIVATE)
         val editor = sharedPreferences?.edit()
         val key = getKeyForUpdateInfo(updateInfo)
-        val json = Gson().toJson(updateInfo)
+        val json =
+            Json.encodeToString(
+                SerializableUpdateInfo.serializer(),
+                updateInfo.toSerializableUpdateInfo()
+            )
         editor?.putString(key, json)
         editor?.apply()
     }
@@ -218,7 +222,8 @@
         for ((_, value) in allEntries) {
             val json = value as? String
             if (json != null) {
-                val updateInfo: UpdateInfo = Gson().fromJson(json, UpdateInfo::class.java)
+                val serializableUpdateInfo: SerializableUpdateInfo = Json.decodeFromString(json)
+                val updateInfo: UpdateInfo = serializableUpdateInfo.toUpdateInfo()
                 allUpdates.add(updateInfo)
             }
         }
diff --git a/security/security-state/src/test/java/androidx/security/state/SecurityPatchStateTest.kt b/security/security-state/src/test/java/androidx/security/state/SecurityPatchStateTest.kt
index d3a8f6fd..7ab74c2 100644
--- a/security/security-state/src/test/java/androidx/security/state/SecurityPatchStateTest.kt
+++ b/security/security-state/src/test/java/androidx/security/state/SecurityPatchStateTest.kt
@@ -28,10 +28,10 @@
 import android.os.Bundle
 import androidx.annotation.RequiresApi
 import androidx.test.ext.junit.runners.AndroidJUnit4
-import com.google.gson.Gson
 import java.time.LocalDate
 import java.time.ZoneOffset
 import java.util.Date
+import kotlinx.serialization.json.Json
 import org.junit.Assert.assertEquals
 import org.junit.Assert.assertFalse
 import org.junit.Assert.assertTrue
@@ -56,6 +56,11 @@
             .setSecurityPatchLevel("2022-01-01")
             .setPublishedDate(Date.from(LocalDate.now().atStartOfDay().toInstant(ZoneOffset.UTC)))
             .build()
+    private val updateInfoJson =
+        Json.encodeToString(
+            SerializableUpdateInfo.serializer(),
+            updateInfo.toSerializableUpdateInfo()
+        )
     private val mockEmptyEditor: SharedPreferences.Editor = mock<SharedPreferences.Editor> {}
     private val mockEditor: SharedPreferences.Editor =
         mock<SharedPreferences.Editor> {
@@ -65,7 +70,7 @@
     private val mockPrefs: SharedPreferences =
         mock<SharedPreferences> {
             on { edit() } doReturn mockEditor
-            on { all } doReturn mapOf(Pair("key", Gson().toJson(updateInfo)))
+            on { all } doReturn mapOf(Pair("key", updateInfoJson))
         }
     private val mockPackageManager: PackageManager =
         mock<PackageManager> {
@@ -77,7 +82,7 @@
         mock<Cursor> {
             on { moveToNext() } doReturn true doReturn false doReturn true doReturn false
             on { getColumnIndexOrThrow(Mockito.eq("json")) } doReturn 123
-            on { getString(Mockito.eq(123)) } doReturn Gson().toJson(updateInfo)
+            on { getString(Mockito.eq(123)) } doReturn updateInfoJson
         }
     private val mockContentResolver: ContentResolver =
         mock<ContentResolver> {
diff --git a/security/security-state/src/test/java/androidx/security/state/UpdateInfoProviderTest.kt b/security/security-state/src/test/java/androidx/security/state/UpdateInfoProviderTest.kt
index ec7c5f8..6d3aff3 100644
--- a/security/security-state/src/test/java/androidx/security/state/UpdateInfoProviderTest.kt
+++ b/security/security-state/src/test/java/androidx/security/state/UpdateInfoProviderTest.kt
@@ -23,10 +23,10 @@
 import android.net.Uri
 import androidx.security.state.SecurityPatchState.Companion.COMPONENT_SYSTEM
 import androidx.test.ext.junit.runners.AndroidJUnit4
-import com.google.gson.Gson
 import java.time.LocalDate
 import java.time.ZoneOffset
 import java.util.Date
+import kotlinx.serialization.json.Json
 import org.junit.Assert.assertEquals
 import org.junit.Assert.assertNotNull
 import org.junit.Assert.assertTrue
@@ -63,7 +63,11 @@
             .setSecurityPatchLevel("2022-01-01")
             .setPublishedDate(publishedDate)
             .build()
-    private val expectedJson = Gson().toJson(updateInfo)
+    private val expectedJson =
+        Json.encodeToString(
+            SerializableUpdateInfo.serializer(),
+            updateInfo.toSerializableUpdateInfo()
+        )
     private val mockEmptyEditor: SharedPreferences.Editor = mock<SharedPreferences.Editor> {}
     private val mockEditor: SharedPreferences.Editor =
         mock<SharedPreferences.Editor> {
@@ -73,7 +77,7 @@
     private val mockPrefs: SharedPreferences =
         mock<SharedPreferences> {
             on { edit() } doReturn mockEditor
-            on { all } doReturn mapOf(Pair("key", Gson().toJson(updateInfo)))
+            on { all } doReturn mapOf(Pair("key", expectedJson))
         }
     private val mockContext: Context =
         mock<Context> {
diff --git a/settings.gradle b/settings.gradle
index 010367a..a6fe1d8 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -463,8 +463,8 @@
 includeProject(":car:app:app-testing", [BuildType.MAIN])
 includeProject(":cardview:cardview", [BuildType.MAIN])
 includeProject(":collection:collection", [BuildType.MAIN, BuildType.INFRAROGUE, BuildType.KMP])
-includeProject(":collection:collection-benchmark", [BuildType.MAIN, BuildType.INFRAROGUE, BuildType.KMP])
-includeProject(":collection:collection-benchmark-kmp", [BuildType.MAIN, BuildType.INFRAROGUE, BuildType.KMP])
+includeProject(":collection:collection-benchmark", [BuildType.INFRAROGUE, BuildType.KMP])
+includeProject(":collection:collection-benchmark-kmp", [BuildType.INFRAROGUE, BuildType.KMP])
 includeProject(":collection:collection-ktx", [BuildType.MAIN, BuildType.INFRAROGUE, BuildType.KMP])
 includeProject(":collection:integration-tests:testapp", [BuildType.MAIN, BuildType.INFRAROGUE, BuildType.KMP])
 includeProject(":compose:animation", [BuildType.COMPOSE])
diff --git a/slidingpanelayout/slidingpanelayout/api/current.txt b/slidingpanelayout/slidingpanelayout/api/current.txt
index e49a46f..b591f76 100644
--- a/slidingpanelayout/slidingpanelayout/api/current.txt
+++ b/slidingpanelayout/slidingpanelayout/api/current.txt
@@ -46,6 +46,7 @@
     method public final void setUserResizeBehavior(androidx.slidingpanelayout.widget.SlidingPaneLayout.UserResizeBehavior userResizeBehavior);
     method public final void setUserResizingDividerDrawable(android.graphics.drawable.Drawable? drawable);
     method public final void setUserResizingDividerDrawable(@DrawableRes int resId);
+    method public final void setUserResizingDividerTint(android.content.res.ColorStateList? colorStateList);
     method public final void setUserResizingEnabled(boolean);
     method @Deprecated public void smoothSlideClosed();
     method @Deprecated public void smoothSlideOpen();
diff --git a/slidingpanelayout/slidingpanelayout/api/res-current.txt b/slidingpanelayout/slidingpanelayout/api/res-current.txt
index 0d72e80..88c866a 100644
--- a/slidingpanelayout/slidingpanelayout/api/res-current.txt
+++ b/slidingpanelayout/slidingpanelayout/api/res-current.txt
@@ -3,3 +3,4 @@
 attr isUserResizingEnabled
 attr userResizeBehavior
 attr userResizingDividerDrawable
+attr userResizingDividerTint
diff --git a/slidingpanelayout/slidingpanelayout/api/restricted_current.txt b/slidingpanelayout/slidingpanelayout/api/restricted_current.txt
index e49a46f..b591f76 100644
--- a/slidingpanelayout/slidingpanelayout/api/restricted_current.txt
+++ b/slidingpanelayout/slidingpanelayout/api/restricted_current.txt
@@ -46,6 +46,7 @@
     method public final void setUserResizeBehavior(androidx.slidingpanelayout.widget.SlidingPaneLayout.UserResizeBehavior userResizeBehavior);
     method public final void setUserResizingDividerDrawable(android.graphics.drawable.Drawable? drawable);
     method public final void setUserResizingDividerDrawable(@DrawableRes int resId);
+    method public final void setUserResizingDividerTint(android.content.res.ColorStateList? colorStateList);
     method public final void setUserResizingEnabled(boolean);
     method @Deprecated public void smoothSlideClosed();
     method @Deprecated public void smoothSlideOpen();
diff --git a/slidingpanelayout/slidingpanelayout/src/androidTest/java/androidx/slidingpanelayout/widget/UserResizeDividerTintTest.kt b/slidingpanelayout/slidingpanelayout/src/androidTest/java/androidx/slidingpanelayout/widget/UserResizeDividerTintTest.kt
new file mode 100644
index 0000000..fe06388
--- /dev/null
+++ b/slidingpanelayout/slidingpanelayout/src/androidTest/java/androidx/slidingpanelayout/widget/UserResizeDividerTintTest.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.slidingpanelayout.widget
+
+import android.content.res.ColorStateList
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.ColorFilter
+import android.graphics.PixelFormat
+import android.graphics.drawable.Drawable
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import androidx.test.platform.app.InstrumentationRegistry
+import com.google.common.truth.Truth.assertWithMessage
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+@SmallTest
+class UserResizeDividerTintTest {
+    @Test
+    fun userResizingDividerTint_tintIsSetToDrawable() {
+        val context = InstrumentationRegistry.getInstrumentation().context
+        val view = SlidingPaneLayout(context)
+        val drawable = TestDrawable()
+
+        view.setUserResizingDividerDrawable(drawable)
+        val tint = ColorStateList.valueOf(Color.RED)
+        view.setUserResizingDividerTint(tint)
+
+        assertWithMessage("userResizingDividerTint is set to drawable")
+            .that(drawable.tint)
+            .isEqualTo(tint)
+    }
+
+    @Test
+    fun userResizingDividerTint_setDrawableAfterTint_tintIsNotSetToDrawable() {
+        val context = InstrumentationRegistry.getInstrumentation().context
+        val view = SlidingPaneLayout(context)
+        val drawable = TestDrawable()
+
+        val tint = ColorStateList.valueOf(Color.RED)
+        view.setUserResizingDividerTint(tint)
+
+        view.setUserResizingDividerDrawable(drawable)
+        assertWithMessage("userResizingDividerTint is not set to drawable")
+            .that(drawable.tint)
+            .isNull()
+    }
+
+    @Test
+    fun userResizingDividerTint_setTintToNull() {
+        val context = InstrumentationRegistry.getInstrumentation().context
+        val view = SlidingPaneLayout(context)
+        val drawable = TestDrawable().apply { setTintList(ColorStateList.valueOf(Color.RED)) }
+        view.setUserResizingDividerDrawable(drawable)
+
+        view.setUserResizingDividerTint(null)
+        assertWithMessage("userResizingDividerTint is set to null").that(drawable.tint).isNull()
+    }
+}
+
+private class TestDrawable : Drawable() {
+    var tint: ColorStateList? = null
+        private set
+
+    override fun draw(canvas: Canvas) {}
+
+    override fun setAlpha(alpha: Int) {}
+
+    override fun setColorFilter(colorFilter: ColorFilter?) {}
+
+    @Suppress("DeprecatedCallableAddReplaceWith")
+    @Deprecated("Deprecated in Java")
+    override fun getOpacity(): Int = PixelFormat.TRANSLUCENT
+
+    override fun setTintList(tint: ColorStateList?) {
+        this.tint = tint
+    }
+}
diff --git a/slidingpanelayout/slidingpanelayout/src/androidTest/res/layout/user_resize_divider_tint.xml b/slidingpanelayout/slidingpanelayout/src/androidTest/res/layout/user_resize_divider_tint.xml
new file mode 100644
index 0000000..4d7a361
--- /dev/null
+++ b/slidingpanelayout/slidingpanelayout/src/androidTest/res/layout/user_resize_divider_tint.xml
@@ -0,0 +1,26 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+  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.
+  -->
+
+<androidx.slidingpanelayout.widget.SlidingPaneLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    app:isUserResizingEnabled="true"
+    app:userResizingDividerDrawable="@android:drawable/ic_menu_add"
+    app:userResizingDividerTint="@android:color/primary_text_dark">
+
+</androidx.slidingpanelayout.widget.SlidingPaneLayout>
\ No newline at end of file
diff --git a/slidingpanelayout/slidingpanelayout/src/main/java/androidx/slidingpanelayout/widget/SlidingPaneLayout.kt b/slidingpanelayout/slidingpanelayout/src/main/java/androidx/slidingpanelayout/widget/SlidingPaneLayout.kt
index 5b023fe..729b2b9 100644
--- a/slidingpanelayout/slidingpanelayout/src/main/java/androidx/slidingpanelayout/widget/SlidingPaneLayout.kt
+++ b/slidingpanelayout/slidingpanelayout/src/main/java/androidx/slidingpanelayout/widget/SlidingPaneLayout.kt
@@ -17,6 +17,7 @@
 package androidx.slidingpanelayout.widget
 
 import android.content.Context
+import android.content.res.ColorStateList
 import android.graphics.Canvas
 import android.graphics.Rect
 import android.graphics.drawable.Drawable
@@ -427,6 +428,16 @@
         setUserResizingDividerDrawable(ContextCompat.getDrawable(context, resId))
     }
 
+    /**
+     * The tint color for the resizing divider [Drawable] which is set by
+     * [setUserResizingDividerDrawable]. This may also be set from `userResizingDividerTint` XML
+     * attribute during the view inflation. Note: the tint is not retained after calling
+     * [setUserResizingDividerDrawable].
+     */
+    fun setUserResizingDividerTint(colorStateList: ColorStateList?) {
+        userResizingDividerDrawable?.apply { setTintList(colorStateList) }
+    }
+
     /** `true` if the user is currently dragging the [user resizing divider][isUserResizable] */
     val isDividerDragging: Boolean
         get() = draggableDividerHandler.isDragging
@@ -581,6 +592,11 @@
                 getBoolean(R.styleable.SlidingPaneLayout_isUserResizingEnabled, false)
             userResizingDividerDrawable =
                 getDrawable(R.styleable.SlidingPaneLayout_userResizingDividerDrawable)
+            // It won't override the tint on drawable if userResizingDividerTint is not specified.
+            getColorStateList(R.styleable.SlidingPaneLayout_userResizingDividerTint)?.apply {
+                setUserResizingDividerTint(this)
+            }
+
             isChildClippingToResizeDividerEnabled =
                 getBoolean(
                     R.styleable.SlidingPaneLayout_isChildClippingToResizeDividerEnabled,
diff --git a/slidingpanelayout/slidingpanelayout/src/main/res/drawable/slidingpanelayout_divider.xml b/slidingpanelayout/slidingpanelayout/src/main/res/drawable/slidingpanelayout_divider.xml
index c5fd33b..f50ac30 100644
--- a/slidingpanelayout/slidingpanelayout/src/main/res/drawable/slidingpanelayout_divider.xml
+++ b/slidingpanelayout/slidingpanelayout/src/main/res/drawable/slidingpanelayout_divider.xml
@@ -15,10 +15,12 @@
   limitations under the License.
   -->
 
-<shape
-    xmlns:android="http://schemas.android.com/apk/res/android"
-    android:shape="rectangle">
-    <solid android:color="#c0555555"/>
-    <size android:width="8dp" android:height="80dp"/>
-    <corners android:radius="4dp"/>
-</shape>
+<selector xmlns:android="http://schemas.android.com/apk/res/android">
+    <!-- Pressed state -->
+    <item
+        android:state_pressed="true"
+        android:drawable="@drawable/slidingpanelayout_divider_pressed" />
+    <!-- Default state -->
+    <item
+        android:drawable="@drawable/slidingpanelayout_divider_default"/>
+</selector>
\ No newline at end of file
diff --git a/slidingpanelayout/slidingpanelayout/src/main/res/drawable/slidingpanelayout_divider_default.xml b/slidingpanelayout/slidingpanelayout/src/main/res/drawable/slidingpanelayout_divider_default.xml
new file mode 100644
index 0000000..cdcc39b1
--- /dev/null
+++ b/slidingpanelayout/slidingpanelayout/src/main/res/drawable/slidingpanelayout_divider_default.xml
@@ -0,0 +1,21 @@
+<?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.
+  -->
+
+<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
+    <size android:height="48dp" android:width="4dp" />
+    <corners android:radius="2dp" />
+    <solid android:color="#ff444746" />
+</shape>
\ No newline at end of file
diff --git a/slidingpanelayout/slidingpanelayout/src/main/res/drawable/slidingpanelayout_divider_pressed.xml b/slidingpanelayout/slidingpanelayout/src/main/res/drawable/slidingpanelayout_divider_pressed.xml
new file mode 100644
index 0000000..4612c86
--- /dev/null
+++ b/slidingpanelayout/slidingpanelayout/src/main/res/drawable/slidingpanelayout_divider_pressed.xml
@@ -0,0 +1,21 @@
+<?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.
+  -->
+
+<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
+    <size android:height="52dp" android:width="12dp" />
+    <corners android:radius="6dp" />
+    <solid android:color="#ff1f1f1f" />
+</shape>
\ No newline at end of file
diff --git a/slidingpanelayout/slidingpanelayout/src/main/res/values/attrs.xml b/slidingpanelayout/slidingpanelayout/src/main/res/values/attrs.xml
index 59f02be..14c032e 100644
--- a/slidingpanelayout/slidingpanelayout/src/main/res/values/attrs.xml
+++ b/slidingpanelayout/slidingpanelayout/src/main/res/values/attrs.xml
@@ -19,6 +19,7 @@
         <attr name="isOverlappingEnabled" format="boolean"/>
         <attr name="isUserResizingEnabled" format="boolean"/>
         <attr name="userResizingDividerDrawable" format="reference"/>
+        <attr name="userResizingDividerTint" format="color"/>
         <attr name="isChildClippingToResizeDividerEnabled" format="boolean"/>
         <attr name="userResizeBehavior" format="enum">
             <enum name="relayoutWhenComplete" value="0"/>
diff --git a/slidingpanelayout/slidingpanelayout/src/main/res/values/public.xml b/slidingpanelayout/slidingpanelayout/src/main/res/values/public.xml
index 497b058..c8e7a8f 100644
--- a/slidingpanelayout/slidingpanelayout/src/main/res/values/public.xml
+++ b/slidingpanelayout/slidingpanelayout/src/main/res/values/public.xml
@@ -18,6 +18,7 @@
     <public type="attr" name="isOverlappingEnabled"/>
     <public type="attr" name="isUserResizingEnabled"/>
     <public type="attr" name="userResizingDividerDrawable"/>
+    <public type="attr" name="userResizingDividerTint"/>
     <public type="attr" name="isChildClippingToResizeDividerEnabled"/>
     <public type="attr" name="userResizeBehavior"/>
 </resources>
diff --git a/transition/transition-ktx/build.gradle b/transition/transition-ktx/build.gradle
index f6609db..7f2bb69 100644
--- a/transition/transition-ktx/build.gradle
+++ b/transition/transition-ktx/build.gradle
@@ -49,4 +49,5 @@
 
 android {
     namespace "androidx.transition.ktx"
+    compileSdk 35
 }
diff --git a/transition/transition/build.gradle b/transition/transition/build.gradle
index ba98363..ebdf43b 100644
--- a/transition/transition/build.gradle
+++ b/transition/transition/build.gradle
@@ -15,7 +15,7 @@
 
 dependencies {
     api("androidx.annotation:annotation:1.8.1")
-    api("androidx.core:core:1.13.0")
+    api(project(":core:core"))
     implementation("androidx.collection:collection:1.4.2")
     compileOnly("androidx.fragment:fragment:1.7.0")
     compileOnly("androidx.appcompat:appcompat:1.0.1")
@@ -39,6 +39,7 @@
 }
 
 android {
+    compileSdk 35
     buildTypes.configureEach {
         consumerProguardFiles "proguard-rules.pro"
     }
diff --git a/transition/transition/src/main/java/androidx/transition/GhostViewHolder.java b/transition/transition/src/main/java/androidx/transition/GhostViewHolder.java
index 95667f4..bd9137b 100644
--- a/transition/transition/src/main/java/androidx/transition/GhostViewHolder.java
+++ b/transition/transition/src/main/java/androidx/transition/GhostViewHolder.java
@@ -25,6 +25,7 @@
 
 import androidx.annotation.NonNull;
 import androidx.annotation.RequiresApi;
+import androidx.core.view.ViewCompat;
 
 import java.util.ArrayList;
 
@@ -40,7 +41,7 @@
         setClipChildren(false);
         mParent = parent;
         mParent.setTag(R.id.ghost_view_holder, this);
-        mParent.getOverlay().add(this);
+        ViewCompat.addOverlayView(mParent, this);
         mAttached = true;
     }
 
diff --git a/transition/transition/src/main/java/androidx/transition/TransitionUtils.java b/transition/transition/src/main/java/androidx/transition/TransitionUtils.java
index f4359b2..5896e52 100644
--- a/transition/transition/src/main/java/androidx/transition/TransitionUtils.java
+++ b/transition/transition/src/main/java/androidx/transition/TransitionUtils.java
@@ -31,6 +31,7 @@
 import android.widget.ImageView;
 
 import androidx.annotation.RequiresApi;
+import androidx.core.view.ViewCompat;
 
 class TransitionUtils {
 
@@ -99,8 +100,7 @@
             }
             parent = (ViewGroup) view.getParent();
             indexInParent = parent.indexOfChild(view);
-            ViewGroupOverlay result = sceneRoot.getOverlay();
-            result.add(view);
+            ViewCompat.addOverlayView(sceneRoot, view);
         }
         Bitmap bitmap = null;
         int bitmapWidth = Math.round(bounds.width());
diff --git a/transition/transition/src/main/java/androidx/transition/Visibility.java b/transition/transition/src/main/java/androidx/transition/Visibility.java
index 543541a..9d87ee2 100644
--- a/transition/transition/src/main/java/androidx/transition/Visibility.java
+++ b/transition/transition/src/main/java/androidx/transition/Visibility.java
@@ -33,6 +33,7 @@
 import androidx.annotation.Nullable;
 import androidx.annotation.RestrictTo;
 import androidx.core.content.res.TypedArrayUtils;
+import androidx.core.view.ViewCompat;
 
 import java.lang.annotation.Retention;
 import java.lang.annotation.RetentionPolicy;
@@ -426,7 +427,7 @@
                 sceneRoot.getLocationOnScreen(loc);
                 overlayView.offsetLeftAndRight((screenX - loc[0]) - overlayView.getLeft());
                 overlayView.offsetTopAndBottom((screenY - loc[1]) - overlayView.getTop());
-                sceneRoot.getOverlay().add(overlayView);
+                ViewCompat.addOverlayView(sceneRoot, overlayView);
             }
             Animator animator = onDisappear(sceneRoot, overlayView, startValues, endValues);
             if (!reusingOverlayView) {
@@ -628,7 +629,7 @@
         @Override
         public void onAnimationResume(Animator animation) {
             if (mOverlayView.getParent() == null) {
-                mOverlayHost.getOverlay().add(mOverlayView);
+                ViewCompat.addOverlayView(mOverlayHost, mOverlayView);
             } else {
                 cancel();
             }
@@ -638,7 +639,7 @@
         public void onAnimationStart(@NonNull Animator animation, boolean isReverse) {
             if (isReverse) {
                 mStartView.setTag(R.id.save_overlay_view, mOverlayView);
-                mOverlayHost.getOverlay().add(mOverlayView);
+                ViewCompat.addOverlayView(mOverlayHost, mOverlayView);
                 mHasOverlay = true;
             }
         }
diff --git a/wear/compose/compose-material3/api/current.txt b/wear/compose/compose-material3/api/current.txt
index 0acff54..82db331 100644
--- a/wear/compose/compose-material3/api/current.txt
+++ b/wear/compose/compose-material3/api/current.txt
@@ -701,6 +701,7 @@
 
   public final class LinearProgressIndicatorKt {
     method @androidx.compose.runtime.Composable public static void LinearProgressIndicator(kotlin.jvm.functions.Function0<java.lang.Float> progress, optional androidx.compose.ui.Modifier modifier, optional androidx.wear.compose.material3.ProgressIndicatorColors colors, optional float strokeWidth, optional boolean enabled);
+    method @androidx.compose.runtime.Composable public static void LinearProgressIndicatorContent(kotlin.jvm.functions.Function0<java.lang.Float> progress, optional androidx.compose.ui.Modifier modifier, optional androidx.wear.compose.material3.ProgressIndicatorColors colors, optional float strokeWidth, optional boolean enabled);
   }
 
   public final class ListHeaderDefaults {
diff --git a/wear/compose/compose-material3/api/restricted_current.txt b/wear/compose/compose-material3/api/restricted_current.txt
index 0acff54..82db331 100644
--- a/wear/compose/compose-material3/api/restricted_current.txt
+++ b/wear/compose/compose-material3/api/restricted_current.txt
@@ -701,6 +701,7 @@
 
   public final class LinearProgressIndicatorKt {
     method @androidx.compose.runtime.Composable public static void LinearProgressIndicator(kotlin.jvm.functions.Function0<java.lang.Float> progress, optional androidx.compose.ui.Modifier modifier, optional androidx.wear.compose.material3.ProgressIndicatorColors colors, optional float strokeWidth, optional boolean enabled);
+    method @androidx.compose.runtime.Composable public static void LinearProgressIndicatorContent(kotlin.jvm.functions.Function0<java.lang.Float> progress, optional androidx.compose.ui.Modifier modifier, optional androidx.wear.compose.material3.ProgressIndicatorColors colors, optional float strokeWidth, optional boolean enabled);
   }
 
   public final class ListHeaderDefaults {
diff --git a/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/ButtonDemo.kt b/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/ButtonDemo.kt
index 219dc41e..cbef4a3 100644
--- a/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/ButtonDemo.kt
+++ b/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/ButtonDemo.kt
@@ -444,7 +444,13 @@
         item {
             ChildButton(
                 onClick = { /* Do something */ },
-                label = { Text("Child Button") },
+                label = {
+                    Text(
+                        "Child Button",
+                        textAlign = TextAlign.Center,
+                        modifier = Modifier.fillMaxWidth()
+                    )
+                },
                 enabled = false,
                 modifier = Modifier.fillMaxWidth(),
             )
@@ -554,7 +560,7 @@
             }
         }
         item { ListHeader { Text("Icon and Label") } }
-        item { CompactButtonSample() }
+        item { CompactButtonSample(modifier = Modifier.fillMaxWidth()) }
         item {
             CompactButton(
                 onClick = { /* Do something */ },
@@ -628,12 +634,13 @@
         item { ListHeader { Text("Long Click") } }
         item {
             CompactButtonWithOnLongClickSample(
+                modifier = Modifier.fillMaxWidth(),
                 onClickHandler = { showOnClickToast(context) },
                 onLongClickHandler = { showOnLongClickToast(context) }
             )
         }
         item { ListHeader { Text("Expandable") } }
-        item { OutlinedCompactButtonSample() }
+        item { OutlinedCompactButtonSample(modifier = Modifier.fillMaxWidth()) }
     }
 }
 
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 6c4cb3a..65e49e1 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
@@ -47,12 +47,14 @@
 import androidx.wear.compose.material3.CircularProgressIndicator
 import androidx.wear.compose.material3.CircularProgressIndicatorDefaults
 import androidx.wear.compose.material3.IconButtonDefaults
+import androidx.wear.compose.material3.LinearProgressIndicator
 import androidx.wear.compose.material3.ListHeader
 import androidx.wear.compose.material3.MaterialTheme
 import androidx.wear.compose.material3.ProgressIndicatorDefaults
 import androidx.wear.compose.material3.SegmentedCircularProgressIndicator
 import androidx.wear.compose.material3.Slider
 import androidx.wear.compose.material3.SliderDefaults
+import androidx.wear.compose.material3.Stepper
 import androidx.wear.compose.material3.SwitchButton
 import androidx.wear.compose.material3.Text
 import androidx.wear.compose.material3.samples.CircularProgressIndicatorContentSample
@@ -136,7 +138,17 @@
                 },
             )
         ),
-        ComposableDemo("Linear progress") { Centralize { LinearProgressIndicatorSamples() } },
+        Material3DemoCategory(
+            title = "Linear progress",
+            listOf(
+                ComposableDemo("Linear Samples") {
+                    Centralize { LinearProgressIndicatorSamples() }
+                },
+                ComposableDemo("Animation") {
+                    Centralize { LinearProgressIndicatorAnimatedDemo() }
+                },
+            )
+        ),
         Material3DemoCategory(
             title = "Arc Progress Indicator",
             listOf(
@@ -556,3 +568,32 @@
         }
     }
 }
+
+@Composable
+fun LinearProgressIndicatorAnimatedDemo() {
+    var progress by remember { mutableFloatStateOf(0.25f) }
+    val valueRange = 0f..1f
+
+    Box(modifier = Modifier.fillMaxSize()) {
+        Stepper(
+            value = progress,
+            onValueChange = { progress = it },
+            valueRange = valueRange,
+            steps = 3,
+        ) {
+            ScalingLazyColumn(
+                modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp),
+                verticalArrangement = Arrangement.Center,
+                horizontalAlignment = Alignment.CenterHorizontally
+            ) {
+                item { Text(String.format("Progress: %.0f%%", progress * 100)) }
+                item {
+                    LinearProgressIndicator(
+                        modifier = Modifier.padding(top = 8.dp),
+                        progress = { progress }
+                    )
+                }
+            }
+        }
+    }
+}
diff --git a/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/TransformingLazyColumnDemo.kt b/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/TransformingLazyColumnDemo.kt
index 4a0c611..4523210 100644
--- a/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/TransformingLazyColumnDemo.kt
+++ b/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/TransformingLazyColumnDemo.kt
@@ -125,7 +125,7 @@
             ListHeader { Text("Buttons", style = MaterialTheme.typography.labelLarge) }
         }
 
-        item { SimpleButtonSample() }
+        item { SimpleButtonSample(modifier = Modifier.fillMaxWidth()) }
         item { ButtonSample() }
         item { ButtonLargeIconSample() }
         item { ButtonExtraLargeIconSample() }
@@ -137,9 +137,9 @@
         item { OutlinedButtonSample() }
         item { SimpleChildButtonSample() }
         item { ChildButtonSample() }
-        item { CompactButtonSample() }
-        item { FilledTonalCompactButtonSample() }
-        item { OutlinedCompactButtonSample() }
+        item { CompactButtonSample(modifier = Modifier.fillMaxWidth()) }
+        item { FilledTonalCompactButtonSample(modifier = Modifier.fillMaxWidth()) }
+        item { OutlinedCompactButtonSample(modifier = Modifier.fillMaxWidth()) }
         item { ButtonBackgroundImage(painterResource(R.drawable.backgroundimage), enabled = true) }
         item { ButtonBackgroundImage(painterResource(R.drawable.backgroundimage), enabled = false) }
         item { ListHeader { Text("Complex Buttons") } }
diff --git a/wear/compose/compose-material3/macrobenchmark-common/src/main/java/androidx/wear/compose/material3/macrobenchmark/common/DatePickerBenchmark.kt b/wear/compose/compose-material3/macrobenchmark-common/src/main/java/androidx/wear/compose/material3/macrobenchmark/common/DatePickerBenchmark.kt
new file mode 100644
index 0000000..96f4cce
--- /dev/null
+++ b/wear/compose/compose-material3/macrobenchmark-common/src/main/java/androidx/wear/compose/material3/macrobenchmark/common/DatePickerBenchmark.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.wear.compose.material3.macrobenchmark.common
+
+import android.os.Build
+import android.os.SystemClock
+import androidx.annotation.RequiresApi
+import androidx.benchmark.macro.MacrobenchmarkScope
+import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Edit
+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.platform.LocalConfiguration
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.test.uiautomator.By
+import androidx.test.uiautomator.Until
+import androidx.wear.compose.material3.Button
+import androidx.wear.compose.material3.DatePicker
+import androidx.wear.compose.material3.DatePickerType
+import androidx.wear.compose.material3.Icon
+import androidx.wear.compose.material3.Text
+import java.time.LocalDate
+import java.time.format.DateTimeFormatter
+import java.time.format.FormatStyle
+
+@RequiresApi(Build.VERSION_CODES.O)
+object DatePickerBenchmark : MacrobenchmarkScreen {
+    override val content: @Composable (BoxScope.() -> Unit)
+        get() = {
+            var showDatePicker by remember { mutableStateOf(false) }
+            var datePickerDate by remember { mutableStateOf(LocalDate.of(2024, 9, 2)) }
+            val formatter =
+                DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
+                    .withLocale(LocalConfiguration.current.locales[0])
+            val minDate = LocalDate.of(2022, 10, 30)
+            val maxDate = LocalDate.of(2025, 2, 4)
+            if (showDatePicker) {
+                DatePicker(
+                    initialDate = datePickerDate, // Initialize with last picked date on reopen
+                    onDatePicked = {
+                        datePickerDate = it
+                        showDatePicker = false
+                    },
+                    minDate = minDate,
+                    maxDate = maxDate,
+                    datePickerType = DatePickerType.YearMonthDay
+                )
+            } else {
+                Button(
+                    onClick = { showDatePicker = true },
+                    modifier = Modifier.semantics { contentDescription = CONTENT_DESCRIPTION },
+                    label = { Text("Selected Date") },
+                    secondaryLabel = { Text(datePickerDate.format(formatter)) },
+                    icon = { Icon(imageVector = Icons.Filled.Edit, contentDescription = "Edit") },
+                )
+            }
+        }
+
+    override val exercise: MacrobenchmarkScope.() -> Unit
+        get() = {
+            device
+                .wait(Until.findObject(By.desc(CONTENT_DESCRIPTION)), FIND_OBJECT_TIMEOUT_MS)
+                .click()
+            device.waitForIdle()
+            SystemClock.sleep(500)
+            repeat(3) { columnIndex ->
+                repeat(2) { i ->
+                    val endY =
+                        if (i % 2 == 0) {
+                            device.displayHeight / 10 // scroll up
+                        } else {
+                            device.displayHeight * 9 / 10 // scroll down
+                        }
+                    device.swipe(
+                        device.displayWidth / 2,
+                        device.displayHeight / 2,
+                        device.displayWidth / 2,
+                        endY,
+                        10
+                    )
+                    device.waitForIdle()
+                    SystemClock.sleep(500)
+                }
+                if (columnIndex < 2) {
+                    device.wait(Until.findObject(By.desc("Next")), FIND_OBJECT_TIMEOUT_MS).click()
+                    device.waitForIdle()
+                    SystemClock.sleep(500)
+                } else {
+                    device
+                        .wait(Until.findObject(By.desc("Confirm")), FIND_OBJECT_TIMEOUT_MS)
+                        .click()
+                    device.waitForIdle()
+                    SystemClock.sleep(500)
+                }
+            }
+        }
+}
diff --git a/wear/compose/compose-material3/macrobenchmark-common/src/main/java/androidx/wear/compose/material3/macrobenchmark/common/TimePickerBenchmark.kt b/wear/compose/compose-material3/macrobenchmark-common/src/main/java/androidx/wear/compose/material3/macrobenchmark/common/TimePickerBenchmark.kt
new file mode 100644
index 0000000..7d15ce7
--- /dev/null
+++ b/wear/compose/compose-material3/macrobenchmark-common/src/main/java/androidx/wear/compose/material3/macrobenchmark/common/TimePickerBenchmark.kt
@@ -0,0 +1,67 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR 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.macrobenchmark.common
+
+import android.os.Build
+import android.os.SystemClock
+import androidx.annotation.RequiresApi
+import androidx.benchmark.macro.MacrobenchmarkScope
+import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.runtime.Composable
+import androidx.wear.compose.material3.TimePicker
+import androidx.wear.compose.material3.TimePickerType
+import java.time.LocalTime
+
+@RequiresApi(Build.VERSION_CODES.O)
+object TimePickerBenchmark : MacrobenchmarkScreen {
+    override val content: @Composable (BoxScope.() -> Unit)
+        get() = {
+            TimePicker(
+                onTimePicked = {},
+                timePickerType = TimePickerType.HoursMinutesAmPm12H,
+                initialTime = LocalTime.of(11, 30)
+            )
+        }
+
+    override val exercise: MacrobenchmarkScope.() -> Unit
+        get() = {
+            repeat(4) { i ->
+                val startY = device.displayHeight / 2
+                val endY =
+                    if (i % 2 == 0) {
+                        device.displayHeight / 10 // scroll up
+                    } else {
+                        device.displayHeight * 9 / 10 // scroll down
+                    }
+
+                val hourX = device.displayWidth / 4
+                device.swipe(hourX, startY, hourX, endY, 10)
+                device.waitForIdle()
+                SystemClock.sleep(500)
+
+                val minutesX = device.displayWidth / 2
+                device.swipe(minutesX, startY, minutesX, endY, 10)
+                device.waitForIdle()
+                SystemClock.sleep(500)
+
+                val amPmX = device.displayWidth * 3 / 4
+                device.swipe(amPmX, startY, amPmX, endY, 10)
+                device.waitForIdle()
+                SystemClock.sleep(500)
+            }
+        }
+}
diff --git a/wear/compose/compose-material3/macrobenchmark-target/src/main/AndroidManifest.xml b/wear/compose/compose-material3/macrobenchmark-target/src/main/AndroidManifest.xml
index ba97ae9..f93d560 100644
--- a/wear/compose/compose-material3/macrobenchmark-target/src/main/AndroidManifest.xml
+++ b/wear/compose/compose-material3/macrobenchmark-target/src/main/AndroidManifest.xml
@@ -57,7 +57,8 @@
         <activity
             android:name=".AnimatedTextActivity"
             android:exported="true"
-            android:theme="@style/AppTheme">
+            android:theme="@style/AppTheme"
+            tools:targetApi="s">
             <intent-filter>
                 <action android:name="androidx.wear.compose.material3.macrobenchmark.target.ANIMATED_TEXT_ACTIVITY" />
                 <category android:name="android.intent.category.DEFAULT" />
@@ -117,6 +118,18 @@
         </activity>
 
         <activity
+            android:name=".DatePickerActivity"
+            android:theme="@style/AppTheme"
+            android:exported="true"
+            tools:targetApi="o">
+            <intent-filter>
+                <action android:name=
+                    "androidx.wear.compose.material3.macrobenchmark.target.DATE_PICKER_ACTIVITY" />
+                <category android:name="android.intent.category.DEFAULT" />
+            </intent-filter>
+        </activity>
+
+        <activity
             android:name=".IndeterminateCircularProgressIndicatorActivity"
             android:theme="@style/AppTheme"
             android:exported="true">
@@ -223,6 +236,18 @@
         </activity>
 
         <activity
+            android:name=".TimePickerActivity"
+            android:theme="@style/AppTheme"
+            android:exported="true"
+            tools:targetApi="o">
+            <intent-filter>
+                <action android:name=
+                    "androidx.wear.compose.material3.macrobenchmark.target.TIME_PICKER_ACTIVITY" />
+                <category android:name="android.intent.category.DEFAULT" />
+            </intent-filter>
+        </activity>
+
+        <activity
             android:name=".TransformingLazyColumnActivity"
             android:theme="@style/AppTheme"
             android:exported="true">
diff --git a/wear/compose/compose-material3/macrobenchmark-target/src/main/java/androidx/wear/compose/material3/macrobenchmark/target/AnimatedTextActivity.kt b/wear/compose/compose-material3/macrobenchmark-target/src/main/java/androidx/wear/compose/material3/macrobenchmark/target/AnimatedTextActivity.kt
index 7709258..fd40622 100644
--- a/wear/compose/compose-material3/macrobenchmark-target/src/main/java/androidx/wear/compose/material3/macrobenchmark/target/AnimatedTextActivity.kt
+++ b/wear/compose/compose-material3/macrobenchmark-target/src/main/java/androidx/wear/compose/material3/macrobenchmark/target/AnimatedTextActivity.kt
@@ -16,6 +16,9 @@
 
 package androidx.wear.compose.material3.macrobenchmark.target
 
+import android.os.Build
+import androidx.annotation.RequiresApi
 import androidx.wear.compose.material3.macrobenchmark.common.AnimatedTextBenchmark
 
+@RequiresApi(Build.VERSION_CODES.S)
 class AnimatedTextActivity : BenchmarkBaseActivity(AnimatedTextBenchmark)
diff --git a/wear/compose/compose-material3/macrobenchmark-target/src/main/java/androidx/wear/compose/material3/macrobenchmark/target/DatePickerActivity.kt b/wear/compose/compose-material3/macrobenchmark-target/src/main/java/androidx/wear/compose/material3/macrobenchmark/target/DatePickerActivity.kt
new file mode 100644
index 0000000..d18d12d
--- /dev/null
+++ b/wear/compose/compose-material3/macrobenchmark-target/src/main/java/androidx/wear/compose/material3/macrobenchmark/target/DatePickerActivity.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.wear.compose.material3.macrobenchmark.target
+
+import android.os.Build
+import androidx.annotation.RequiresApi
+import androidx.wear.compose.material3.macrobenchmark.common.DatePickerBenchmark
+
+@RequiresApi(Build.VERSION_CODES.O)
+class DatePickerActivity : BenchmarkBaseActivity(DatePickerBenchmark)
diff --git a/wear/compose/compose-material3/macrobenchmark-target/src/main/java/androidx/wear/compose/material3/macrobenchmark/target/TimePickerActivity.kt b/wear/compose/compose-material3/macrobenchmark-target/src/main/java/androidx/wear/compose/material3/macrobenchmark/target/TimePickerActivity.kt
new file mode 100644
index 0000000..e134065
--- /dev/null
+++ b/wear/compose/compose-material3/macrobenchmark-target/src/main/java/androidx/wear/compose/material3/macrobenchmark/target/TimePickerActivity.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.wear.compose.material3.macrobenchmark.target
+
+import android.os.Build
+import androidx.annotation.RequiresApi
+import androidx.wear.compose.material3.macrobenchmark.common.TimePickerBenchmark
+
+@RequiresApi(Build.VERSION_CODES.O)
+class TimePickerActivity : BenchmarkBaseActivity(TimePickerBenchmark)
diff --git a/wear/compose/compose-material3/macrobenchmark/src/main/java/androidx/wear/compose/material3/macrobenchmark/DatePickerBenchmarkTest.kt b/wear/compose/compose-material3/macrobenchmark/src/main/java/androidx/wear/compose/material3/macrobenchmark/DatePickerBenchmarkTest.kt
new file mode 100644
index 0000000..3316732
--- /dev/null
+++ b/wear/compose/compose-material3/macrobenchmark/src/main/java/androidx/wear/compose/material3/macrobenchmark/DatePickerBenchmarkTest.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.wear.compose.material3.macrobenchmark
+
+import android.os.Build
+import androidx.annotation.RequiresApi
+import androidx.benchmark.macro.CompilationMode
+import androidx.test.filters.LargeTest
+import androidx.wear.compose.material3.macrobenchmark.common.DatePickerBenchmark
+import org.junit.runner.RunWith
+import org.junit.runners.Parameterized
+
+@RequiresApi(Build.VERSION_CODES.O)
+@LargeTest
+@RunWith(Parameterized::class)
+class DatePickerBenchmarkTest(compilationMode: CompilationMode) :
+    BenchmarkTestBase(
+        compilationMode = compilationMode,
+        macrobenchmarkScreen = DatePickerBenchmark,
+        actionSuffix = "DATE_PICKER_ACTIVITY"
+    )
diff --git a/wear/compose/compose-material3/macrobenchmark/src/main/java/androidx/wear/compose/material3/macrobenchmark/TimePickerBenchmarkTest.kt b/wear/compose/compose-material3/macrobenchmark/src/main/java/androidx/wear/compose/material3/macrobenchmark/TimePickerBenchmarkTest.kt
new file mode 100644
index 0000000..52a5a38
--- /dev/null
+++ b/wear/compose/compose-material3/macrobenchmark/src/main/java/androidx/wear/compose/material3/macrobenchmark/TimePickerBenchmarkTest.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.wear.compose.material3.macrobenchmark
+
+import android.os.Build
+import androidx.annotation.RequiresApi
+import androidx.benchmark.macro.CompilationMode
+import androidx.test.filters.LargeTest
+import androidx.wear.compose.material3.macrobenchmark.common.TimePickerBenchmark
+import org.junit.runner.RunWith
+import org.junit.runners.Parameterized
+
+@RequiresApi(Build.VERSION_CODES.O)
+@LargeTest
+@RunWith(Parameterized::class)
+class TimePickerBenchmarkTest(compilationMode: CompilationMode) :
+    BenchmarkTestBase(
+        compilationMode = compilationMode,
+        macrobenchmarkScreen = TimePickerBenchmark,
+        actionSuffix = "TIME_PICKER_ACTIVITY"
+    )
diff --git a/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/AlertDialogSample.kt b/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/AlertDialogSample.kt
index 6308c19..43fb374 100644
--- a/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/AlertDialogSample.kt
+++ b/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/AlertDialogSample.kt
@@ -36,6 +36,7 @@
 import androidx.wear.compose.material3.FilledTonalButton
 import androidx.wear.compose.material3.Icon
 import androidx.wear.compose.material3.MaterialTheme
+import androidx.wear.compose.material3.SwitchButton
 import androidx.wear.compose.material3.Text
 
 @Sampled
@@ -122,6 +123,8 @@
 @Composable
 fun AlertDialogWithContentGroupsSample() {
     var showDialog by remember { mutableStateOf(false) }
+    var weatherEnabled by remember { mutableStateOf(false) }
+    var calendarEnabled by remember { mutableStateOf(false) }
 
     Box(Modifier.fillMaxSize()) {
         FilledTonalButton(
@@ -147,16 +150,18 @@
         }
     ) {
         item {
-            FilledTonalButton(
+            SwitchButton(
                 modifier = Modifier.fillMaxWidth(),
-                onClick = {},
+                checked = weatherEnabled,
+                onCheckedChange = { weatherEnabled = it },
                 label = { Text("Weather") }
             )
         }
         item {
-            FilledTonalButton(
+            SwitchButton(
                 modifier = Modifier.fillMaxWidth(),
-                onClick = {},
+                checked = calendarEnabled,
+                onCheckedChange = { calendarEnabled = it },
                 label = { Text("Calendar") }
             )
         }
diff --git a/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/AnimatedTextSample.kt b/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/AnimatedTextSample.kt
index e3389e9..3d10692 100644
--- a/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/AnimatedTextSample.kt
+++ b/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/AnimatedTextSample.kt
@@ -19,13 +19,17 @@
 import androidx.annotation.Sampled
 import androidx.compose.animation.core.Animatable
 import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.padding
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.mutableIntStateOf
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.rememberCoroutineScope
 import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
 import androidx.compose.ui.text.font.FontVariation
+import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.sp
 import androidx.wear.compose.material3.AnimatedText
 import androidx.wear.compose.material3.Button
@@ -87,39 +91,34 @@
                     FontVariation.weight(500),
                 ),
             startFontSize = 30.sp,
-            endFontSize = 40.sp,
+            endFontSize = 30.sp,
         )
-    val firstNumber = remember { mutableIntStateOf(0) }
-    val firstAnimatable = remember { Animatable(0f) }
-    val secondNumber = remember { mutableIntStateOf(0) }
-    val secondAnimatable = remember { Animatable(0f) }
-    Column(horizontalAlignment = Alignment.CenterHorizontally) {
-        AnimatedText(
-            text = "${firstNumber.value}",
-            fontRegistry = animatedTextFontRegistry,
-            progressFraction = { firstAnimatable.value },
-        )
+    val number = remember { mutableIntStateOf(0) }
+    val textAnimatable = remember { Animatable(0f) }
+    Row(verticalAlignment = Alignment.CenterVertically) {
         Button(
+            modifier = Modifier.padding(horizontal = 16.dp),
             onClick = {
-                firstNumber.value += 1
+                number.value -= 1
                 scope.launch {
-                    firstAnimatable.animateTo(1f)
-                    firstAnimatable.animateTo(0f)
+                    textAnimatable.animateTo(1f)
+                    textAnimatable.animateTo(0f)
                 }
             },
-            label = { Text("+") }
+            label = { Text("-") }
         )
         AnimatedText(
-            text = "${secondNumber.value}",
+            text = "${number.value}",
             fontRegistry = animatedTextFontRegistry,
-            progressFraction = { secondAnimatable.value },
+            progressFraction = { textAnimatable.value },
         )
         Button(
+            modifier = Modifier.padding(horizontal = 16.dp),
             onClick = {
-                secondNumber.value += 1
+                number.value += 1
                 scope.launch {
-                    secondAnimatable.animateTo(1f)
-                    secondAnimatable.animateTo(0f)
+                    textAnimatable.animateTo(1f)
+                    textAnimatable.animateTo(0f)
                 }
             },
             label = { Text("+") }
@@ -132,17 +131,17 @@
 fun AnimatedTextSampleSharedFontRegistry() {
     val animatedTextFontRegistry =
         rememberAnimatedTextFontRegistry(
-            // Variation axes at the start of the animation, width 10, weight 200
+            // Variation axes at the start of the animation, width 50, weight 300
             startFontVariationSettings =
                 FontVariation.Settings(
-                    FontVariation.width(10f),
-                    FontVariation.weight(200),
+                    FontVariation.width(50f),
+                    FontVariation.weight(300),
                 ),
-            // Variation axes at the end of the animation, width 100, weight 500
+            // Variation axes at the end of the animation are the same as the start axes
             endFontVariationSettings =
                 FontVariation.Settings(
-                    FontVariation.width(100f),
-                    FontVariation.weight(500),
+                    FontVariation.width(50f),
+                    FontVariation.weight(300),
                 ),
             startFontSize = 15.sp,
             endFontSize = 25.sp,
diff --git a/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/ButtonGroupSample.kt b/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/ButtonGroupSample.kt
index b328822..a467fe1 100644
--- a/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/ButtonGroupSample.kt
+++ b/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/ButtonGroupSample.kt
@@ -40,16 +40,12 @@
         ButtonGroup(Modifier.fillMaxWidth()) {
             buttonGroupItem(interactionSource = interactionSourceLeft) {
                 Button(onClick = {}, interactionSource = interactionSourceLeft) {
-                    Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
-                        Text("Left")
-                    }
+                    Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Text("L") }
                 }
             }
             buttonGroupItem(interactionSource = interactionSourceRight) {
                 Button(onClick = {}, interactionSource = interactionSourceRight) {
-                    Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
-                        Text("Right")
-                    }
+                    Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Text("R") }
                 }
             }
         }
diff --git a/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/ButtonSample.kt b/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/ButtonSample.kt
index 40d5816..39798a1 100644
--- a/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/ButtonSample.kt
+++ b/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/ButtonSample.kt
@@ -24,6 +24,7 @@
 import androidx.compose.runtime.Composable
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.style.TextAlign
 import androidx.compose.ui.text.style.TextOverflow
 import androidx.wear.compose.material3.Button
 import androidx.wear.compose.material3.ButtonDefaults
@@ -37,7 +38,7 @@
 @Sampled
 @Composable
 fun SimpleButtonSample(modifier: Modifier = Modifier) {
-    Button(onClick = { /* Do something */ }, label = { Text("Button") }, modifier = modifier)
+    Button(onClick = { /* Do something */ }, label = { Text("Simple Button") }, modifier = modifier)
 }
 
 @Sampled
@@ -196,7 +197,9 @@
 fun SimpleChildButtonSample(modifier: Modifier = Modifier.fillMaxWidth()) {
     ChildButton(
         onClick = { /* Do something */ },
-        label = { Text("Child Button") },
+        label = {
+            Text("Child Button", textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth())
+        },
         modifier = modifier,
     )
 }
@@ -221,7 +224,7 @@
 
 @Sampled
 @Composable
-fun CompactButtonSample(modifier: Modifier = Modifier.fillMaxWidth()) {
+fun CompactButtonSample(modifier: Modifier = Modifier) {
     CompactButton(
         onClick = { /* Do something */ },
         icon = {
@@ -242,7 +245,7 @@
 fun CompactButtonWithOnLongClickSample(
     onClickHandler: () -> Unit,
     onLongClickHandler: () -> Unit,
-    modifier: Modifier = Modifier.fillMaxWidth()
+    modifier: Modifier = Modifier
 ) {
     CompactButton(
         onClick = onClickHandler,
@@ -255,7 +258,7 @@
 
 @Sampled
 @Composable
-fun FilledTonalCompactButtonSample(modifier: Modifier = Modifier.fillMaxWidth()) {
+fun FilledTonalCompactButtonSample(modifier: Modifier = Modifier) {
     CompactButton(
         onClick = { /* Do something */ },
         icon = {
@@ -274,7 +277,7 @@
 
 @Sampled
 @Composable
-fun OutlinedCompactButtonSample(modifier: Modifier = Modifier.fillMaxWidth()) {
+fun OutlinedCompactButtonSample(modifier: Modifier = Modifier) {
     CompactButton(
         onClick = { /* Do something */ },
         icon = {
diff --git a/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/CardSample.kt b/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/CardSample.kt
index d553884..f30ea5d 100644
--- a/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/CardSample.kt
+++ b/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/CardSample.kt
@@ -77,7 +77,7 @@
         onClick = { /* Do something */ },
         appName = { Text("App name") },
         title = { Text("Card title") },
-        time = { Text("now") },
+        time = { Text("Now") },
     ) {
         Text("Card content")
     }
@@ -96,10 +96,11 @@
                 modifier =
                     Modifier.size(CardDefaults.AppImageSize)
                         .wrapContentSize(align = Alignment.Center),
+                tint = MaterialTheme.colorScheme.primary
             )
         },
         title = { Text("Card title") },
-        time = { Text("now") },
+        time = { Text("Now") },
     ) {
         Text("Card content")
     }
@@ -122,10 +123,11 @@
                 modifier =
                     Modifier.size(CardDefaults.AppImageSize)
                         .wrapContentSize(align = Alignment.Center),
+                tint = MaterialTheme.colorScheme.primary
             )
         },
         title = { Text("With image") },
-        time = { Text("now") },
+        time = { Text("Now") },
     ) {
         Spacer(modifier = Modifier.height(4.dp))
         Row(modifier = Modifier.fillMaxWidth()) {
@@ -147,7 +149,7 @@
     TitleCard(
         onClick = { /* Do something */ },
         title = { Text("Title card") },
-        time = { Text("now") },
+        time = { Text("Now") },
     ) {
         Text("Card content")
     }
@@ -158,7 +160,7 @@
 fun TitleCardWithSubtitleAndTimeSample() {
     TitleCard(
         onClick = { /* Do something */ },
-        time = { Text("now") },
+        time = { Text("Now") },
         title = { Text("Title card") },
         subtitle = { Text("Subtitle") }
     )
@@ -170,7 +172,7 @@
     TitleCard(
         onClick = { /* Do something */ },
         title = { Text("Title card") },
-        time = { Text("now") },
+        time = { Text("Now") },
         modifier = Modifier.semantics { contentDescription = "Background image" }
     ) {
         Spacer(Modifier.height(4.dp))
@@ -206,7 +208,7 @@
     TitleCard(
         onClick = { /* Do something */ },
         title = { Text("Card title") },
-        time = { Text("now") },
+        time = { Text("Now") },
         colors =
             CardDefaults.imageCardColors(
                 containerPainter =
@@ -247,7 +249,7 @@
             )
         },
         title = { Text("App card") },
-        time = { Text("now") },
+        time = { Text("Now") },
         colors = CardDefaults.outlinedCardColors(),
         border = CardDefaults.outlinedCardBorder(),
     ) {
@@ -261,7 +263,7 @@
     TitleCard(
         onClick = { /* Do something */ },
         title = { Text("Title card") },
-        time = { Text("now") },
+        time = { Text("Now") },
         colors = CardDefaults.outlinedCardColors(),
         border = CardDefaults.outlinedCardBorder(),
     ) {
diff --git a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/LinearProgressIndicatorScreenshotTest.kt b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/LinearProgressIndicatorScreenshotTest.kt
index cd4f141..498ce2f 100644
--- a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/LinearProgressIndicatorScreenshotTest.kt
+++ b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/LinearProgressIndicatorScreenshotTest.kt
@@ -21,6 +21,7 @@
 import androidx.compose.foundation.layout.aspectRatio
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.mutableFloatStateOf
 import androidx.compose.testutils.assertAgainstGolden
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.graphics.Color
@@ -59,6 +60,14 @@
     }
 
     @Test
+    fun linear_progress_indicator_1_percent() = linear_progress_indicator_test {
+        LinearProgressIndicator(
+            progress = { 0.01f },
+            modifier = Modifier.aspectRatio(1f).testTag(TEST_TAG),
+        )
+    }
+
+    @Test
     fun linear_progress_indicator_50_percent() = linear_progress_indicator_test {
         LinearProgressIndicator(
             progress = { 0.5f },
@@ -67,6 +76,14 @@
     }
 
     @Test
+    fun linear_progress_indicator_95_percent() = linear_progress_indicator_test {
+        LinearProgressIndicator(
+            progress = { 0.95f },
+            modifier = Modifier.aspectRatio(1f).testTag(TEST_TAG),
+        )
+    }
+
+    @Test
     fun linear_progress_indicator_full() = linear_progress_indicator_test {
         LinearProgressIndicator(
             progress = { 1f },
@@ -105,16 +122,41 @@
             )
         }
 
+    @Test
+    fun linear_progress_indicator_animated_progress() {
+        rule.mainClock.autoAdvance = false
+        val progress = mutableFloatStateOf(0f)
+
+        rule.setContentWithTheme {
+            ScreenConfiguration(ScreenSize.LARGE.size) {
+                LinearProgressIndicator(
+                    progress = { progress.value },
+                    modifier = Modifier.aspectRatio(1f).testTag(TEST_TAG),
+                )
+            }
+        }
+
+        rule.runOnIdle { progress.value = 1f }
+        rule.mainClock.advanceTimeBy(100)
+
+        rule
+            .onNodeWithTag(TEST_TAG)
+            .captureToImage()
+            .assertAgainstGolden(screenshotRule, testName.goldenIdentifier())
+    }
+
     private fun linear_progress_indicator_test(
         isLtr: Boolean = true,
         content: @Composable () -> Unit
     ) {
         rule.setContentWithTheme(modifier = Modifier.background(Color.Black)) {
-            val layoutDirection = if (isLtr) LayoutDirection.Ltr else LayoutDirection.Rtl
-            CompositionLocalProvider(
-                LocalLayoutDirection provides layoutDirection,
-                content = content
-            )
+            ScreenConfiguration(ScreenSize.LARGE.size) {
+                val layoutDirection = if (isLtr) LayoutDirection.Ltr else LayoutDirection.Rtl
+                CompositionLocalProvider(
+                    LocalLayoutDirection provides layoutDirection,
+                    content = content
+                )
+            }
         }
 
         rule
diff --git a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/ListHeaderTest.kt b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/ListHeaderTest.kt
index 3a34438..eaa7353 100644
--- a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/ListHeaderTest.kt
+++ b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/ListHeaderTest.kt
@@ -119,7 +119,7 @@
         var expectedTextStyle = TextStyle.Default
 
         rule.setContentWithTheme {
-            expectedTextStyle = MaterialTheme.typography.titleMedium
+            expectedTextStyle = MaterialTheme.typography.titleSmall
             ListSubHeader { actualTextStyle = LocalTextStyle.current }
         }
 
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/LinearProgressIndicator.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/LinearProgressIndicator.kt
index 8a30e1a..4862818 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/LinearProgressIndicator.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/LinearProgressIndicator.kt
@@ -16,21 +16,30 @@
 
 package androidx.wear.compose.material3
 
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.AnimationSpec
 import androidx.compose.foundation.Canvas
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.height
 import androidx.compose.foundation.layout.padding
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.runtime.snapshotFlow
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.draw.scale
 import androidx.compose.ui.geometry.Offset
 import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.SolidColor
 import androidx.compose.ui.graphics.StrokeCap
 import androidx.compose.ui.graphics.drawscope.DrawScope
 import androidx.compose.ui.platform.LocalLayoutDirection
 import androidx.compose.ui.unit.Dp
 import androidx.compose.ui.unit.LayoutDirection
 import androidx.compose.ui.unit.dp
+import kotlinx.coroutines.flow.collectLatest
 
 /**
  * Material Design linear progress indicator.
@@ -43,13 +52,15 @@
  * The indicator also includes a small dot at the end of the progress line. This dot serves as an
  * accessibility feature to show the range of the indicator.
  *
- * Small progress values that are larger than zero will be rounded up to at least the stroke width.
+ * Progress updates will be animated. Small progress values that are larger than zero will be
+ * rounded up to at least the stroke width.
  *
  * [LinearProgressIndicator] sample:
  *
  * @sample androidx.wear.compose.material3.samples.LinearProgressIndicatorSample
  * @param progress The progress of this progress indicator where 0.0 represents no progress and 1.0
- *   represents completion. Values outside of this range are coerced into the range 0..1.
+ *   represents completion. Values outside of this range are coerced into the range 0..1. Progress
+ *   value changes will be animated.
  * @param modifier Modifier to be applied to the [LinearProgressIndicator].
  * @param colors [ProgressIndicatorColors] that will be used to resolve the indicator and track
  *   colors for this progress indicator in different states.
@@ -72,7 +83,52 @@
         "Stroke width cannot be less than ${LinearProgressIndicatorDefaults.StrokeWidthSmall}"
     }
 
-    val coercedProgress = { progress().coerceIn(0f, 1f) }
+    val progressAnimationSpec: AnimationSpec<Float> = linearProgressAnimationSpec
+    val updatedProgress by rememberUpdatedState(progress)
+    val animatedProgress = remember { Animatable(updatedProgress().coerceIn(0f, 1f)) }
+
+    LaunchedEffect(Unit) {
+        snapshotFlow(updatedProgress).collectLatest {
+            val currentProgress = it.coerceIn(0f, 1f)
+            animatedProgress.animateTo(currentProgress, progressAnimationSpec)
+        }
+    }
+
+    LinearProgressIndicatorContent(
+        progress = animatedProgress::value,
+        modifier = modifier,
+        colors = colors,
+        strokeWidth = strokeWidth,
+        enabled = enabled,
+    )
+}
+
+/**
+ * Linear progress indicator content with no progress animations.
+ *
+ * @param progress The progress of this progress indicator where 0.0 represents no progress and 1.0
+ *   represents completion. Values outside of this range are coerced into the range 0..1.
+ * @param modifier Modifier to be applied to the [LinearProgressIndicator].
+ * @param colors [ProgressIndicatorColors] that will be used to resolve the indicator and track
+ *   colors for this progress indicator in different states.
+ * @param strokeWidth The stroke width for the progress indicator. The minimum value is
+ *   [LinearProgressIndicatorDefaults.StrokeWidthSmall] to ensure that the dot drawn at the end of
+ *   the range can be distinguished.
+ * @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.
+ */
+@Composable
+fun LinearProgressIndicatorContent(
+    progress: () -> Float,
+    modifier: Modifier = Modifier,
+    colors: ProgressIndicatorColors = ProgressIndicatorDefaults.colors(),
+    strokeWidth: Dp = LinearProgressIndicatorDefaults.StrokeWidthLarge,
+    enabled: Boolean = true,
+) {
+    require(strokeWidth >= LinearProgressIndicatorDefaults.StrokeWidthSmall) {
+        "Stroke width cannot be less than ${LinearProgressIndicatorDefaults.StrokeWidthSmall}"
+    }
     val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
 
     Canvas(
@@ -83,36 +139,45 @@
                 .padding(LinearProgressIndicatorDefaults.OuterHorizontalMargin)
                 .scale(scaleX = if (isRtl) -1f else 1f, scaleY = 1f), // Flip X axis for RTL layouts
     ) {
-        val progressPx = coercedProgress() * size.width
+        val progressPx = progress() * (size.width - strokeWidth.toPx())
+        val strokeCapOffset = strokeWidth.toPx() / 2f
 
         // Draw the background
         drawLinearIndicator(
-            start = 0f,
-            end = size.width,
+            start = strokeCapOffset,
+            end = size.width - strokeCapOffset,
             brush = colors.trackBrush(enabled),
             strokeWidth = strokeWidth.toPx()
         )
 
-        if (progressPx > 0) {
+        if (progressPx > 0f) {
             // Draw the indicator
             drawLinearIndicator(
-                start = 0f,
-                end = progressPx,
+                start = strokeCapOffset,
+                end = strokeCapOffset + progressPx,
                 brush = colors.indicatorBrush(enabled),
                 strokeWidth = strokeWidth.toPx(),
             )
         }
 
-        // Draw the dot at the end of the line. The dot will be hidden when progress plus margin
-        // would touch the dot
+        // Draw a dot at the end of the line.
         val dotRadius = LinearProgressIndicatorDefaults.DotRadius.toPx()
         val dotMargin = LinearProgressIndicatorDefaults.DotMargin.toPx()
+        val dotCenterX = size.width - dotRadius - dotMargin
+        val dotCenterY = size.height / 2f
+        val distanceFromProgressToDot = dotCenterX - dotRadius - progressPx - strokeCapOffset * 2f
 
-        if (progressPx + dotMargin * 2 + dotRadius * 2 < size.width) {
+        // The dot will be hidden when the progress line would touch it.
+        if (distanceFromProgressToDot > 0f) {
+            // The dot will be scaled down when distance from progress line  to dot is smaller than
+            // the margin.
+            val scaleFraction = (distanceFromProgressToDot / dotMargin).coerceAtMost(1f)
+
             drawLinearIndicatorDot(
                 brush = colors.indicatorBrush(enabled),
                 radius = dotRadius,
-                offset = dotMargin
+                center = Offset(dotCenterX, dotCenterY),
+                scaleFraction = scaleFraction
             )
         }
     }
@@ -155,28 +220,15 @@
 ) {
     // Start drawing from the vertical center of the stroke
     val yOffset = size.height / 2
-
-    // need to adjust barStart and barEnd for the stroke caps
-    val strokeCapOffset = strokeWidth / 2
-    val adjustedBarStart = start + strokeCapOffset
-    val adjustedBarEnd = end - strokeCapOffset
-
-    if (adjustedBarEnd > adjustedBarStart) {
+    if (end > start) {
         // Draw progress line
         drawLine(
             brush = brush,
-            start = Offset(adjustedBarStart, yOffset),
-            end = Offset(adjustedBarEnd, yOffset),
+            start = Offset(start, yOffset),
+            end = Offset(end, yOffset),
             strokeWidth = strokeWidth,
             cap = StrokeCap.Round,
         )
-    } else {
-        // For small values, draw a circle with diameter equal of stroke width
-        drawCircle(
-            brush = brush,
-            radius = strokeCapOffset,
-            center = Offset(strokeCapOffset, size.height / 2)
-        )
     }
 }
 
@@ -184,11 +236,24 @@
 private fun DrawScope.drawLinearIndicatorDot(
     brush: Brush,
     radius: Float,
-    offset: Float,
+    center: Offset,
+    scaleFraction: Float = 1f
 ) {
-    drawCircle(
-        brush = brush,
-        radius = radius,
-        center = Offset(size.width - offset - radius, size.height / 2)
-    )
+    // Scale down the dot by the scale fraction.
+    val scaledDotRadius = radius * scaleFraction
+    // Apply the scale fraction alpha to the dot color.
+    val alpha = scaleFraction.coerceAtMost(1f)
+    val brushWithAlpha =
+        if (brush is SolidColor && alpha < 1f) {
+            SolidColor(brush.value.copy(alpha = brush.value.alpha * alpha))
+        } else {
+            brush
+        }
+
+    // Draw the dot with scaled down radius and alpha color.
+    drawCircle(brush = brushWithAlpha, radius = scaledDotRadius, center = center)
 }
+
+/** Progress animation spec for [LinearProgressIndicator] */
+internal val linearProgressAnimationSpec: AnimationSpec<Float>
+    @Composable get() = MaterialTheme.motionScheme.defaultEffectsSpec()
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/CardTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/CardTokens.kt
index 21233ee..9e6fb7e 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/CardTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/CardTokens.kt
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-// VERSION: v0_100
+// VERSION: v0_103
 // GENERATED CODE - DO NOT MODIFY BY HAND
 
 package androidx.wear.compose.material3.tokens
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/CheckboxButtonTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/CheckboxButtonTokens.kt
index f04d8b7..7242277 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/CheckboxButtonTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/CheckboxButtonTokens.kt
@@ -14,13 +14,13 @@
  * limitations under the License.
  */
 
-// VERSION: v0_100
+// VERSION: v0_103
 // GENERATED CODE - DO NOT MODIFY BY HAND
 
 package androidx.wear.compose.material3.tokens
 internal object CheckboxButtonTokens {
     val CheckedBoxColor = ColorSchemeKeyTokens.Primary
-    val CheckedCheckmarkColor = ColorSchemeKeyTokens.OnPrimary
+    val CheckedCheckmarkColor = ColorSchemeKeyTokens.PrimaryContainer
     val CheckedContainerColor = ColorSchemeKeyTokens.PrimaryContainer
     val CheckedContentColor = ColorSchemeKeyTokens.OnPrimaryContainer
     val CheckedIconColor = ColorSchemeKeyTokens.Primary
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/ChildButtonTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/ChildButtonTokens.kt
index 5b31022..89f2076 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/ChildButtonTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/ChildButtonTokens.kt
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-// VERSION: v0_100
+// VERSION: v0_103
 // GENERATED CODE - DO NOT MODIFY BY HAND
 
 package androidx.wear.compose.material3.tokens
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/ColorSchemeKeyTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/ColorSchemeKeyTokens.kt
index 3ab4aad..1bf715d 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/ColorSchemeKeyTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/ColorSchemeKeyTokens.kt
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-// VERSION: v0_100
+// VERSION: v0_103
 // GENERATED CODE - DO NOT MODIFY BY HAND
 
 package androidx.wear.compose.material3.tokens
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/ColorTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/ColorTokens.kt
index 6a241e1..fc55742 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/ColorTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/ColorTokens.kt
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-// VERSION: v0_100
+// VERSION: v0_103
 // GENERATED CODE - DO NOT MODIFY BY HAND
 
 package androidx.wear.compose.material3.tokens
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/CompactButtonTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/CompactButtonTokens.kt
index 773d0c0..cd254fe 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/CompactButtonTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/CompactButtonTokens.kt
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-// VERSION: v0_100
+// VERSION: v0_103
 // GENERATED CODE - DO NOT MODIFY BY HAND
 
 package androidx.wear.compose.material3.tokens
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/DatePickerTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/DatePickerTokens.kt
index ddb27ca..abfd822 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/DatePickerTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/DatePickerTokens.kt
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-// VERSION: v0_100
+// VERSION: v0_103
 // GENERATED CODE - DO NOT MODIFY BY HAND
 
 package androidx.wear.compose.material3.tokens
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/FilledButtonTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/FilledButtonTokens.kt
index b79f7de..f7e31cc 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/FilledButtonTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/FilledButtonTokens.kt
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-// VERSION: v0_100
+// VERSION: v0_103
 // GENERATED CODE - DO NOT MODIFY BY HAND
 
 package androidx.wear.compose.material3.tokens
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/FilledIconButtonTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/FilledIconButtonTokens.kt
index e9a1f12..a786e29 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/FilledIconButtonTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/FilledIconButtonTokens.kt
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-// VERSION: v0_100
+// VERSION: v0_103
 // GENERATED CODE - DO NOT MODIFY BY HAND
 
 package androidx.wear.compose.material3.tokens
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/FilledTextButtonTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/FilledTextButtonTokens.kt
index 02386f9..2279987 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/FilledTextButtonTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/FilledTextButtonTokens.kt
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-// VERSION: v0_100
+// VERSION: v0_103
 // GENERATED CODE - DO NOT MODIFY BY HAND
 
 package androidx.wear.compose.material3.tokens
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/FilledTonalButtonTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/FilledTonalButtonTokens.kt
index 7ada2ff..ca9db63 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/FilledTonalButtonTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/FilledTonalButtonTokens.kt
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-// VERSION: v0_100
+// VERSION: v0_103
 // GENERATED CODE - DO NOT MODIFY BY HAND
 
 package androidx.wear.compose.material3.tokens
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/FilledTonalIconButtonTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/FilledTonalIconButtonTokens.kt
index 8b2e912..b631443 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/FilledTonalIconButtonTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/FilledTonalIconButtonTokens.kt
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-// VERSION: v0_100
+// VERSION: v0_103
 // GENERATED CODE - DO NOT MODIFY BY HAND
 
 package androidx.wear.compose.material3.tokens
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/FilledTonalTextButtonTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/FilledTonalTextButtonTokens.kt
index a53e857..fe44c99 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/FilledTonalTextButtonTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/FilledTonalTextButtonTokens.kt
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-// VERSION: v0_100
+// VERSION: v0_103
 // GENERATED CODE - DO NOT MODIFY BY HAND
 
 package androidx.wear.compose.material3.tokens
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/IconButtonTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/IconButtonTokens.kt
index 01a974d..97f2a8d 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/IconButtonTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/IconButtonTokens.kt
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-// VERSION: v0_100
+// VERSION: v0_103
 // GENERATED CODE - DO NOT MODIFY BY HAND
 
 package androidx.wear.compose.material3.tokens
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/IconToggleButtonTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/IconToggleButtonTokens.kt
index 2e722e6..7104056 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/IconToggleButtonTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/IconToggleButtonTokens.kt
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-// VERSION: v0_100
+// VERSION: v0_103
 // GENERATED CODE - DO NOT MODIFY BY HAND
 
 package androidx.wear.compose.material3.tokens
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/ImageButtonTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/ImageButtonTokens.kt
index 4fe245a..5bfdf2e 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/ImageButtonTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/ImageButtonTokens.kt
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-// VERSION: v0_100
+// VERSION: v0_103
 // GENERATED CODE - DO NOT MODIFY BY HAND
 
 package androidx.wear.compose.material3.tokens
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/ImageCardTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/ImageCardTokens.kt
index c86f5a7..31cc500 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/ImageCardTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/ImageCardTokens.kt
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-// VERSION: v0_100
+// VERSION: v0_103
 // GENERATED CODE - DO NOT MODIFY BY HAND
 
 package androidx.wear.compose.material3.tokens
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/ListHeaderTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/ListHeaderTokens.kt
index 2997f123..df3bd7c 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/ListHeaderTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/ListHeaderTokens.kt
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-// VERSION: v0_100
+// VERSION: v0_103
 // GENERATED CODE - DO NOT MODIFY BY HAND
 
 package androidx.wear.compose.material3.tokens
@@ -22,7 +22,7 @@
 import androidx.compose.ui.unit.dp
 
 internal object ListHeaderTokens {
-    val ContentColor = ColorSchemeKeyTokens.OnSurfaceVariant
+    val ContentColor = ColorSchemeKeyTokens.OnBackground
     val ContentTypography = TypographyKeyTokens.TitleMedium
     val Height = 48.0.dp
 }
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/ListSubHeaderTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/ListSubHeaderTokens.kt
index d412653..f2c2ba6 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/ListSubHeaderTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/ListSubHeaderTokens.kt
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-// VERSION: v0_100
+// VERSION: v0_103
 // GENERATED CODE - DO NOT MODIFY BY HAND
 
 package androidx.wear.compose.material3.tokens
@@ -22,7 +22,7 @@
 import androidx.compose.ui.unit.dp
 
 internal object ListSubHeaderTokens {
-    val ContentColor = ColorSchemeKeyTokens.OnBackground
-    val ContentTypography = TypographyKeyTokens.TitleMedium
+    val ContentColor = ColorSchemeKeyTokens.OnSurface
+    val ContentTypography = TypographyKeyTokens.TitleSmall
     val Height = 48.0.dp
 }
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/OutlinedButtonTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/OutlinedButtonTokens.kt
index 5fefd10..150ed56 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/OutlinedButtonTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/OutlinedButtonTokens.kt
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-// VERSION: v0_100
+// VERSION: v0_103
 // GENERATED CODE - DO NOT MODIFY BY HAND
 
 package androidx.wear.compose.material3.tokens
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/OutlinedCardTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/OutlinedCardTokens.kt
index ee033d8..7129bd2 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/OutlinedCardTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/OutlinedCardTokens.kt
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-// VERSION: v0_100
+// VERSION: v0_103
 // GENERATED CODE - DO NOT MODIFY BY HAND
 
 package androidx.wear.compose.material3.tokens
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/OutlinedIconButtonTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/OutlinedIconButtonTokens.kt
index ce53ef2..b72d68c 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/OutlinedIconButtonTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/OutlinedIconButtonTokens.kt
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-// VERSION: v0_100
+// VERSION: v0_103
 // GENERATED CODE - DO NOT MODIFY BY HAND
 
 package androidx.wear.compose.material3.tokens
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/OutlinedTextButtonTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/OutlinedTextButtonTokens.kt
index fd6c03c..e63aa6c 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/OutlinedTextButtonTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/OutlinedTextButtonTokens.kt
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-// VERSION: v0_100
+// VERSION: v0_103
 // GENERATED CODE - DO NOT MODIFY BY HAND
 
 package androidx.wear.compose.material3.tokens
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/PaletteTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/PaletteTokens.kt
index d09bffb..00e2ee4 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/PaletteTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/PaletteTokens.kt
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-// VERSION: v0_100
+// VERSION: v0_103
 // GENERATED CODE - DO NOT MODIFY BY HAND
 
 package androidx.wear.compose.material3.tokens
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/RadioButtonTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/RadioButtonTokens.kt
index 84b2672..5d021cb 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/RadioButtonTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/RadioButtonTokens.kt
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-// VERSION: v0_100
+// VERSION: v0_103
 // GENERATED CODE - DO NOT MODIFY BY HAND
 
 package androidx.wear.compose.material3.tokens
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/ShapeKeyTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/ShapeKeyTokens.kt
index fafc112..f4736c7 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/ShapeKeyTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/ShapeKeyTokens.kt
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-// VERSION: v0_100
+// VERSION: v0_103
 // GENERATED CODE - DO NOT MODIFY BY HAND
 
 package androidx.wear.compose.material3.tokens
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/ShapeTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/ShapeTokens.kt
index c74138d..56de250 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/ShapeTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/ShapeTokens.kt
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-// VERSION: v0_100
+// VERSION: v0_103
 // GENERATED CODE - DO NOT MODIFY BY HAND
 
 package androidx.wear.compose.material3.tokens
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/SliderTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/SliderTokens.kt
index 6aea0f8..454ef3d 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/SliderTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/SliderTokens.kt
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-// VERSION: v0_100
+// VERSION: v0_103
 // GENERATED CODE - DO NOT MODIFY BY HAND
 
 package androidx.wear.compose.material3.tokens
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/SplitCheckboxButtonTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/SplitCheckboxButtonTokens.kt
index a9f9bf9..5266b86 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/SplitCheckboxButtonTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/SplitCheckboxButtonTokens.kt
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-// VERSION: v0_100
+// VERSION: v0_103
 // GENERATED CODE - DO NOT MODIFY BY HAND
 
 package androidx.wear.compose.material3.tokens
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/SplitRadioButtonTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/SplitRadioButtonTokens.kt
index 017aa10..15f0fb8 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/SplitRadioButtonTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/SplitRadioButtonTokens.kt
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-// VERSION: v0_100
+// VERSION: v0_103
 // GENERATED CODE - DO NOT MODIFY BY HAND
 
 package androidx.wear.compose.material3.tokens
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/SplitSwitchButtonTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/SplitSwitchButtonTokens.kt
index 3e4ce32..8d2f206 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/SplitSwitchButtonTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/SplitSwitchButtonTokens.kt
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-// VERSION: v0_100
+// VERSION: v0_103
 // GENERATED CODE - DO NOT MODIFY BY HAND
 
 package androidx.wear.compose.material3.tokens
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/SwipeToRevealTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/SwipeToRevealTokens.kt
index 2326886..f5419d2 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/SwipeToRevealTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/SwipeToRevealTokens.kt
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-// VERSION: v0_100
+// VERSION: v0_103
 // GENERATED CODE - DO NOT MODIFY BY HAND
 
 package androidx.wear.compose.material3.tokens
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/SwitchButtonTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/SwitchButtonTokens.kt
index 516998c..03744de 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/SwitchButtonTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/SwitchButtonTokens.kt
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-// VERSION: v0_100
+// VERSION: v0_103
 // GENERATED CODE - DO NOT MODIFY BY HAND
 
 package androidx.wear.compose.material3.tokens
@@ -24,7 +24,7 @@
     val CheckedIconColor = ColorSchemeKeyTokens.Primary
     val CheckedSecondaryLabelColor = ColorSchemeKeyTokens.OnPrimaryContainer
     val CheckedSecondaryLabelOpacity = 0.9f
-    val CheckedThumbColor = ColorSchemeKeyTokens.OnPrimary
+    val CheckedThumbColor = ColorSchemeKeyTokens.PrimaryContainer
     val CheckedThumbIconColor = ColorSchemeKeyTokens.Primary
     val CheckedTrackBorderColor = ColorSchemeKeyTokens.Primary
     val CheckedTrackColor = ColorSchemeKeyTokens.Primary
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/TextButtonTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/TextButtonTokens.kt
index 76ebdb2..2fe783f 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/TextButtonTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/TextButtonTokens.kt
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-// VERSION: v0_100
+// VERSION: v0_103
 // GENERATED CODE - DO NOT MODIFY BY HAND
 
 package androidx.wear.compose.material3.tokens
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/TextToggleButtonTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/TextToggleButtonTokens.kt
index c9fd23a..e322ac7 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/TextToggleButtonTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/TextToggleButtonTokens.kt
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-// VERSION: v0_100
+// VERSION: v0_103
 // GENERATED CODE - DO NOT MODIFY BY HAND
 
 package androidx.wear.compose.material3.tokens
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/TimePickerTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/TimePickerTokens.kt
index 9a81220..352dc79 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/TimePickerTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/TimePickerTokens.kt
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-// VERSION: v0_100
+// VERSION: v0_103
 // GENERATED CODE - DO NOT MODIFY BY HAND
 
 package androidx.wear.compose.material3.tokens
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/TypeScaleTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/TypeScaleTokens.kt
index 0c221f7..c23d721 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/TypeScaleTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/TypeScaleTokens.kt
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-// VERSION: v0_100
+// VERSION: v0_103
 // GENERATED CODE - DO NOT MODIFY BY HAND
 
 package androidx.wear.compose.material3.tokens
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/TypefaceTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/TypefaceTokens.kt
index 75e8112..260235c 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/TypefaceTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/TypefaceTokens.kt
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-// VERSION: v0_100
+// VERSION: v0_103
 // GENERATED CODE - DO NOT MODIFY BY HAND
 
 package androidx.wear.compose.material3.tokens
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/TypographyKeyTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/TypographyKeyTokens.kt
index 88a0830..531e688 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/TypographyKeyTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/TypographyKeyTokens.kt
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-// VERSION: v0_100
+// VERSION: v0_103
 // GENERATED CODE - DO NOT MODIFY BY HAND
 
 package androidx.wear.compose.material3.tokens
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/TypographyTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/TypographyTokens.kt
index edff1b9..f9626c2 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/TypographyTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/TypographyTokens.kt
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-// VERSION: v0_100
+// VERSION: v0_103
 // GENERATED CODE - DO NOT MODIFY BY HAND
 
 package androidx.wear.compose.material3.tokens
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/TypographyVariableFontsTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/TypographyVariableFontsTokens.kt
index cf23b2c..9f13848 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/TypographyVariableFontsTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/TypographyVariableFontsTokens.kt
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-// VERSION: v0_100
+// VERSION: v0_103
 // GENERATED CODE - DO NOT MODIFY BY HAND
 
 package androidx.wear.compose.material3.tokens
@@ -34,13 +34,13 @@
         )
     val ArcSmallVariationSettings =
         FontVariation.Settings(
-            FontVariation.Setting("wght", TypeScaleTokens.ArcSmallWeight),
             FontVariation.Setting("wdth", TypeScaleTokens.ArcSmallWidth),
+            FontVariation.Setting("wght", TypeScaleTokens.ArcSmallWeight),
         )
     val BodyExtraSmallVariationSettings =
         FontVariation.Settings(
-            FontVariation.Setting("wdth", TypeScaleTokens.BodyExtraSmallWidth),
             FontVariation.Setting("wght", TypeScaleTokens.BodyExtraSmallWeight),
+            FontVariation.Setting("wdth", TypeScaleTokens.BodyExtraSmallWidth),
         )
     val BodyLargeVariationSettings =
         FontVariation.Settings(
@@ -54,13 +54,13 @@
         )
     val BodySmallVariationSettings =
         FontVariation.Settings(
-            FontVariation.Setting("wdth", TypeScaleTokens.BodySmallWidth),
             FontVariation.Setting("wght", TypeScaleTokens.BodySmallWeight),
+            FontVariation.Setting("wdth", TypeScaleTokens.BodySmallWidth),
         )
     val DisplayLargeVariationSettings =
         FontVariation.Settings(
-            FontVariation.Setting("wdth", TypeScaleTokens.DisplayLargeWidth),
             FontVariation.Setting("wght", TypeScaleTokens.DisplayLargeWeight),
+            FontVariation.Setting("wdth", TypeScaleTokens.DisplayLargeWidth),
         )
     val DisplayMediumVariationSettings =
         FontVariation.Settings(
@@ -69,8 +69,8 @@
         )
     val DisplaySmallVariationSettings =
         FontVariation.Settings(
-            FontVariation.Setting("wdth", TypeScaleTokens.DisplaySmallWidth),
             FontVariation.Setting("wght", TypeScaleTokens.DisplaySmallWeight),
+            FontVariation.Setting("wdth", TypeScaleTokens.DisplaySmallWidth),
         )
     val LabelLargeVariationSettings =
         FontVariation.Settings(
@@ -79,13 +79,13 @@
         )
     val LabelMediumVariationSettings =
         FontVariation.Settings(
-            FontVariation.Setting("wght", TypeScaleTokens.LabelMediumWeight),
             FontVariation.Setting("wdth", TypeScaleTokens.LabelMediumWidth),
+            FontVariation.Setting("wght", TypeScaleTokens.LabelMediumWeight),
         )
     val LabelSmallVariationSettings =
         FontVariation.Settings(
-            FontVariation.Setting("wdth", TypeScaleTokens.LabelSmallWidth),
             FontVariation.Setting("wght", TypeScaleTokens.LabelSmallWeight),
+            FontVariation.Setting("wdth", TypeScaleTokens.LabelSmallWidth),
         )
     val NumeralExtraLargeVariationSettings =
         FontVariation.Settings(
@@ -99,13 +99,13 @@
         )
     val NumeralLargeVariationSettings =
         FontVariation.Settings(
-            FontVariation.Setting("wght", TypeScaleTokens.NumeralLargeWeight),
             FontVariation.Setting("wdth", TypeScaleTokens.NumeralLargeWidth),
+            FontVariation.Setting("wght", TypeScaleTokens.NumeralLargeWeight),
         )
     val NumeralMediumVariationSettings =
         FontVariation.Settings(
-            FontVariation.Setting("wght", TypeScaleTokens.NumeralMediumWeight),
             FontVariation.Setting("wdth", TypeScaleTokens.NumeralMediumWidth),
+            FontVariation.Setting("wght", TypeScaleTokens.NumeralMediumWeight),
         )
     val NumeralSmallVariationSettings =
         FontVariation.Settings(
@@ -119,12 +119,12 @@
         )
     val TitleMediumVariationSettings =
         FontVariation.Settings(
-            FontVariation.Setting("wght", TypeScaleTokens.TitleMediumWeight),
             FontVariation.Setting("wdth", TypeScaleTokens.TitleMediumWidth),
+            FontVariation.Setting("wght", TypeScaleTokens.TitleMediumWeight),
         )
     val TitleSmallVariationSettings =
         FontVariation.Settings(
-            FontVariation.Setting("wght", TypeScaleTokens.TitleSmallWeight),
             FontVariation.Setting("wdth", TypeScaleTokens.TitleSmallWidth),
+            FontVariation.Setting("wght", TypeScaleTokens.TitleSmallWeight),
         )
 }
diff --git a/webkit/OWNERS b/webkit/OWNERS
index 93f399a..25b44cd 100644
--- a/webkit/OWNERS
+++ b/webkit/OWNERS
@@ -2,5 +2,4 @@
 [email protected]
 [email protected]
 [email protected]
[email protected]
 [email protected]
diff --git a/webkit/webkit/src/main/java/androidx/webkit/WebViewFeature.java b/webkit/webkit/src/main/java/androidx/webkit/WebViewFeature.java
index 4282944..9243ab1 100644
--- a/webkit/webkit/src/main/java/androidx/webkit/WebViewFeature.java
+++ b/webkit/webkit/src/main/java/androidx/webkit/WebViewFeature.java
@@ -109,6 +109,8 @@
             MUTE_AUDIO,
             PROFILE_URL_PREFETCH,
             WEB_AUTHENTICATION,
+            SPECULATIVE_LOADING,
+            BACK_FORWARD_CACHE,
     })
     @Retention(RetentionPolicy.SOURCE)
     @Target({ElementType.PARAMETER, ElementType.METHOD})
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 6e55ac3..18f87d9 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
@@ -437,6 +437,7 @@
             )
         return OneTimeWorkRequest.Builder(RemoteWorker::class.java)
             .setInputData(data)
+            .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
             .setConstraints(Constraints(requiredNetworkType = NetworkType.CONNECTED))
             .build()
     }
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 48a5429..bc312d9 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
@@ -30,8 +30,8 @@
 import androidx.core.app.NotificationCompat
 import androidx.work.Data
 import androidx.work.ForegroundInfo
+import androidx.work.ListenableWorker
 import androidx.work.WorkerParameters
-import androidx.work.multiprocess.RemoteListenableWorker
 import androidx.work.workDataOf
 import com.google.common.util.concurrent.ListenableFuture
 import kotlinx.coroutines.CoroutineScope
@@ -41,7 +41,7 @@
 import kotlinx.coroutines.launch
 
 class RemoteWorker(private val context: Context, private val parameters: WorkerParameters) :
-    RemoteListenableWorker(context, parameters) {
+    ListenableWorker(context, parameters) {
 
     private val notificationManager =
         context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
@@ -49,7 +49,7 @@
     private var job: Job? = null
     private var progress: Data = Data.EMPTY
 
-    override fun startRemoteWork(): ListenableFuture<Result> {
+    override fun startWork(): ListenableFuture<Result> {
         return CallbackToFutureAdapter.getFuture { completer ->
             Log.d(TAG, "Starting Remote Worker.")
             if (Looper.getMainLooper().thread != Thread.currentThread()) {
@@ -80,6 +80,14 @@
         job?.cancel()
     }
 
+    override fun getForegroundInfoAsync(): ListenableFuture<ForegroundInfo> {
+        return CallbackToFutureAdapter.getFuture { completer ->
+            val scope = CoroutineScope(Dispatchers.Default)
+            scope.launch { completer.set(getForegroundInfo(NotificationId)) }
+            return@getFuture "getForegroundInfoAsync"
+        }
+    }
+
     private fun getForegroundInfo(notificationId: Int): ForegroundInfo {
         val percent = progress.getInt(Progress, 0)
         val id = applicationContext.getString(R.string.channel_id)
diff --git a/work/work-multiprocess/src/androidTest/java/androidx/work/multiprocess/ParcelableForegroundInfoTest.kt b/work/work-multiprocess/src/androidTest/java/androidx/work/multiprocess/ParcelableForegroundInfoTest.kt
new file mode 100644
index 0000000..c435456
--- /dev/null
+++ b/work/work-multiprocess/src/androidTest/java/androidx/work/multiprocess/ParcelableForegroundInfoTest.kt
@@ -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.work.multiprocess
+
+import android.app.Notification
+import android.content.Context
+import android.content.pm.ServiceInfo
+import androidx.core.app.NotificationCompat
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SdkSuppress
+import androidx.test.filters.SmallTest
+import androidx.work.ForegroundInfo
+import androidx.work.multiprocess.parcelable.ParcelConverters
+import androidx.work.multiprocess.parcelable.ParcelableForegroundInfo
+import com.google.common.truth.Truth.assertThat
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+@SdkSuppress(minSdkVersion = 27)
+class ParcelableForegroundInfoTest {
+
+    lateinit var context: Context
+
+    @Before
+    public fun setUp() {
+        context = ApplicationProvider.getApplicationContext()
+    }
+
+    @Test
+    @SmallTest
+    public fun converterTest1() {
+        val channelId = "channelId"
+        val notificationId = 10
+        val title = "Some title"
+        val description = "Some description"
+        val notification =
+            NotificationCompat.Builder(context, channelId)
+                .setContentTitle(title)
+                .setContentText(description)
+                .setTicker(title)
+                .setSmallIcon(android.R.drawable.ic_dialog_info)
+                .build()
+
+        val foregroundInfo =
+            ForegroundInfo(
+                notificationId,
+                notification,
+                ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
+            )
+
+        assertOn(foregroundInfo)
+    }
+
+    private fun assertOn(foregroundInfo: ForegroundInfo) {
+        val parcelable = ParcelableForegroundInfo(foregroundInfo)
+        val parcelled: ParcelableForegroundInfo =
+            ParcelConverters.unmarshall(
+                ParcelConverters.marshall(parcelable),
+                ParcelableForegroundInfo.CREATOR
+            )
+        equal(foregroundInfo, parcelled.foregroundInfo)
+    }
+
+    private fun equal(first: ForegroundInfo, second: ForegroundInfo) {
+        assertThat(first.notificationId).isEqualTo(second.notificationId)
+        assertThat(first.foregroundServiceType).isEqualTo(second.foregroundServiceType)
+        equal(first.notification, second.notification)
+    }
+
+    private fun equal(first: Notification, second: Notification) {
+        assertThat(first.channelId).isEqualTo(second.channelId)
+        assertThat(first.tickerText).isEqualTo(second.tickerText)
+    }
+}
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 1f1b8e2..36eeb32 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
@@ -23,9 +23,9 @@
 import android.content.Context;
 
 import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RestrictTo;
 import androidx.work.Configuration;
+import androidx.work.ForegroundInfo;
 import androidx.work.ForegroundUpdater;
 import androidx.work.ListenableWorker;
 import androidx.work.Logger;
@@ -33,6 +33,7 @@
 import androidx.work.WorkerParameters;
 import androidx.work.impl.utils.taskexecutor.TaskExecutor;
 import androidx.work.multiprocess.parcelable.ParcelConverters;
+import androidx.work.multiprocess.parcelable.ParcelableForegroundInfo;
 import androidx.work.multiprocess.parcelable.ParcelableInterruptRequest;
 import androidx.work.multiprocess.parcelable.ParcelableRemoteWorkRequest;
 import androidx.work.multiprocess.parcelable.ParcelableResult;
@@ -40,6 +41,8 @@
 
 import com.google.common.util.concurrent.ListenableFuture;
 
+import java.util.HashMap;
+import java.util.Map;
 import java.util.concurrent.CancellationException;
 import java.util.concurrent.ExecutionException;
 
@@ -49,7 +52,7 @@
 @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
 public class ListenableWorkerImpl extends IListenableWorkerImpl.Stub {
     // Synthetic access
-    static final String TAG = Logger.tagWithPrefix("WM-RemoteWorker ListenableWorkerImpl");
+    static final String TAG = Logger.tagWithPrefix("ListenableWorkerImpl");
     // Synthetic access
     static byte[] sEMPTY = new byte[0];
     // Synthetic access
@@ -65,15 +68,10 @@
     final ProgressUpdater mProgressUpdater;
     // Synthetic access
     final ForegroundUpdater mForegroundUpdater;
-
-    // Additional state to keep track of, when creating underlying instances of ListenableWorkers.
-    // If the instance of ListenableWorker is null, then the corresponding throwable will always be
-    // non-null.
-    @Nullable
-    ListenableWorker mWorker;
-
-    @Nullable
-    Throwable mThrowable;
+    // Synthetic access
+    final Map<String, ListenableWorker> mListenableWorkerMap;
+    // Synthetic access
+    final Map<String, Throwable> mThrowableMap;
 
     ListenableWorkerImpl(@NonNull Context context) {
         mContext = context.getApplicationContext();
@@ -82,6 +80,12 @@
         mTaskExecutor = remoteInfo.getTaskExecutor();
         mProgressUpdater = remoteInfo.getProgressUpdater();
         mForegroundUpdater = remoteInfo.getForegroundUpdater();
+        // We need to track the actual workers and exceptions when creating instances of workers
+        // using the WorkerFactory. The service is longer lived than the workers, and therefore
+        // needs to be cognizant of attributing state to the right workerClassName. The keys
+        // to both the maps are the unique work request ids.
+        mListenableWorkerMap = new HashMap<>();
+        mThrowableMap = new HashMap<>();
     }
 
     @Override
@@ -125,6 +129,11 @@
                     } catch (CancellationException cancellationException) {
                         Logger.get().debug(TAG, "Worker (" + id + ") was cancelled");
                         reportFailure(callback, cancellationException);
+                    } finally {
+                        synchronized (sLock) {
+                            mListenableWorkerMap.remove(id);
+                            mThrowableMap.remove(id);
+                        }
                     }
                 }
             }, mTaskExecutor.getSerialTaskExecutor());
@@ -143,11 +152,13 @@
             final String id = interruptRequest.getId();
             final int stopReason = interruptRequest.getStopReason();
             Logger.get().debug(TAG, "Interrupting work with id (" + id + ")");
-
-            if (mWorker != null) {
+            // No need to remove the ListenableWorker from the map here, given after interruption
+            // the future gets notified and the cleanup happens automatically.
+            final ListenableWorker worker = mListenableWorkerMap.get(id);
+            if (worker != null) {
                 mTaskExecutor.getSerialTaskExecutor()
                         .execute(() -> {
-                            mWorker.stop(stopReason);
+                            worker.stop(stopReason);
                             reportSuccess(callback, sEMPTY);
                         });
             } else {
@@ -159,21 +170,110 @@
         }
     }
 
+    @Override
+    public void getForegroundInfoAsync(
+            @NonNull final byte[] request,
+            @NonNull final IWorkManagerImplCallback callback) {
+        try {
+            ParcelableRemoteWorkRequest parcelableRemoteWorkRequest =
+                    ParcelConverters.unmarshall(request, ParcelableRemoteWorkRequest.CREATOR);
+
+            ParcelableWorkerParameters parcelableWorkerParameters =
+                    parcelableRemoteWorkRequest.getParcelableWorkerParameters();
+
+            WorkerParameters workerParameters =
+                    parcelableWorkerParameters.toWorkerParameters(
+                            mConfiguration,
+                            mTaskExecutor,
+                            mProgressUpdater,
+                            mForegroundUpdater
+                    );
+
+            final String id = workerParameters.getId().toString();
+            final String workerClassName = parcelableRemoteWorkRequest.getWorkerClassName();
+
+            // Only instantiate the Worker if necessary.
+            createWorker(id, workerClassName, workerParameters);
+            ListenableWorker worker = mListenableWorkerMap.get(id);
+            Throwable throwable = mThrowableMap.get(id);
+
+            if (throwable != null) {
+                reportFailure(callback, throwable);
+            } else if (worker != null) {
+                mTaskExecutor.getSerialTaskExecutor().execute(new Runnable() {
+                    @Override
+                    public void run() {
+                        final ListenableFuture<ForegroundInfo> futureResult =
+                                worker.getForegroundInfoAsync();
+                        futureResult.addListener(new Runnable() {
+                            @Override
+                            public void run() {
+                                try {
+                                    ForegroundInfo foregroundInfo = futureResult.get();
+                                    ParcelableForegroundInfo parcelableForegroundInfo =
+                                            new ParcelableForegroundInfo(foregroundInfo);
+                                    byte[] response = ParcelConverters.marshall(
+                                            parcelableForegroundInfo
+                                    );
+                                    reportSuccess(callback, response);
+                                } catch (Throwable throwable) {
+                                    reportFailure(callback, throwable);
+                                }
+                            }
+                        }, mTaskExecutor.getSerialTaskExecutor());
+                    }
+                });
+            } else {
+                reportFailure(callback, new IllegalStateException("Should never happen."));
+            }
+        } catch (Throwable throwable) {
+            reportFailure(callback, throwable);
+        }
+    }
+
     @NonNull
     private ListenableFuture<ListenableWorker.Result> executeWorkRequest(
             @NonNull String workerClassName,
             @NonNull WorkerParameters workerParameters) {
 
-        try {
-            mWorker = mConfiguration.getWorkerFactory().createWorkerWithDefaultFallback(
-                    mContext, workerClassName, workerParameters
-            );
-        } catch (Throwable throwable) {
-            mThrowable = throwable;
-        }
+        String id = workerParameters.getId().toString();
+        // Only instantiate the Worker if necessary.
+        createWorker(id, workerClassName, workerParameters);
+
+        ListenableWorker worker = mListenableWorkerMap.get(id);
+        Throwable throwable = mThrowableMap.get(id);
+
         return executeRemoteWorker(
-                mConfiguration, workerClassName, workerParameters, mWorker, mThrowable,
-                mTaskExecutor
-        );
+                mConfiguration, workerClassName, workerParameters, worker, throwable,
+                mTaskExecutor);
+    }
+
+    private void createWorker(
+            @NonNull String id,
+            @NonNull String workerClassName,
+            @NonNull WorkerParameters workerParameters) {
+
+        // Use the id to keep track of the underlying instances. This is because the same worker
+        // could be concurrently being executed with a different set of inputs.
+        ListenableWorker worker = mListenableWorkerMap.get(id);
+        Throwable throwable = mThrowableMap.get(id);
+        // Check before we acquire a lock here, to make things as cheap as possible.
+        if (worker == null && throwable == null) {
+            synchronized (sLock) {
+                worker = mListenableWorkerMap.get(id);
+                throwable = mThrowableMap.get(id);
+                if (worker == null && throwable == null) {
+                    try {
+                        worker = mConfiguration.getWorkerFactory()
+                                .createWorkerWithDefaultFallback(
+                                        mContext, workerClassName, workerParameters
+                                );
+                        mListenableWorkerMap.put(id, worker);
+                    } catch (Throwable workerThrowable) {
+                        mThrowableMap.put(id, workerThrowable);
+                    }
+                }
+            }
+        }
     }
 }
diff --git a/work/work-multiprocess/src/main/java/androidx/work/multiprocess/RemoteListenableDelegatingWorker.kt b/work/work-multiprocess/src/main/java/androidx/work/multiprocess/RemoteListenableDelegatingWorker.kt
index b1269e2..7fa32d6 100644
--- a/work/work-multiprocess/src/main/java/androidx/work/multiprocess/RemoteListenableDelegatingWorker.kt
+++ b/work/work-multiprocess/src/main/java/androidx/work/multiprocess/RemoteListenableDelegatingWorker.kt
@@ -21,6 +21,7 @@
 import android.content.Context
 import androidx.annotation.RestrictTo
 import androidx.concurrent.futures.SuspendToFutureAdapter.launchFuture
+import androidx.work.ForegroundInfo
 import androidx.work.ListenableWorker
 import androidx.work.Logger
 import androidx.work.WorkerParameters
@@ -29,6 +30,7 @@
 import androidx.work.multiprocess.RemoteListenableWorker.ARGUMENT_CLASS_NAME
 import androidx.work.multiprocess.RemoteListenableWorker.ARGUMENT_PACKAGE_NAME
 import androidx.work.multiprocess.parcelable.ParcelConverters
+import androidx.work.multiprocess.parcelable.ParcelableForegroundInfo
 import androidx.work.multiprocess.parcelable.ParcelableInterruptRequest
 import androidx.work.multiprocess.parcelable.ParcelableRemoteWorkRequest
 import androidx.work.multiprocess.parcelable.ParcelableResult
@@ -49,36 +51,43 @@
 
     @Suppress("AsyncSuffixFuture") // Implementing a ListenableWorker
     override fun startWork(): ListenableFuture<Result> {
-        val workManager = WorkManagerImpl.getInstance(context.applicationContext)
-        val dispatcher = workManager.workTaskExecutor.taskCoroutineDispatcher
-        return launchFuture(context = dispatcher) {
-            val servicePackageName = inputData.getString(ARGUMENT_PACKAGE_NAME)
-            val serviceClassName = inputData.getString(ARGUMENT_CLASS_NAME)
-            val workerClassName = inputData.getString(ARGUMENT_REMOTE_LISTENABLE_WORKER_NAME)
-            requireNotNull(servicePackageName) {
-                "Need to specify a package name for the Remote Service."
+        return executeRemote(
+            block = { iListenableWorkerImpl, callback ->
+                val workerClassName = inputData.getString(ARGUMENT_REMOTE_LISTENABLE_WORKER_NAME)
+                requireNotNull(workerClassName) {
+                    "Need to specify a class name for the RemoteListenableWorker to delegate to."
+                }
+                val remoteWorkRequest =
+                    ParcelableRemoteWorkRequest(workerClassName, workerParameters)
+                val requestPayload = ParcelConverters.marshall(remoteWorkRequest)
+                iListenableWorkerImpl.startWork(requestPayload, callback)
+            },
+            transformation = { response ->
+                val parcelableResult =
+                    ParcelConverters.unmarshall(response, ParcelableResult.CREATOR)
+                parcelableResult.result
             }
-            requireNotNull(serviceClassName) {
-                "Need to specify a class name for the Remote Service."
+        )
+    }
+
+    override fun getForegroundInfoAsync(): ListenableFuture<ForegroundInfo> {
+        return executeRemote(
+            block = { iListenableWorkerImpl, callback ->
+                val workerClassName = inputData.getString(ARGUMENT_REMOTE_LISTENABLE_WORKER_NAME)
+                requireNotNull(workerClassName) {
+                    "Need to specify a class name for the RemoteListenableWorker to delegate to."
+                }
+                val remoteWorkRequest =
+                    ParcelableRemoteWorkRequest(workerClassName, workerParameters)
+                val requestPayload = ParcelConverters.marshall(remoteWorkRequest)
+                iListenableWorkerImpl.getForegroundInfoAsync(requestPayload, callback)
+            },
+            transformation = { response ->
+                val parcelableResult =
+                    ParcelConverters.unmarshall(response, ParcelableForegroundInfo.CREATOR)
+                parcelableResult.foregroundInfo
             }
-            requireNotNull(workerClassName) {
-                "Need to specify a class name for the RemoteListenableWorker to delegate to."
-            }
-            componentName = ComponentName(servicePackageName, serviceClassName)
-            val response =
-                client
-                    .execute(componentName!!) { iListenableWorkerImpl, callback ->
-                        val remoteWorkRequest =
-                            ParcelableRemoteWorkRequest(workerClassName, workerParameters)
-                        val requestPayload = ParcelConverters.marshall(remoteWorkRequest)
-                        iListenableWorkerImpl.startWork(requestPayload, callback)
-                    }
-                    .awaitWithin(this@RemoteListenableDelegatingWorker)
-            val parcelableResult = ParcelConverters.unmarshall(response, ParcelableResult.CREATOR)
-            Logger.get().debug(TAG, "Cleaning up")
-            client.unbindService()
-            parcelableResult.result
-        }
+        )
     }
 
     @SuppressLint("NewApi") // stopReason is actually a safe method to call.
@@ -94,6 +103,38 @@
         }
     }
 
+    private inline fun <T> executeRemote(
+        crossinline block:
+            (
+                iListenableWorkerImpl: IListenableWorkerImpl, callback: IWorkManagerImplCallback
+            ) -> Unit,
+        crossinline transformation: (input: ByteArray) -> T
+    ): ListenableFuture<T> {
+        val workManager = WorkManagerImpl.getInstance(context.applicationContext)
+        val dispatcher = workManager.workTaskExecutor.taskCoroutineDispatcher
+        return launchFuture(context = dispatcher) {
+            val servicePackageName = inputData.getString(ARGUMENT_PACKAGE_NAME)
+            val serviceClassName = inputData.getString(ARGUMENT_CLASS_NAME)
+            requireNotNull(servicePackageName) {
+                "Need to specify a package name for the Remote Service."
+            }
+            requireNotNull(serviceClassName) {
+                "Need to specify a class name for the Remote Service."
+            }
+            componentName = ComponentName(servicePackageName, serviceClassName)
+            val response =
+                client
+                    .execute(componentName!!) { iListenableWorkerImpl, callback ->
+                        block(iListenableWorkerImpl, callback)
+                    }
+                    .awaitWithin(this@RemoteListenableDelegatingWorker)
+            val result = transformation(response)
+            Logger.get().debug(TAG, "Cleaning up")
+            client.unbindService()
+            result
+        }
+    }
+
     companion object {
         private const val TAG = "RemoteListenableDelegatingWorker"
 
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 6110d9a..949bb5ef 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
@@ -16,12 +16,15 @@
 
 package androidx.work.multiprocess.parcelable;
 
+import android.os.Build;
 import android.os.Parcel;
+import android.os.Parcelable;
 
 import androidx.annotation.NonNull;
 import androidx.annotation.RestrictTo;
 
 /**
+ *
  */
 @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
 public final class ParcelUtils {
@@ -43,4 +46,23 @@
     public static void writeBooleanValue(@NonNull Parcel parcel, boolean value) {
         parcel.writeInt(value ? 1 : 0);
     }
+
+    /**
+     * Reads a parcelable from a parcel.
+     * <p>
+     * It's safe for us to suppress warnings here, given the correct API is being used starting
+     * API 33.
+     */
+    @SuppressWarnings("deprecation")
+    public static <T extends Parcelable> T readParcelable(
+            @NonNull Parcel parcel,
+            @NonNull Class<T> klass
+    ) {
+        ClassLoader classLoader = klass.getClassLoader();
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+            return parcel.readParcelable(classLoader, klass);
+        } else {
+            return parcel.readParcelable(classLoader);
+        }
+    }
 }
diff --git a/work/work-multiprocess/src/main/java/androidx/work/multiprocess/parcelable/ParcelableForegroundInfo.kt b/work/work-multiprocess/src/main/java/androidx/work/multiprocess/parcelable/ParcelableForegroundInfo.kt
new file mode 100644
index 0000000..05ee7ae
--- /dev/null
+++ b/work/work-multiprocess/src/main/java/androidx/work/multiprocess/parcelable/ParcelableForegroundInfo.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.work.multiprocess.parcelable
+
+import android.annotation.SuppressLint
+import android.app.Notification
+import android.os.Parcel
+import android.os.Parcelable
+import androidx.annotation.RestrictTo
+import androidx.work.ForegroundInfo
+
+/** [androidx.work.ForegroundInfo] but [android.os.Parcelable]. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+@SuppressLint("BanParcelableUsage")
+data class ParcelableForegroundInfo(val foregroundInfo: ForegroundInfo) : Parcelable {
+
+    constructor(
+        parcel: Parcel
+    ) : this(
+        foregroundInfo =
+            ForegroundInfo(
+                parcel.readInt(),
+                ParcelUtils.readParcelable(parcel, Notification::class.java),
+                parcel.readInt()
+            )
+    )
+
+    override fun describeContents(): Int {
+        return 0
+    }
+
+    override fun writeToParcel(parcel: Parcel, flags: Int) {
+        parcel.writeInt(foregroundInfo.notificationId)
+        parcel.writeParcelable(foregroundInfo.notification, flags)
+        parcel.writeInt(foregroundInfo.foregroundServiceType)
+    }
+
+    companion object {
+        @JvmField
+        val CREATOR: Parcelable.Creator<ParcelableForegroundInfo> =
+            object : Parcelable.Creator<ParcelableForegroundInfo> {
+                override fun createFromParcel(parcel: Parcel): ParcelableForegroundInfo {
+                    return ParcelableForegroundInfo(parcel)
+                }
+
+                override fun newArray(size: Int): Array<ParcelableForegroundInfo?> {
+                    return arrayOfNulls(size)
+                }
+            }
+    }
+}
diff --git a/work/work-runtime/api/aidlRelease/current/androidx/work/multiprocess/IListenableWorkerImpl.aidl b/work/work-runtime/api/aidlRelease/current/androidx/work/multiprocess/IListenableWorkerImpl.aidl
index 9fa0b41..0b86802 100644
--- a/work/work-runtime/api/aidlRelease/current/androidx/work/multiprocess/IListenableWorkerImpl.aidl
+++ b/work/work-runtime/api/aidlRelease/current/androidx/work/multiprocess/IListenableWorkerImpl.aidl
@@ -36,4 +36,5 @@
 interface IListenableWorkerImpl {
   oneway void startWork(in byte[] request, androidx.work.multiprocess.IWorkManagerImplCallback callback);
   oneway void interrupt(in byte[] request, androidx.work.multiprocess.IWorkManagerImplCallback callback);
+  oneway void getForegroundInfoAsync(in byte[] request, androidx.work.multiprocess.IWorkManagerImplCallback callback);
 }
diff --git a/work/work-runtime/src/main/stableAidl/androidx/work/multiprocess/IListenableWorkerImpl.aidl b/work/work-runtime/src/main/stableAidl/androidx/work/multiprocess/IListenableWorkerImpl.aidl
index 7d798ed..3a75f7a5 100644
--- a/work/work-runtime/src/main/stableAidl/androidx/work/multiprocess/IListenableWorkerImpl.aidl
+++ b/work/work-runtime/src/main/stableAidl/androidx/work/multiprocess/IListenableWorkerImpl.aidl
@@ -29,6 +29,11 @@
 
    // interrupt request.
    // request is a ParcelableWorkerParameters instance.
-   // callback gets an empty result
+   // callback gets an empty result.
    oneway void interrupt(in byte[] request, IWorkManagerImplCallback callback);
+
+   // getForegroundInfoAsync request.
+   // request is a ParcelablelRemoteRequest instance.
+   // callback gets a parcelized representation of ForegroundInfo.
+   oneway void getForegroundInfoAsync(in byte[] request, IWorkManagerImplCallback callback);
 }