Merge "Add verbalized toString() method for CameraError" into androidx-main
diff --git a/appsearch/appsearch/api/current.txt b/appsearch/appsearch/api/current.txt
index c3c298f..29645b5 100644
--- a/appsearch/appsearch/api/current.txt
+++ b/appsearch/appsearch/api/current.txt
@@ -109,12 +109,35 @@
     method public byte[] getSha256Digest();
   }
 
+  @SuppressCompatibility @androidx.appsearch.app.ExperimentalAppSearchApi public final class AppSearchCommitBlobResponse {
+    ctor public AppSearchCommitBlobResponse(androidx.appsearch.app.AppSearchBatchResult<androidx.appsearch.app.AppSearchBlobHandle!,java.lang.Void!>);
+    method public androidx.appsearch.app.AppSearchBatchResult<androidx.appsearch.app.AppSearchBlobHandle!,java.lang.Void!> getResult();
+    method public void writeToParcel(android.os.Parcel, int);
+    field public static final android.os.Parcelable.Creator<androidx.appsearch.app.AppSearchCommitBlobResponse!> CREATOR;
+  }
+
   @AnyThread public abstract class AppSearchDocumentClassMap {
     ctor public AppSearchDocumentClassMap();
     method @WorkerThread public static java.util.Map<java.lang.String!,java.util.List<java.lang.String!>!> getGlobalMap();
     method protected abstract java.util.Map<java.lang.String!,java.util.List<java.lang.String!>!> getMap();
   }
 
+  @SuppressCompatibility @androidx.appsearch.app.ExperimentalAppSearchApi public final class AppSearchOpenBlobForReadResponse implements java.io.Closeable {
+    ctor public AppSearchOpenBlobForReadResponse(androidx.appsearch.app.AppSearchBatchResult<androidx.appsearch.app.AppSearchBlobHandle!,android.os.ParcelFileDescriptor!>);
+    method public void close();
+    method public androidx.appsearch.app.AppSearchBatchResult<androidx.appsearch.app.AppSearchBlobHandle!,android.os.ParcelFileDescriptor!> getResult();
+    method public void writeToParcel(android.os.Parcel, int);
+    field public static final android.os.Parcelable.Creator<androidx.appsearch.app.AppSearchOpenBlobForReadResponse!> CREATOR;
+  }
+
+  @SuppressCompatibility @androidx.appsearch.app.ExperimentalAppSearchApi public final class AppSearchOpenBlobForWriteResponse implements java.io.Closeable {
+    ctor public AppSearchOpenBlobForWriteResponse(androidx.appsearch.app.AppSearchBatchResult<androidx.appsearch.app.AppSearchBlobHandle!,android.os.ParcelFileDescriptor!>);
+    method public void close();
+    method public androidx.appsearch.app.AppSearchBatchResult<androidx.appsearch.app.AppSearchBlobHandle!,android.os.ParcelFileDescriptor!> getResult();
+    method public void writeToParcel(android.os.Parcel, int);
+    field public static final android.os.Parcelable.Creator<androidx.appsearch.app.AppSearchOpenBlobForWriteResponse!> CREATOR;
+  }
+
   public final class AppSearchResult<ValueType> {
     method public String? getErrorMessage();
     method public int getResultCode();
diff --git a/appsearch/appsearch/api/restricted_current.txt b/appsearch/appsearch/api/restricted_current.txt
index c3c298f..29645b5 100644
--- a/appsearch/appsearch/api/restricted_current.txt
+++ b/appsearch/appsearch/api/restricted_current.txt
@@ -109,12 +109,35 @@
     method public byte[] getSha256Digest();
   }
 
+  @SuppressCompatibility @androidx.appsearch.app.ExperimentalAppSearchApi public final class AppSearchCommitBlobResponse {
+    ctor public AppSearchCommitBlobResponse(androidx.appsearch.app.AppSearchBatchResult<androidx.appsearch.app.AppSearchBlobHandle!,java.lang.Void!>);
+    method public androidx.appsearch.app.AppSearchBatchResult<androidx.appsearch.app.AppSearchBlobHandle!,java.lang.Void!> getResult();
+    method public void writeToParcel(android.os.Parcel, int);
+    field public static final android.os.Parcelable.Creator<androidx.appsearch.app.AppSearchCommitBlobResponse!> CREATOR;
+  }
+
   @AnyThread public abstract class AppSearchDocumentClassMap {
     ctor public AppSearchDocumentClassMap();
     method @WorkerThread public static java.util.Map<java.lang.String!,java.util.List<java.lang.String!>!> getGlobalMap();
     method protected abstract java.util.Map<java.lang.String!,java.util.List<java.lang.String!>!> getMap();
   }
 
+  @SuppressCompatibility @androidx.appsearch.app.ExperimentalAppSearchApi public final class AppSearchOpenBlobForReadResponse implements java.io.Closeable {
+    ctor public AppSearchOpenBlobForReadResponse(androidx.appsearch.app.AppSearchBatchResult<androidx.appsearch.app.AppSearchBlobHandle!,android.os.ParcelFileDescriptor!>);
+    method public void close();
+    method public androidx.appsearch.app.AppSearchBatchResult<androidx.appsearch.app.AppSearchBlobHandle!,android.os.ParcelFileDescriptor!> getResult();
+    method public void writeToParcel(android.os.Parcel, int);
+    field public static final android.os.Parcelable.Creator<androidx.appsearch.app.AppSearchOpenBlobForReadResponse!> CREATOR;
+  }
+
+  @SuppressCompatibility @androidx.appsearch.app.ExperimentalAppSearchApi public final class AppSearchOpenBlobForWriteResponse implements java.io.Closeable {
+    ctor public AppSearchOpenBlobForWriteResponse(androidx.appsearch.app.AppSearchBatchResult<androidx.appsearch.app.AppSearchBlobHandle!,android.os.ParcelFileDescriptor!>);
+    method public void close();
+    method public androidx.appsearch.app.AppSearchBatchResult<androidx.appsearch.app.AppSearchBlobHandle!,android.os.ParcelFileDescriptor!> getResult();
+    method public void writeToParcel(android.os.Parcel, int);
+    field public static final android.os.Parcelable.Creator<androidx.appsearch.app.AppSearchOpenBlobForWriteResponse!> CREATOR;
+  }
+
   public final class AppSearchResult<ValueType> {
     method public String? getErrorMessage();
     method public int getResultCode();
diff --git a/appsearch/appsearch/src/androidTest/java/androidx/appsearch/app/aidl/AppSearchBatchResultGeneralKeyParcelTest.java b/appsearch/appsearch/src/androidTest/java/androidx/appsearch/app/aidl/AppSearchBatchResultGeneralKeyParcelTest.java
new file mode 100644
index 0000000..d2dd377
--- /dev/null
+++ b/appsearch/appsearch/src/androidTest/java/androidx/appsearch/app/aidl/AppSearchBatchResultGeneralKeyParcelTest.java
@@ -0,0 +1,95 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.appsearch.app.aidl;
+
+import static android.os.ParcelFileDescriptor.MODE_WRITE_ONLY;
+
+import static androidx.appsearch.testutil.AppSearchTestUtils.calculateDigest;
+import static androidx.appsearch.testutil.AppSearchTestUtils.generateRandomBytes;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.os.ParcelFileDescriptor;
+
+import androidx.appsearch.app.AppSearchBatchResult;
+import androidx.appsearch.app.AppSearchBlobHandle;
+import androidx.appsearch.app.AppSearchResult;
+
+import org.junit.Test;
+
+import java.io.File;
+
+public class AppSearchBatchResultGeneralKeyParcelTest {
+
+    @Test
+    public void testFromBlobHandleToPfd() throws Exception {
+        File file = File.createTempFile(/*prefix=*/"appsearch", /*suffix=*/null);
+        ParcelFileDescriptor pfd = ParcelFileDescriptor.open(file, MODE_WRITE_ONLY);
+        AppSearchResult<ParcelFileDescriptor> successResult =
+                AppSearchResult.newSuccessfulResult(pfd);
+        AppSearchResult<ParcelFileDescriptor> failureResult = AppSearchResult.newFailedResult(
+                AppSearchResult.RESULT_NOT_FOUND, "not found");
+        byte[] data1 = generateRandomBytes(10); // 10 Bytes
+        byte[] digest1 = calculateDigest(data1);
+        byte[] data2 = generateRandomBytes(10); // 10 Bytes
+        byte[] digest2 = calculateDigest(data2);
+        AppSearchBlobHandle blobHandle1 = AppSearchBlobHandle.createWithSha256(
+                digest1, "package1", "db1", "ns");
+        AppSearchBlobHandle blobHandle2 = AppSearchBlobHandle.createWithSha256(
+                digest2, "package1", "db1", "ns");
+        AppSearchBatchResult<AppSearchBlobHandle, ParcelFileDescriptor> result =
+                new AppSearchBatchResult.Builder<AppSearchBlobHandle, ParcelFileDescriptor>()
+                        .setResult(blobHandle1, successResult)
+                        .setResult(blobHandle2, failureResult)
+                        .build();
+        AppSearchBatchResultGeneralKeyParcel<AppSearchBlobHandle, ParcelFileDescriptor>
+                resultParcel = AppSearchBatchResultGeneralKeyParcel.fromBlobHandleToPfd(result);
+
+        AppSearchBatchResult<AppSearchBlobHandle, ParcelFileDescriptor> outResult =
+                resultParcel.getResult();
+
+        assertThat(outResult.getSuccesses()).containsExactly(blobHandle1, pfd);
+        assertThat(outResult.getFailures()).containsExactly(blobHandle2, failureResult);
+    }
+
+    @Test
+    public void testFromBlobHandleToVoid() throws Exception {
+        byte[] data1 = generateRandomBytes(10); // 10 Bytes
+        byte[] digest1 = calculateDigest(data1);
+        byte[] data2 = generateRandomBytes(10); // 10 Bytes
+        byte[] digest2 = calculateDigest(data2);
+        AppSearchBlobHandle blobHandle1 = AppSearchBlobHandle.createWithSha256(
+                digest1, "package1", "db1", "ns");
+        AppSearchBlobHandle blobHandle2 = AppSearchBlobHandle.createWithSha256(
+                digest2, "package1", "db1", "ns");
+        AppSearchResult<Void> successResult = AppSearchResult.newSuccessfulResult(null);
+        AppSearchResult<Void> failureResult = AppSearchResult.newFailedResult(
+                AppSearchResult.RESULT_NOT_FOUND, "not found");
+        AppSearchBatchResult<AppSearchBlobHandle, Void> result =
+                new AppSearchBatchResult.Builder<AppSearchBlobHandle, Void>()
+                        .setResult(blobHandle1, successResult)
+                        .setResult(blobHandle2, failureResult)
+                        .build();
+        AppSearchBatchResultGeneralKeyParcel<AppSearchBlobHandle, Void>
+                resultParcel = AppSearchBatchResultGeneralKeyParcel.fromBlobHandleToVoid(result);
+
+        AppSearchBatchResult<AppSearchBlobHandle, Void> outResult = resultParcel.getResult();
+
+        assertThat(outResult.getSuccesses()).containsExactly(blobHandle1, null);
+        assertThat(outResult.getFailures()).containsExactly(blobHandle2, failureResult);
+    }
+}
diff --git a/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/app/AppSearchCommitBlobResponseTest.java b/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/app/AppSearchCommitBlobResponseTest.java
new file mode 100644
index 0000000..4fc1370
--- /dev/null
+++ b/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/app/AppSearchCommitBlobResponseTest.java
@@ -0,0 +1,86 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.appsearch.cts.app;
+
+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.AppSearchBatchResult;
+import androidx.appsearch.app.AppSearchBlobHandle;
+import androidx.appsearch.app.AppSearchCommitBlobResponse;
+import androidx.appsearch.app.AppSearchResult;
+import androidx.appsearch.flags.CheckFlagsRule;
+import androidx.appsearch.flags.DeviceFlagsValueProvider;
+import androidx.appsearch.flags.Flags;
+import androidx.appsearch.flags.RequiresFlagsEnabled;
+
+import org.junit.Rule;
+import org.junit.Test;
+
+@RequiresFlagsEnabled(Flags.FLAG_ENABLE_BLOB_STORE)
+public class AppSearchCommitBlobResponseTest {
+    @Rule
+    public final CheckFlagsRule mCheckFlagsRule = DeviceFlagsValueProvider.createCheckFlagsRule();
+
+    @Test
+    public void testBuildAndGet() throws Exception {
+        byte[] data1 = generateRandomBytes(10); // 10 Bytes
+        byte[] digest1 = calculateDigest(data1);
+        byte[] data2 = generateRandomBytes(10); // 10 Bytes
+        byte[] digest2 = calculateDigest(data2);
+        byte[] data3 = generateRandomBytes(10); // 10 Bytes
+        byte[] digest3 = calculateDigest(data3);
+        byte[] data4 = generateRandomBytes(10); // 10 Bytes
+        byte[] digest4 = calculateDigest(data4);
+        AppSearchBlobHandle blobHandle1 = AppSearchBlobHandle.createWithSha256(
+                digest1, "package1", "db1", "ns");
+        AppSearchBlobHandle blobHandle2 = AppSearchBlobHandle.createWithSha256(
+                digest2, "package1", "db1", "ns");
+        AppSearchBlobHandle blobHandle3 = AppSearchBlobHandle.createWithSha256(
+                digest3, "package1", "db1", "ns");
+        AppSearchBlobHandle blobHandle4 = AppSearchBlobHandle.createWithSha256(
+                digest4, "package1", "db1", "ns");
+
+        AppSearchResult<Void> failureResult = AppSearchResult.newFailedResult(
+                AppSearchResult.RESULT_ALREADY_EXISTS, "already exists");
+        AppSearchResult<Void> successResult = AppSearchResult.newSuccessfulResult(null);
+
+        AppSearchBatchResult<AppSearchBlobHandle, Void> batchResult =
+                new AppSearchBatchResult.Builder<AppSearchBlobHandle, Void>()
+                        .setSuccess(blobHandle1, null)
+                        .setFailure(blobHandle2, AppSearchResult.RESULT_ALREADY_EXISTS,
+                                "already exists")
+                        .setResult(blobHandle3, successResult)
+                        .setResult(blobHandle4, failureResult)
+                        .build();
+
+        AppSearchCommitBlobResponse appSearchCommitBlobResponse =
+                new AppSearchCommitBlobResponse(batchResult);
+
+        AppSearchBatchResult<AppSearchBlobHandle, Void> outResult =
+                appSearchCommitBlobResponse.getResult();
+        assertThat(outResult.getSuccesses()).containsExactly(
+                blobHandle1, null, blobHandle3, null);
+        assertThat(outResult.getFailures()).containsExactly(
+                blobHandle2, failureResult, blobHandle4, failureResult);
+        assertThat(outResult.getAll()).containsExactly(
+                blobHandle1, successResult, blobHandle2, failureResult,
+                blobHandle3, successResult, blobHandle4, failureResult);
+    }
+}
diff --git a/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/app/AppSearchOpenBlobForReadResponseTest.java b/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/app/AppSearchOpenBlobForReadResponseTest.java
new file mode 100644
index 0000000..22daaca
--- /dev/null
+++ b/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/app/AppSearchOpenBlobForReadResponseTest.java
@@ -0,0 +1,130 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.appsearch.cts.app;
+
+import static android.os.ParcelFileDescriptor.MODE_WRITE_ONLY;
+
+import static androidx.appsearch.testutil.AppSearchTestUtils.calculateDigest;
+import static androidx.appsearch.testutil.AppSearchTestUtils.generateRandomBytes;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.junit.Assert.assertThrows;
+
+import android.os.ParcelFileDescriptor;
+
+import androidx.appsearch.app.AppSearchBatchResult;
+import androidx.appsearch.app.AppSearchBlobHandle;
+import androidx.appsearch.app.AppSearchOpenBlobForReadResponse;
+import androidx.appsearch.app.AppSearchResult;
+import androidx.appsearch.flags.CheckFlagsRule;
+import androidx.appsearch.flags.DeviceFlagsValueProvider;
+import androidx.appsearch.flags.Flags;
+import androidx.appsearch.flags.RequiresFlagsEnabled;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+
+import java.io.File;
+
+@RequiresFlagsEnabled(Flags.FLAG_ENABLE_BLOB_STORE)
+public class AppSearchOpenBlobForReadResponseTest {
+    @Rule
+    public final CheckFlagsRule mCheckFlagsRule = DeviceFlagsValueProvider.createCheckFlagsRule();
+
+    ParcelFileDescriptor mPfd;
+    private AppSearchResult<ParcelFileDescriptor> mSuccessResult;
+    private AppSearchResult<ParcelFileDescriptor> mFailureResult;
+
+    @Before
+    public void setUp() throws Exception {
+        File file = File.createTempFile(/*prefix=*/"appsearch", /*suffix=*/null);
+        mPfd = ParcelFileDescriptor.open(file, MODE_WRITE_ONLY);
+        mSuccessResult = AppSearchResult.newSuccessfulResult(mPfd);
+        mFailureResult = AppSearchResult.newFailedResult(
+                AppSearchResult.RESULT_ALREADY_EXISTS, "already exists");
+    }
+
+    @After
+    public void tearDown() throws Exception {
+        mPfd.close();
+    }
+
+    @Test
+    public void testBuildAndGet() throws Exception {
+        byte[] data1 = generateRandomBytes(10); // 10 Bytes
+        byte[] digest1 = calculateDigest(data1);
+        byte[] data2 = generateRandomBytes(10); // 10 Bytes
+        byte[] digest2 = calculateDigest(data2);
+        byte[] data3 = generateRandomBytes(10); // 10 Bytes
+        byte[] digest3 = calculateDigest(data3);
+        byte[] data4 = generateRandomBytes(10); // 10 Bytes
+        byte[] digest4 = calculateDigest(data4);
+        AppSearchBlobHandle blobHandle1 = AppSearchBlobHandle.createWithSha256(
+                digest1, "package1", "db1", "ns");
+        AppSearchBlobHandle blobHandle2 = AppSearchBlobHandle.createWithSha256(
+                digest2, "package1", "db1", "ns");
+        AppSearchBlobHandle blobHandle3 = AppSearchBlobHandle.createWithSha256(
+                digest3, "package1", "db1", "ns");
+        AppSearchBlobHandle blobHandle4 = AppSearchBlobHandle.createWithSha256(
+                digest4, "package1", "db1", "ns");
+
+        AppSearchBatchResult<AppSearchBlobHandle, ParcelFileDescriptor> batchResult =
+                new AppSearchBatchResult.Builder<AppSearchBlobHandle, ParcelFileDescriptor>()
+                        .setSuccess(blobHandle1, mPfd)
+                        .setFailure(blobHandle2, AppSearchResult.RESULT_ALREADY_EXISTS,
+                                "already exists")
+                        .setResult(blobHandle3, mSuccessResult)
+                        .setResult(blobHandle4, mFailureResult)
+                        .build();
+
+        try (AppSearchOpenBlobForReadResponse response =
+                new AppSearchOpenBlobForReadResponse(batchResult)) {
+
+            AppSearchBatchResult<AppSearchBlobHandle, ParcelFileDescriptor> outResult =
+                    response.getResult();
+            assertThat(outResult.getSuccesses()).containsExactly(
+                    blobHandle1, mPfd, blobHandle3, mPfd);
+            assertThat(outResult.getFailures()).containsExactly(
+                    blobHandle2, mFailureResult, blobHandle4, mFailureResult);
+            assertThat(outResult.getAll()).containsExactly(
+                    blobHandle1, mSuccessResult, blobHandle2, mFailureResult,
+                    blobHandle3, mSuccessResult, blobHandle4, mFailureResult);
+        }
+    }
+
+    @Test
+    public void testAccessPfdAfterClose() throws Exception {
+        byte[] data = generateRandomBytes(10); // 10 Bytes
+        byte[] digest = calculateDigest(data);
+        AppSearchBlobHandle blobHandle = AppSearchBlobHandle.createWithSha256(
+                digest, "package1", "db1", "ns");
+        AppSearchBatchResult<AppSearchBlobHandle, ParcelFileDescriptor> batchResult =
+                new AppSearchBatchResult.Builder<AppSearchBlobHandle, ParcelFileDescriptor>()
+                        .setResult(blobHandle, mSuccessResult)
+                        .build();
+        try (AppSearchOpenBlobForReadResponse ignored =
+                    new AppSearchOpenBlobForReadResponse(batchResult)) {
+            // Pfd is accessible now
+            mPfd.detachFd();
+        }
+        // Pfd is NOT accessible after close()
+        assertThrows(IllegalStateException.class, () -> mPfd.detachFd());
+    }
+}
diff --git a/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/app/AppSearchOpenBlobForWriteResponseTest.java b/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/app/AppSearchOpenBlobForWriteResponseTest.java
new file mode 100644
index 0000000..a4ff1f8
--- /dev/null
+++ b/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/app/AppSearchOpenBlobForWriteResponseTest.java
@@ -0,0 +1,130 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.appsearch.cts.app;
+
+import static android.os.ParcelFileDescriptor.MODE_WRITE_ONLY;
+
+import static androidx.appsearch.testutil.AppSearchTestUtils.calculateDigest;
+import static androidx.appsearch.testutil.AppSearchTestUtils.generateRandomBytes;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.junit.Assert.assertThrows;
+
+import android.os.ParcelFileDescriptor;
+
+import androidx.appsearch.app.AppSearchBatchResult;
+import androidx.appsearch.app.AppSearchBlobHandle;
+import androidx.appsearch.app.AppSearchOpenBlobForWriteResponse;
+import androidx.appsearch.app.AppSearchResult;
+import androidx.appsearch.flags.CheckFlagsRule;
+import androidx.appsearch.flags.DeviceFlagsValueProvider;
+import androidx.appsearch.flags.Flags;
+import androidx.appsearch.flags.RequiresFlagsEnabled;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+
+import java.io.File;
+
+@RequiresFlagsEnabled(Flags.FLAG_ENABLE_BLOB_STORE)
+public class AppSearchOpenBlobForWriteResponseTest {
+    @Rule
+    public final CheckFlagsRule mCheckFlagsRule = DeviceFlagsValueProvider.createCheckFlagsRule();
+
+    ParcelFileDescriptor mPfd;
+    private AppSearchResult<ParcelFileDescriptor> mSuccessResult;
+    private AppSearchResult<ParcelFileDescriptor> mFailureResult;
+
+    @Before
+    public void setUp() throws Exception {
+        File file = File.createTempFile(/*prefix=*/"appsearch", /*suffix=*/null);
+        mPfd = ParcelFileDescriptor.open(file, MODE_WRITE_ONLY);
+        mSuccessResult = AppSearchResult.newSuccessfulResult(mPfd);
+        mFailureResult = AppSearchResult.newFailedResult(
+                AppSearchResult.RESULT_ALREADY_EXISTS, "already exists");
+    }
+
+    @After
+    public void tearDown() throws Exception {
+        mPfd.close();
+    }
+
+    @Test
+    public void testBuildAndGet() throws Exception {
+        byte[] data1 = generateRandomBytes(10); // 10 Bytes
+        byte[] digest1 = calculateDigest(data1);
+        byte[] data2 = generateRandomBytes(10); // 10 Bytes
+        byte[] digest2 = calculateDigest(data2);
+        byte[] data3 = generateRandomBytes(10); // 10 Bytes
+        byte[] digest3 = calculateDigest(data3);
+        byte[] data4 = generateRandomBytes(10); // 10 Bytes
+        byte[] digest4 = calculateDigest(data4);
+        AppSearchBlobHandle blobHandle1 = AppSearchBlobHandle.createWithSha256(
+                digest1, "package1", "db1", "ns");
+        AppSearchBlobHandle blobHandle2 = AppSearchBlobHandle.createWithSha256(
+                digest2, "package1", "db1", "ns");
+        AppSearchBlobHandle blobHandle3 = AppSearchBlobHandle.createWithSha256(
+                digest3, "package1", "db1", "ns");
+        AppSearchBlobHandle blobHandle4 = AppSearchBlobHandle.createWithSha256(
+                digest4, "package1", "db1", "ns");
+
+        AppSearchBatchResult<AppSearchBlobHandle, ParcelFileDescriptor> batchResult =
+                new AppSearchBatchResult.Builder<AppSearchBlobHandle, ParcelFileDescriptor>()
+                        .setSuccess(blobHandle1, mPfd)
+                        .setFailure(blobHandle2, AppSearchResult.RESULT_ALREADY_EXISTS,
+                                "already exists")
+                        .setResult(blobHandle3, mSuccessResult)
+                        .setResult(blobHandle4, mFailureResult)
+                        .build();
+
+        try (AppSearchOpenBlobForWriteResponse response =
+                new AppSearchOpenBlobForWriteResponse(batchResult)) {
+
+            AppSearchBatchResult<AppSearchBlobHandle, ParcelFileDescriptor> outResult =
+                    response.getResult();
+            assertThat(outResult.getSuccesses()).containsExactly(
+                    blobHandle1, mPfd, blobHandle3, mPfd);
+            assertThat(outResult.getFailures()).containsExactly(
+                    blobHandle2, mFailureResult, blobHandle4, mFailureResult);
+            assertThat(outResult.getAll()).containsExactly(
+                    blobHandle1, mSuccessResult, blobHandle2, mFailureResult,
+                    blobHandle3, mSuccessResult, blobHandle4, mFailureResult);
+        }
+    }
+
+    @Test
+    public void testAccessPfdAfterClose() throws Exception {
+        byte[] data = generateRandomBytes(10); // 10 Bytes
+        byte[] digest = calculateDigest(data);
+        AppSearchBlobHandle blobHandle = AppSearchBlobHandle.createWithSha256(
+                digest, "package1", "db1", "ns");
+        AppSearchBatchResult<AppSearchBlobHandle, ParcelFileDescriptor> batchResult =
+                new AppSearchBatchResult.Builder<AppSearchBlobHandle, ParcelFileDescriptor>()
+                        .setResult(blobHandle, mSuccessResult)
+                        .build();
+        try (AppSearchOpenBlobForWriteResponse ignored =
+                    new AppSearchOpenBlobForWriteResponse(batchResult)) {
+            // Pfd is accessible now
+            mPfd.detachFd();
+        }
+        // Pfd is NOT accessible after close()
+        assertThrows(IllegalStateException.class, () -> mPfd.detachFd());
+    }
+}
diff --git a/appsearch/appsearch/src/main/java/androidx/appsearch/app/AppSearchCommitBlobResponse.java b/appsearch/appsearch/src/main/java/androidx/appsearch/app/AppSearchCommitBlobResponse.java
new file mode 100644
index 0000000..3b693bb
--- /dev/null
+++ b/appsearch/appsearch/src/main/java/androidx/appsearch/app/AppSearchCommitBlobResponse.java
@@ -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.appsearch.app;
+
+import android.os.Parcel;
+import android.os.Parcelable;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.RestrictTo;
+import androidx.appsearch.app.aidl.AppSearchBatchResultGeneralKeyParcel;
+import androidx.appsearch.flags.FlaggedApi;
+import androidx.appsearch.flags.Flags;
+import androidx.appsearch.safeparcel.AbstractSafeParcelable;
+import androidx.appsearch.safeparcel.SafeParcelable;
+import androidx.appsearch.safeparcel.stub.StubCreators.CommitBlobResponseCreator;
+import androidx.core.util.Preconditions;
+
+/**
+ * The response to provide batch operation results of
+ * {@link AppSearchSession#commitBlobAsync}.
+ *
+ * <p> This class is used to retrieve the result of a batch commit operation on a collection of
+ * blob handles.
+ */
+@FlaggedApi(Flags.FLAG_ENABLE_BLOB_STORE)
+@SuppressWarnings("HiddenSuperclass")
[email protected](creator = "CommitBlobResponseCreator")
+@ExperimentalAppSearchApi
+public final class AppSearchCommitBlobResponse extends AbstractSafeParcelable {
+
+    @NonNull
+    public static final Parcelable.Creator<AppSearchCommitBlobResponse> CREATOR =
+            new CommitBlobResponseCreator();
+
+    @Field(id = 1, getter = "getResponseParcel")
+    private final AppSearchBatchResultGeneralKeyParcel<AppSearchBlobHandle, Void> mResultParcel;
+
+    /**
+     * Creates a {@link AppSearchCommitBlobResponse} with given {@link AppSearchBatchResult}.
+     */
+    public AppSearchCommitBlobResponse(
+            @NonNull AppSearchBatchResult<AppSearchBlobHandle, Void> result) {
+        this(AppSearchBatchResultGeneralKeyParcel.fromBlobHandleToVoid(result));
+    }
+
+    @Constructor
+    AppSearchCommitBlobResponse(
+            @Param(id = 1) @NonNull AppSearchBatchResultGeneralKeyParcel<AppSearchBlobHandle, Void>
+                    resultParcel) {
+        mResultParcel = Preconditions.checkNotNull(resultParcel);
+    }
+
+    /**
+     * Returns the {@link AppSearchBatchResult} object containing the results of the
+     * commit operation for each {@link AppSearchBlobHandle}.
+     *
+     * @return A {@link AppSearchBatchResult} maps {@link AppSearchBlobHandle}s which is a unique
+     * identifier for a specific blob being committed to the outcome of that commit. If the
+     * operation was successful, the result for that handle is {@code null}; if there was an error,
+     * the result contains an {@link AppSearchResult} with details of the failure.
+     */
+    @NonNull
+    public AppSearchBatchResult<AppSearchBlobHandle, Void> getResult() {
+        return mResultParcel.getResult();
+    }
+
+    /**
+     * Retrieves the underlying parcel representation of the batch result.
+     * @exportToFramework:hide
+     */
+    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+    @NonNull
+    public AppSearchBatchResultGeneralKeyParcel<AppSearchBlobHandle, Void> getResponseParcel() {
+        return mResultParcel;
+    }
+
+    @Override
+    public void writeToParcel(@NonNull Parcel dest, int flags) {
+        CommitBlobResponseCreator.writeToParcel(this, dest, flags);
+    }
+}
diff --git a/appsearch/appsearch/src/main/java/androidx/appsearch/app/AppSearchOpenBlobForReadResponse.java b/appsearch/appsearch/src/main/java/androidx/appsearch/app/AppSearchOpenBlobForReadResponse.java
new file mode 100644
index 0000000..a3152be
--- /dev/null
+++ b/appsearch/appsearch/src/main/java/androidx/appsearch/app/AppSearchOpenBlobForReadResponse.java
@@ -0,0 +1,111 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package androidx.appsearch.app;
+
+import android.os.Parcel;
+import android.os.ParcelFileDescriptor;
+import android.os.Parcelable;
+
+import androidx.annotation.NonNull;
+import androidx.appsearch.app.aidl.AppSearchBatchResultGeneralKeyParcel;
+import androidx.appsearch.flags.FlaggedApi;
+import androidx.appsearch.flags.Flags;
+import androidx.appsearch.safeparcel.AbstractSafeParcelable;
+import androidx.appsearch.safeparcel.SafeParcelable;
+import androidx.appsearch.safeparcel.stub.StubCreators.OpenBlobForReadResponseCreator;
+import androidx.core.util.Preconditions;
+
+import java.io.Closeable;
+import java.io.IOException;
+
+/**
+ * The response to provide batch operation results of
+ * {@link AppSearchSession#openBlobForReadAsync}.
+ *
+ * <p> This class is used to retrieve the result of a batch read operation on a collection of
+ * blob handles.
+ *
+ * <p class="caution">
+ * The returned {@link android.os.ParcelFileDescriptor} must be closed after use to avoid resource
+ * leaks. Failing to close the descriptor will result in system resource exhaustion, as each open
+ * {@link android.os.ParcelFileDescriptor} occupies a limited file descriptor in the system.
+ * </p>
+ */
+@FlaggedApi(Flags.FLAG_ENABLE_BLOB_STORE)
+@SuppressWarnings("HiddenSuperclass")
[email protected](creator = "OpenBlobForReadResponseCreator")
+@ExperimentalAppSearchApi
+public final class AppSearchOpenBlobForReadResponse extends AbstractSafeParcelable implements
+        Closeable {
+
+    @NonNull
+    public static final Parcelable.Creator<AppSearchOpenBlobForReadResponse> CREATOR =
+            new OpenBlobForReadResponseCreator();
+
+    @Field(id = 1)
+    final AppSearchBatchResultGeneralKeyParcel<AppSearchBlobHandle, ParcelFileDescriptor>
+            mResultParcel;
+
+    /**
+     * Creates a {@link AppSearchOpenBlobForReadResponse} with given {@link AppSearchBatchResult}.
+     */
+    public AppSearchOpenBlobForReadResponse(
+            @NonNull AppSearchBatchResult<AppSearchBlobHandle, ParcelFileDescriptor> result) {
+        this(AppSearchBatchResultGeneralKeyParcel.fromBlobHandleToPfd(result));
+    }
+
+
+    @AbstractSafeParcelable.Constructor
+    AppSearchOpenBlobForReadResponse(
+            @AbstractSafeParcelable.Param(id = 1)
+            @NonNull AppSearchBatchResultGeneralKeyParcel<AppSearchBlobHandle, ParcelFileDescriptor>
+                    resultParcel) {
+        mResultParcel = Preconditions.checkNotNull(resultParcel);
+    }
+
+    /**
+     * Returns the {@link AppSearchBatchResult} object containing the results of the read blob for
+     * read operation for each {@link AppSearchBlobHandle}.
+     *
+     * @return A {@link AppSearchBatchResult} maps {@link AppSearchBlobHandle}s which is a unique
+     * identifier for a specific blob being committed to the outcome of that read operation. If the
+     * operation was successful, the result for that handle is {@link ParcelFileDescriptor}; if
+     * there was an error, the result contains an {@link AppSearchResult} with details of the
+     * failure.
+     */
+    @NonNull
+    public AppSearchBatchResult<AppSearchBlobHandle, ParcelFileDescriptor> getResult() {
+        return mResultParcel.getResult();
+    }
+
+    @Override
+    public void close() {
+        AppSearchBatchResult<AppSearchBlobHandle, ParcelFileDescriptor> batchResult =
+                mResultParcel.getResult();
+        for (ParcelFileDescriptor pfd : batchResult.getSuccesses().values()) {
+            try {
+                pfd.close();
+            } catch (IOException ignored) {
+                // The file may be already removed, just ignoring any checked exceptions.
+            }
+        }
+    }
+
+    @Override
+    public void writeToParcel(@NonNull Parcel dest, int flags) {
+        OpenBlobForReadResponseCreator.writeToParcel(this, dest, flags);
+    }
+}
diff --git a/appsearch/appsearch/src/main/java/androidx/appsearch/app/AppSearchOpenBlobForWriteResponse.java b/appsearch/appsearch/src/main/java/androidx/appsearch/app/AppSearchOpenBlobForWriteResponse.java
new file mode 100644
index 0000000..b33ca71
--- /dev/null
+++ b/appsearch/appsearch/src/main/java/androidx/appsearch/app/AppSearchOpenBlobForWriteResponse.java
@@ -0,0 +1,110 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package androidx.appsearch.app;
+
+import android.os.Parcel;
+import android.os.ParcelFileDescriptor;
+import android.os.Parcelable;
+
+import androidx.annotation.NonNull;
+import androidx.appsearch.app.aidl.AppSearchBatchResultGeneralKeyParcel;
+import androidx.appsearch.flags.FlaggedApi;
+import androidx.appsearch.flags.Flags;
+import androidx.appsearch.safeparcel.AbstractSafeParcelable;
+import androidx.appsearch.safeparcel.SafeParcelable;
+import androidx.appsearch.safeparcel.stub.StubCreators.OpenBlobForWriteResponseCreator;
+import androidx.core.util.Preconditions;
+
+import java.io.Closeable;
+import java.io.IOException;
+
+/**
+ * The response to provide batch operation results of
+ * {@link AppSearchSession#openBlobForWriteAsync}.
+ *
+ * <p> This class is used to retrieve the result of a batch write operation on a collection of
+ * blob handles.
+ *
+ * <p class="caution">
+ * The returned {@link android.os.ParcelFileDescriptor} must be closed after use to avoid resource
+ * leaks. Failing to close the descriptor will result in system resource exhaustion, as each open
+ * {@link android.os.ParcelFileDescriptor} occupies a limited file descriptor in the system.
+ * </p>
+ */
+@FlaggedApi(Flags.FLAG_ENABLE_BLOB_STORE)
+@SuppressWarnings("HiddenSuperclass")
[email protected](creator = "OpenBlobForWriteResponseCreator")
+@ExperimentalAppSearchApi
+public final class AppSearchOpenBlobForWriteResponse extends AbstractSafeParcelable implements
+        Closeable {
+
+    @NonNull
+    public static final Parcelable.Creator<AppSearchOpenBlobForWriteResponse> CREATOR =
+            new OpenBlobForWriteResponseCreator();
+
+    @Field(id = 1)
+    final AppSearchBatchResultGeneralKeyParcel<AppSearchBlobHandle, ParcelFileDescriptor>
+            mResultParcel;
+
+    /**
+     * Creates a {@link AppSearchOpenBlobForWriteResponse} with given {@link AppSearchBatchResult}.
+     */
+    public AppSearchOpenBlobForWriteResponse(
+            @NonNull AppSearchBatchResult<AppSearchBlobHandle, ParcelFileDescriptor> result) {
+        this(AppSearchBatchResultGeneralKeyParcel.fromBlobHandleToPfd(result));
+    }
+
+    @AbstractSafeParcelable.Constructor
+    AppSearchOpenBlobForWriteResponse(
+            @AbstractSafeParcelable.Param(id = 1)
+            @NonNull AppSearchBatchResultGeneralKeyParcel<AppSearchBlobHandle, ParcelFileDescriptor>
+                    resultParcel) {
+        mResultParcel = Preconditions.checkNotNull(resultParcel);
+    }
+
+    /**
+     * Returns the {@link AppSearchBatchResult} object containing the results of the write blob for
+     * write operation for each {@link AppSearchBlobHandle}.
+     *
+     * @return A {@link AppSearchBatchResult} maps {@link AppSearchBlobHandle}s which is a unique
+     * identifier for a specific blob being committed to the outcome of that write operation. If the
+     * operation was successful, the result for that handle is {@link ParcelFileDescriptor}; if
+     * there was an error, the result contains an {@link AppSearchResult} with details of the
+     * failure.
+     */
+    @NonNull
+    public AppSearchBatchResult<AppSearchBlobHandle, ParcelFileDescriptor> getResult() {
+        return mResultParcel.getResult();
+    }
+
+    @Override
+    public void close() {
+        AppSearchBatchResult<AppSearchBlobHandle, ParcelFileDescriptor> batchResult =
+                mResultParcel.getResult();
+        for (ParcelFileDescriptor pfd : batchResult.getSuccesses().values()) {
+            try {
+                pfd.close();
+            } catch (IOException ignored) {
+                // The file may be already removed, just ignoring any checked exceptions.
+            }
+        }
+    }
+
+    @Override
+    public void writeToParcel(@NonNull Parcel dest, int flags) {
+        OpenBlobForWriteResponseCreator.writeToParcel(this, dest, flags);
+    }
+}
diff --git a/appsearch/appsearch/src/main/java/androidx/appsearch/app/aidl/AppSearchBatchResultGeneralKeyParcel.java b/appsearch/appsearch/src/main/java/androidx/appsearch/app/aidl/AppSearchBatchResultGeneralKeyParcel.java
new file mode 100644
index 0000000..238f9c7
--- /dev/null
+++ b/appsearch/appsearch/src/main/java/androidx/appsearch/app/aidl/AppSearchBatchResultGeneralKeyParcel.java
@@ -0,0 +1,70 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT 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:skipFile()
+package androidx.appsearch.app.aidl;
+
+import android.os.ParcelFileDescriptor;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.RestrictTo;
+import androidx.appsearch.app.AppSearchBatchResult;
+import androidx.appsearch.app.AppSearchBlobHandle;
+import androidx.appsearch.app.ExperimentalAppSearchApi;
+import androidx.core.util.Preconditions;
+
+/**
+ * A dummy version of AppSearchBatchResultGeneralKeyParcel in jetpack.
+ * @param <KeyType> The type of keys in the batch result, such as {@link AppSearchBlobHandle}.
+ * @param <ValueType> The type of values in the batch result, such as {@link ParcelFileDescriptor}
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+@ExperimentalAppSearchApi
+public final class AppSearchBatchResultGeneralKeyParcel<KeyType, ValueType> {
+    private final AppSearchBatchResult<KeyType, ValueType> mResult;
+
+    private AppSearchBatchResultGeneralKeyParcel(
+            @NonNull AppSearchBatchResult<KeyType, ValueType> result) {
+        mResult = Preconditions.checkNotNull(result);
+    }
+
+    /**
+     * Creates an instance of {@link AppSearchBatchResultGeneralKeyParcel} with key type
+     * {@link AppSearchBlobHandle} and value type {@link ParcelFileDescriptor}.
+     */
+    @NonNull
+    public static AppSearchBatchResultGeneralKeyParcel<AppSearchBlobHandle, ParcelFileDescriptor>
+            fromBlobHandleToPfd(
+            @NonNull AppSearchBatchResult<AppSearchBlobHandle, ParcelFileDescriptor> result) {
+        return new AppSearchBatchResultGeneralKeyParcel<>(result);
+    }
+
+    /**
+     * Creates an instance of {@link AppSearchBatchResultGeneralKeyParcel} with key type
+     * {@link AppSearchBlobHandle} and value type {@link Void}.
+     */
+    @NonNull
+    public static AppSearchBatchResultGeneralKeyParcel<AppSearchBlobHandle, Void>
+            fromBlobHandleToVoid(
+            @NonNull AppSearchBatchResult<AppSearchBlobHandle, Void> result) {
+        return new AppSearchBatchResultGeneralKeyParcel<>(result);
+    }
+
+    /** Returns the wrapped batch result.  */
+    @NonNull
+    public AppSearchBatchResult<KeyType, ValueType> getResult() {
+        return mResult;
+    }
+}
diff --git a/appsearch/appsearch/src/main/java/androidx/appsearch/safeparcel/stub/StubCreators.java b/appsearch/appsearch/src/main/java/androidx/appsearch/safeparcel/stub/StubCreators.java
index a9b33eb..8a452f2 100644
--- a/appsearch/appsearch/src/main/java/androidx/appsearch/safeparcel/stub/StubCreators.java
+++ b/appsearch/appsearch/src/main/java/androidx/appsearch/safeparcel/stub/StubCreators.java
@@ -17,6 +17,9 @@
 
 import androidx.annotation.RestrictTo;
 import androidx.appsearch.app.AppSearchBlobHandle;
+import androidx.appsearch.app.AppSearchCommitBlobResponse;
+import androidx.appsearch.app.AppSearchOpenBlobForReadResponse;
+import androidx.appsearch.app.AppSearchOpenBlobForWriteResponse;
 import androidx.appsearch.app.AppSearchSchema;
 import androidx.appsearch.app.EmbeddingVector;
 import androidx.appsearch.app.GetByDocumentIdRequest;
@@ -201,4 +204,19 @@
     public static class AppSearchBlobHandleCreator extends
             AbstractCreator<AppSearchBlobHandle> {
     }
+
+    /** Stub creator for {@link AppSearchOpenBlobForWriteResponse}. */
+    public static class OpenBlobForWriteResponseCreator extends
+            AbstractCreator<AppSearchOpenBlobForWriteResponse> {
+    }
+
+    /** Stub creator for {@link AppSearchCommitBlobResponse}. */
+    public static class CommitBlobResponseCreator extends
+            AbstractCreator<AppSearchCommitBlobResponse> {
+    }
+
+    /** Stub creator for {@link AppSearchOpenBlobForReadResponse}. */
+    public static class OpenBlobForReadResponseCreator extends
+            AbstractCreator<AppSearchOpenBlobForReadResponse> {
+    }
 }
diff --git a/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/ProfileInstallBroadcastTest.kt b/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/ProfileInstallBroadcastTest.kt
index 722b651..a856ba4 100644
--- a/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/ProfileInstallBroadcastTest.kt
+++ b/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/ProfileInstallBroadcastTest.kt
@@ -96,19 +96,32 @@
 
     @SdkSuppress(minSdkVersion = Build.VERSION_CODES.N)
     @Test
-    fun saveProfilesForAllProcesses() {
+    fun saveProfilesForAllProcesses_target() {
+        // first command just used to wake target - otherwise we'd get 0
+        ProfileInstallBroadcast.dropShaderCache(Packages.TARGET)
+
         assertEquals(
-            expected = ProfileInstallBroadcast.SaveProfileResult(1, null),
-            actual = ProfileInstallBroadcast.saveProfilesForAllProcesses(Packages.TARGET)
+            ProfileInstallBroadcast.SaveProfileResult(1, null),
+            ProfileInstallBroadcast.saveProfilesForAllProcesses(Packages.TARGET)
         )
     }
 
     @SdkSuppress(minSdkVersion = Build.VERSION_CODES.N)
     @Test
+    fun saveProfilesForAllProcesses_self() {
+        val result = ProfileInstallBroadcast.saveProfilesForAllProcesses(Packages.TEST)
+        // only one process, but we don't care if succeeds
+        // (since this test may not depend on profileinstaller)
+        assertEquals(1, result.processCount)
+    }
+
+    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.N)
+    @Test
     fun saveProfilesForAllProcesses_missing() {
-        val result = ProfileInstallBroadcast.saveProfilesForAllProcesses(Packages.MISSING)
-        assertEquals(0, result.processCount)
-        assertNull(result.error)
+        assertEquals(
+            ProfileInstallBroadcast.SaveProfileResult(0, null),
+            ProfileInstallBroadcast.saveProfilesForAllProcesses(Packages.MISSING)
+        )
     }
 
     @SdkSuppress(minSdkVersion = Build.VERSION_CODES.N)
diff --git a/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/TraceSectionMetricTest.kt b/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/TraceSectionMetricTest.kt
index 0cb5228..b96ad25 100644
--- a/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/TraceSectionMetricTest.kt
+++ b/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/TraceSectionMetricTest.kt
@@ -37,6 +37,13 @@
         createTempFileFromAsset(prefix = "api24_commas_in_slice_names", suffix = ".perfetto-trace")
             .absolutePath
 
+    private val truncatedProcessName =
+        createTempFileFromAsset(
+                prefix = "api29_cold_startup_processname_truncated",
+                suffix = ".perfetto-trace"
+            )
+            .absolutePath
+
     @Test
     fun activityThreadMain() =
         verifyFirstSum(
@@ -125,6 +132,20 @@
             targetPackageOnly = false,
         )
 
+    @Test
+    fun truncatedProcessName() =
+        verifyFirstSum(
+            tracePath = truncatedProcessName, // trace with truncated target process name
+            packageName = Packages.TARGET,
+            sectionName = "Choreographer#doFrame",
+            expectedFirstMs = 30.819014,
+            expectedMinMs = 0.122031,
+            expectedMaxMs = 30.81901,
+            expectedSumMs = 31.983701,
+            expectedSumCount = 3,
+            targetPackageOnly = true,
+        )
+
     companion object {
         private fun verifyMetric(
             tracePath: String,
diff --git a/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/perfetto/StartupTimingQueryTest.kt b/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/perfetto/StartupTimingQueryTest.kt
index 1cb8b92..cc3d69f 100644
--- a/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/perfetto/StartupTimingQueryTest.kt
+++ b/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/perfetto/StartupTimingQueryTest.kt
@@ -126,7 +126,7 @@
         )
 
     /**
-     * Validate that StartupTimingQuery returns null and doesn't crash when process name truncated
+     * Validate that StartupTimingQuery successfully captures metrics when process name truncated
      */
     @Test
     fun fixedApi29ColdProcessNameTruncated() =
@@ -134,6 +134,11 @@
             api = 29,
             startupMode = StartupMode.COLD,
             tracePrefix = "api29_cold_startup_processname_truncated",
-            expectedMetrics = null // process name is truncated, and we currently don't handle this
+            expectedMetrics =
+                StartupTimingQuery.SubMetrics(
+                    timeToInitialDisplayNs = 145119546,
+                    timeToFullDisplayNs = null,
+                    timelineRangeNs = 935014155850..935159275396
+                )
         )
 }
diff --git a/benchmark/benchmark-macro/src/main/java/androidx/benchmark/perfetto/PerfettoTraceProcessor.kt b/benchmark/benchmark-macro/src/main/java/androidx/benchmark/perfetto/PerfettoTraceProcessor.kt
index 4305566..56e6efc 100644
--- a/benchmark/benchmark-macro/src/main/java/androidx/benchmark/perfetto/PerfettoTraceProcessor.kt
+++ b/benchmark/benchmark-macro/src/main/java/androidx/benchmark/perfetto/PerfettoTraceProcessor.kt
@@ -466,5 +466,14 @@
 
 /** Helper for fuzzy matching process name to package */
 internal fun processNameLikePkg(pkg: String): String {
-    return """(process.name LIKE "$pkg" OR process.name LIKE "$pkg:%")"""
+    // check for truncated package names, which can sometimes occur if perfetto can't capture full
+    // names, and only has 16 bytes from sched info (which results in 15 chars due to null
+    // termination)
+    val truncated =
+        if (pkg.length > 15) {
+            " OR process.name LIKE \"${pkg.takeLast(15)}\""
+        } else {
+            ""
+        }
+    return """(process.name LIKE "$pkg" OR process.name LIKE "$pkg:%"$truncated)"""
 }
diff --git a/buildSrc/private/src/main/kotlin/androidx/build/AndroidXComposeImplPlugin.kt b/buildSrc/private/src/main/kotlin/androidx/build/AndroidXComposeImplPlugin.kt
index 76cb02d..08f6ad5 100644
--- a/buildSrc/private/src/main/kotlin/androidx/build/AndroidXComposeImplPlugin.kt
+++ b/buildSrc/private/src/main/kotlin/androidx/build/AndroidXComposeImplPlugin.kt
@@ -125,7 +125,7 @@
             }
         // Add Compose compiler plugin to kotlinPlugin configuration, making sure it works
         // for Playground builds as well
-        val compilerPluginVersion = project.getVersionByName("kotlin")
+        val compilerPluginVersion = project.getVersionByName("composeCompilerPlugin")
         project.dependencies.add(
             COMPILER_PLUGIN_CONFIGURATION,
             "org.jetbrains.kotlin:kotlin-compose-compiler-plugin-embeddable:$compilerPluginVersion"
@@ -185,11 +185,8 @@
 
             compile.pluginClasspath.from(kotlinPluginProvider.get())
 
-            // todo(b/291587160): enable when Compose compiler 2.0.20 is merged
-            // compile.enableFeatureFlag(ComposeFeatureFlag.StrongSkipping)
-            // compile.enableFeatureFlag(ComposeFeatureFlag.OptimizeNonSkippingGroups)
-            compile.addPluginOption(ComposeCompileOptions.StrongSkipping, "true")
-            compile.addPluginOption(ComposeCompileOptions.NonSkippingGroupOptimization, "true")
+            compile.enableFeatureFlag(ComposeFeatureFlag.StrongSkipping)
+            compile.enableFeatureFlag(ComposeFeatureFlag.OptimizeNonSkippingGroups)
             if (shouldPublish) {
                 compile.addPluginOption(ComposeCompileOptions.SourceOption, "true")
             }
diff --git a/camera/camera-camera2-pipe-integration/src/androidTest/java/androidx/camera/camera2/pipe/integration/UseCaseSurfaceManagerDeviceTest.kt b/camera/camera-camera2-pipe-integration/src/androidTest/java/androidx/camera/camera2/pipe/integration/UseCaseSurfaceManagerDeviceTest.kt
index 5810c6e..bde668a 100644
--- a/camera/camera-camera2-pipe-integration/src/androidTest/java/androidx/camera/camera2/pipe/integration/UseCaseSurfaceManagerDeviceTest.kt
+++ b/camera/camera-camera2-pipe-integration/src/androidTest/java/androidx/camera/camera2/pipe/integration/UseCaseSurfaceManagerDeviceTest.kt
@@ -195,24 +195,13 @@
             )
         assertThat(surfaceActiveCountDown.await(3, TimeUnit.SECONDS)).isTrue()
         val cameraOpenedUsageCount = testSessionParameters.deferrableSurface.useCount
-        val cameraDisconnectedUsageCount: Int
 
         // Act. Launch Camera2Activity to open the camera, it disconnects the CameraGraph.
         ActivityScenario.launch<Camera2TestActivity>(
                 Intent(ApplicationProvider.getApplicationContext(), Camera2TestActivity::class.java)
                     .apply { putExtra(Camera2TestActivity.EXTRA_CAMERA_ID, cameraId) }
             )
-            .use {
-                // TODO(b/268768235): Under some conditions, it is possible that the camera gets
-                //  disconnected for both the foreground and test activity, before the preview has a
-                //  chance to be ready. Fix it with follow-up changes to change this test by using a
-                //  CameraGraphSimulator rather than a real CameraGraph.
-                // lateinit var previewReady: IdlingResource
-                // it.onActivity { activity -> previewReady = activity.mPreviewReady!! }
-                // previewReady.waitForIdle()
-
-                cameraDisconnectedUsageCount = testSessionParameters.deferrableSurface.useCount
-            }
+            .close()
         // Close the CameraGraph to ensure the usage count does go back down.
         testUseCaseCamera.useCaseCameraGraphConfig.graph.close()
         testUseCaseCamera.useCaseSurfaceManager.stopAsync().awaitWithTimeout()
@@ -221,7 +210,6 @@
 
         // Assert, verify the usage count of the DeferrableSurface
         assertThat(cameraOpenedUsageCount).isEqualTo(2)
-        assertThat(cameraDisconnectedUsageCount).isEqualTo(2)
         assertThat(cameraClosedUsageCount).isEqualTo(1)
     }
 
diff --git a/camera/camera-camera2-pipe-testing/src/main/java/androidx/camera/camera2/pipe/testing/FakeCameraBackend.kt b/camera/camera-camera2-pipe-testing/src/main/java/androidx/camera/camera2/pipe/testing/FakeCameraBackend.kt
index f624b2b..9726735 100644
--- a/camera/camera-camera2-pipe-testing/src/main/java/androidx/camera/camera2/pipe/testing/FakeCameraBackend.kt
+++ b/camera/camera-camera2-pipe-testing/src/main/java/androidx/camera/camera2/pipe/testing/FakeCameraBackend.kt
@@ -25,6 +25,7 @@
 import androidx.camera.camera2.pipe.CameraId
 import androidx.camera.camera2.pipe.CameraMetadata
 import androidx.camera.camera2.pipe.StreamGraph
+import androidx.camera.camera2.pipe.SurfaceTracker
 import androidx.camera.camera2.pipe.graph.GraphListener
 import kotlinx.coroutines.CompletableDeferred
 import kotlinx.coroutines.Deferred
@@ -63,7 +64,8 @@
         graphId: CameraGraphId,
         graphConfig: CameraGraph.Config,
         graphListener: GraphListener,
-        streamGraph: StreamGraph
+        streamGraph: StreamGraph,
+        surfaceTracker: SurfaceTracker,
     ): CameraController {
         val cameraController =
             CameraControllerSimulator(cameraContext, graphId, graphConfig, graphListener)
diff --git a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/CameraBackend.kt b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/CameraBackend.kt
index 19859af..80670b0 100644
--- a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/CameraBackend.kt
+++ b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/CameraBackend.kt
@@ -105,7 +105,8 @@
         graphId: CameraGraphId,
         graphConfig: CameraGraph.Config,
         graphListener: GraphListener,
-        streamGraph: StreamGraph
+        streamGraph: StreamGraph,
+        surfaceTracker: SurfaceTracker,
     ): CameraController
 
     /** Connects and starts the underlying camera */
diff --git a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/SurfaceTracker.kt b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/SurfaceTracker.kt
new file mode 100644
index 0000000..8cd26fe
--- /dev/null
+++ b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/SurfaceTracker.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.camera.camera2.pipe
+
+import android.view.Surface
+import androidx.annotation.RestrictTo
+
+/**
+ * A SurfaceTracker tracks the current usage of [Surface]s at the CameraGraph level. It keeps track
+ * of the surface usages through the tokens acquired from [CameraSurfaceManager].
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+public interface SurfaceTracker {
+    /**
+     * Disconnect and releases the current SurfaceTokens. Intended to be used when a camera is
+     * closed due to disconnect, error or just simple camera shutdown. Note that after this call, no
+     * tokens are going to be acquired for new Surfaces until [registerAllSurfaces] is called.
+     */
+    public fun unregisterAllSurfaces()
+
+    /**
+     * Reacquires the SurfaceTokens for the currently configured Surfaces. Intended to be used when
+     * the camera is restarted and the Surfaces are used again.
+     */
+    public fun registerAllSurfaces()
+}
diff --git a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/compat/Camera2Backend.kt b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/compat/Camera2Backend.kt
index 79900402..39ed241 100644
--- a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/compat/Camera2Backend.kt
+++ b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/compat/Camera2Backend.kt
@@ -25,6 +25,7 @@
 import androidx.camera.camera2.pipe.CameraId
 import androidx.camera.camera2.pipe.CameraMetadata
 import androidx.camera.camera2.pipe.StreamGraph
+import androidx.camera.camera2.pipe.SurfaceTracker
 import androidx.camera.camera2.pipe.config.Camera2ControllerComponent
 import androidx.camera.camera2.pipe.config.Camera2ControllerConfig
 import androidx.camera.camera2.pipe.graph.GraphListener
@@ -92,7 +93,8 @@
         graphId: CameraGraphId,
         graphConfig: CameraGraph.Config,
         graphListener: GraphListener,
-        streamGraph: StreamGraph
+        streamGraph: StreamGraph,
+        surfaceTracker: SurfaceTracker,
     ): CameraController {
         // Use Dagger to create the camera2 controller component, then create the CameraController.
         val cameraControllerComponent =
@@ -104,6 +106,7 @@
                         graphConfig,
                         graphListener,
                         streamGraph as StreamGraphImpl,
+                        surfaceTracker,
                     )
                 )
                 .build()
diff --git a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/compat/Camera2CameraController.kt b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/compat/Camera2CameraController.kt
index 9268480..203ca4c 100644
--- a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/compat/Camera2CameraController.kt
+++ b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/compat/Camera2CameraController.kt
@@ -30,6 +30,7 @@
 import androidx.camera.camera2.pipe.GraphState
 import androidx.camera.camera2.pipe.StreamGraph
 import androidx.camera.camera2.pipe.StreamId
+import androidx.camera.camera2.pipe.SurfaceTracker
 import androidx.camera.camera2.pipe.config.Camera2ControllerScope
 import androidx.camera.camera2.pipe.core.DurationNs
 import androidx.camera.camera2.pipe.core.Log
@@ -64,6 +65,7 @@
     private val threads: Threads,
     private val graphConfig: CameraGraph.Config,
     private val graphListener: GraphListener,
+    private val surfaceTracker: SurfaceTracker,
     private val cameraStatusMonitor: CameraStatusMonitor,
     private val captureSessionFactory: CaptureSessionFactory,
     private val captureSequenceProcessorFactory: Camera2CaptureSequenceProcessorFactory,
@@ -171,6 +173,7 @@
                             controllerState != ControllerState.STOPPED
                     ) {
                         Log.debug { "$this: Restarting Camera2CameraController..." }
+                        surfaceTracker.registerAllSurfaces()
                         stopLocked()
                         startLocked()
                     }
@@ -368,6 +371,8 @@
             } else {
                 controllerState = ControllerState.STOPPED
             }
+
+            surfaceTracker.unregisterAllSurfaces()
             tryRestart()
         }
     }
diff --git a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/config/Camera2Component.kt b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/config/Camera2Component.kt
index 7684737..a92c954 100644
--- a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/config/Camera2Component.kt
+++ b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/config/Camera2Component.kt
@@ -22,6 +22,7 @@
 import androidx.camera.camera2.pipe.CameraGraph
 import androidx.camera.camera2.pipe.CameraGraphId
 import androidx.camera.camera2.pipe.StreamGraph
+import androidx.camera.camera2.pipe.SurfaceTracker
 import androidx.camera.camera2.pipe.compat.AudioRestrictionController
 import androidx.camera.camera2.pipe.compat.AudioRestrictionControllerImpl
 import androidx.camera.camera2.pipe.compat.Camera2Backend
@@ -123,6 +124,7 @@
     private val graphConfig: CameraGraph.Config,
     private val graphListener: GraphListener,
     private val streamGraph: StreamGraph,
+    private val surfaceTracker: SurfaceTracker,
 ) {
     @Provides fun provideCameraGraphConfig() = graphConfig
 
@@ -133,6 +135,8 @@
     @Provides fun provideStreamGraph() = streamGraph as StreamGraphImpl
 
     @Provides fun provideGraphListener() = graphListener
+
+    @Provides fun provideSurfaceGraph() = surfaceTracker
 }
 
 @Module
diff --git a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/config/CameraGraphComponent.kt b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/config/CameraGraphComponent.kt
index e7a6b9c..99b54433c 100644
--- a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/config/CameraGraphComponent.kt
+++ b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/config/CameraGraphComponent.kt
@@ -27,6 +27,7 @@
 import androidx.camera.camera2.pipe.CameraSurfaceManager
 import androidx.camera.camera2.pipe.Request
 import androidx.camera.camera2.pipe.StreamGraph
+import androidx.camera.camera2.pipe.SurfaceTracker
 import androidx.camera.camera2.pipe.core.Threads
 import androidx.camera.camera2.pipe.graph.CameraGraphImpl
 import androidx.camera.camera2.pipe.graph.GraphListener
@@ -43,6 +44,7 @@
 import dagger.Module
 import dagger.Provides
 import dagger.Subcomponent
+import javax.inject.Provider
 import javax.inject.Qualifier
 import javax.inject.Scope
 import kotlinx.coroutines.CoroutineName
@@ -93,6 +95,10 @@
 
     @Binds abstract fun bindStreamGraph(streamGraph: StreamGraphImpl): StreamGraph
 
+    @CameraGraphScope
+    @Binds
+    abstract fun bindSurfaceTracker(surfaceGraph: SurfaceGraph): SurfaceTracker
+
     @Binds
     abstract fun bindCameraGraphParameters(
         parameters: CameraGraphParametersImpl
@@ -139,7 +145,7 @@
         @Provides
         fun provideSurfaceGraph(
             streamGraphImpl: StreamGraphImpl,
-            cameraController: CameraController,
+            cameraController: Provider<CameraController>,
             cameraSurfaceManager: CameraSurfaceManager,
             imageSourceMap: ImageSourceMap
         ): SurfaceGraph {
@@ -208,13 +214,15 @@
             cameraContext: CameraContext,
             graphProcessor: GraphProcessorImpl,
             streamGraph: StreamGraph,
+            surfaceTracker: SurfaceTracker,
         ): CameraController {
             return cameraBackend.createCameraController(
                 cameraContext,
                 graphId,
                 graphConfig,
                 graphProcessor,
-                streamGraph
+                streamGraph,
+                surfaceTracker,
             )
         }
     }
diff --git a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/config/ExternalCameraGraphComponent.kt b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/config/ExternalCameraGraphComponent.kt
index 7089a3b..ef4d68a 100644
--- a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/config/ExternalCameraGraphComponent.kt
+++ b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/config/ExternalCameraGraphComponent.kt
@@ -28,6 +28,7 @@
 import androidx.camera.camera2.pipe.CameraMetadata
 import androidx.camera.camera2.pipe.RequestProcessor
 import androidx.camera.camera2.pipe.StreamGraph
+import androidx.camera.camera2.pipe.SurfaceTracker
 import androidx.camera.camera2.pipe.compat.ExternalCameraController
 import androidx.camera.camera2.pipe.graph.GraphListener
 import dagger.Module
@@ -88,7 +89,8 @@
                 graphId: CameraGraphId,
                 graphConfig: CameraGraph.Config,
                 graphListener: GraphListener,
-                streamGraph: StreamGraph
+                streamGraph: StreamGraph,
+                surfaceTracker: SurfaceTracker,
             ): CameraController {
                 throwUnsupportedOperationException()
             }
diff --git a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/graph/SurfaceGraph.kt b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/graph/SurfaceGraph.kt
index 76885a4..b65d285 100644
--- a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/graph/SurfaceGraph.kt
+++ b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/graph/SurfaceGraph.kt
@@ -22,8 +22,10 @@
 import androidx.camera.camera2.pipe.CameraGraph
 import androidx.camera.camera2.pipe.CameraSurfaceManager
 import androidx.camera.camera2.pipe.StreamId
+import androidx.camera.camera2.pipe.SurfaceTracker
 import androidx.camera.camera2.pipe.core.Log
 import androidx.camera.camera2.pipe.media.ImageSource
+import javax.inject.Provider
 
 /**
  * A SurfaceGraph tracks the current stream-to-surface mapping state for a [CameraGraph] instance.
@@ -33,18 +35,28 @@
  */
 internal class SurfaceGraph(
     private val streamGraphImpl: StreamGraphImpl,
-    private val cameraController: CameraController,
+    private val cameraController: Provider<CameraController>,
     private val surfaceManager: CameraSurfaceManager,
     private val imageSources: Map<StreamId, ImageSource>
-) {
+) : SurfaceTracker, AutoCloseable {
     private val lock = Any()
 
+    /**
+     * A map of [StreamId]s to [Surface]s that stores the mapping of [Surface]s set on the streams
+     * on a [CameraGraph].
+     */
     @GuardedBy("lock")
     private val surfaceMap = imageSources.mapValuesTo(mutableMapOf()) { it.value.surface }
 
+    /**
+     * A map of [Surface]s to closeables from [CameraSurfaceManager]. This keeps track of the token
+     * each [Surface] is associated with, as well as the current tokens that remain active.
+     */
     @GuardedBy("lock")
     private val surfaceUsageMap: MutableMap<Surface, AutoCloseable> = mutableMapOf()
 
+    @GuardedBy("lock") private var shouldRegisterSurfaces = true
+
     @GuardedBy("lock") private var closed: Boolean = false
 
     operator fun set(streamId: StreamId, surface: Surface?) {
@@ -72,14 +84,14 @@
                     // TODO: Tell the graph processor that it should resubmit the repeating request
                     // or reconfigure the camera2 captureSession
                     val oldSurface = surfaceMap.remove(streamId)
-                    if (oldSurface != null) {
+                    if (shouldRegisterSurfaces && oldSurface != null) {
                         oldSurfaceToken = surfaceUsageMap.remove(oldSurface)
                     }
                 } else {
                     val oldSurface = surfaceMap[streamId]
                     surfaceMap[streamId] = surface
 
-                    if (oldSurface != surface) {
+                    if (shouldRegisterSurfaces && oldSurface != surface) {
                         check(!surfaceUsageMap.containsKey(surface)) {
                             "Surface ($surface) is already in use!"
                         }
@@ -95,7 +107,30 @@
         closeable?.close()
     }
 
-    fun close() {
+    override fun unregisterAllSurfaces() {
+        val closeables =
+            synchronized(lock) {
+                shouldRegisterSurfaces = false
+                surfaceUsageMap.values.toList().also { surfaceUsageMap.clear() }
+            }
+        for (closeable in closeables) {
+            closeable.close()
+        }
+    }
+
+    override fun registerAllSurfaces() {
+        synchronized(lock) {
+            check(!closed)
+            for (surface in surfaceMap.values) {
+                surfaceManager.registerSurface(surface).also { token ->
+                    surfaceUsageMap[surface] = token
+                }
+            }
+            shouldRegisterSurfaces = true
+        }
+    }
+
+    override fun close() {
         val closeables =
             synchronized(lock) {
                 if (closed) {
@@ -122,7 +157,7 @@
         if (surfaces.isEmpty()) {
             return
         }
-        cameraController.updateSurfaceMap(surfaces)
+        cameraController.get().updateSurfaceMap(surfaces)
     }
 
     private fun buildSurfaceMap(): Map<StreamId, Surface> =
diff --git a/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/compat/Camera2CameraControllerTest.kt b/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/compat/Camera2CameraControllerTest.kt
index 1d71917..2c1314b 100644
--- a/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/compat/Camera2CameraControllerTest.kt
+++ b/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/compat/Camera2CameraControllerTest.kt
@@ -29,6 +29,7 @@
 import androidx.camera.camera2.pipe.CameraSurfaceManager
 import androidx.camera.camera2.pipe.StreamFormat
 import androidx.camera.camera2.pipe.StreamId
+import androidx.camera.camera2.pipe.SurfaceTracker
 import androidx.camera.camera2.pipe.core.TimeSource
 import androidx.camera.camera2.pipe.core.TimestampNs
 import androidx.camera.camera2.pipe.graph.GraphListener
@@ -66,6 +67,7 @@
     private val streamId1 = StreamId(1)
     private val fakeGraphConfig = CameraGraph.Config(cameraId, listOf(streamConfig1))
     private val fakeGraphListener: GraphListener = mock()
+    private val fakeSurfaceTracker: SurfaceTracker = mock()
 
     // TODO: b/372258646 - Implement a proper fake implementation to simulate status changes.
     private val fakeCameraStatusMonitor = FakeCameraStatusMonitor(cameraId)
@@ -86,6 +88,7 @@
             fakeThreads,
             fakeGraphConfig,
             fakeGraphListener,
+            fakeSurfaceTracker,
             fakeCameraStatusMonitor,
             fakeCaptureSessionFactory,
             fakeCaptureSequenceProcessorFactory,
diff --git a/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/graph/CameraGraphImplTest.kt b/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/graph/CameraGraphImplTest.kt
index 5598cda..1a7117a 100644
--- a/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/graph/CameraGraphImplTest.kt
+++ b/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/graph/CameraGraphImplTest.kt
@@ -118,7 +118,7 @@
     private val frameDistributor =
         FrameDistributor(imageSourceMap.imageSources, frameCaptureQueue) {}
     private val surfaceGraph =
-        SurfaceGraph(streamGraph, cameraController, cameraSurfaceManager, emptyMap())
+        SurfaceGraph(streamGraph, cameraControllerProvider, cameraSurfaceManager, emptyMap())
     private val audioRestriction = FakeAudioRestrictionController()
     private val sessionLock = SessionLock()
     private val cameraGraph =
diff --git a/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/graph/SurfaceGraphTest.kt b/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/graph/SurfaceGraphTest.kt
index 4f7c6c4..77f018e 100644
--- a/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/graph/SurfaceGraphTest.kt
+++ b/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/graph/SurfaceGraphTest.kt
@@ -19,6 +19,7 @@
 import android.graphics.SurfaceTexture
 import android.os.Build
 import android.view.Surface
+import androidx.camera.camera2.pipe.CameraController
 import androidx.camera.camera2.pipe.CameraGraphId
 import androidx.camera.camera2.pipe.CameraSurfaceManager
 import androidx.camera.camera2.pipe.testing.FakeCameraController
@@ -44,6 +45,7 @@
     private val config = FakeGraphConfigs
     private val graphId = CameraGraphId.nextId()
     private val fakeCameraController = FakeCameraController(graphId)
+    private val fakeCameraControllerProvider: () -> CameraController = { fakeCameraController }
 
     private val streamMap = StreamGraphImpl(config.fakeMetadata, config.graphConfig, mock())
 
@@ -51,7 +53,7 @@
     private val cameraSurfaceManager =
         CameraSurfaceManager().also { it.addListener(fakeSurfaceListener) }
     private val surfaceGraph =
-        SurfaceGraph(streamMap, fakeCameraController, cameraSurfaceManager, emptyMap())
+        SurfaceGraph(streamMap, fakeCameraControllerProvider, cameraSurfaceManager, emptyMap())
 
     private val stream1 = streamMap[config.streamConfig1]!!
     private val stream2 = streamMap[config.streamConfig2]!!
@@ -230,7 +232,7 @@
     }
 
     @Test
-    fun settingSurfaceToNullThenPreviousSurfaceWillReaquireSurfaceToken() {
+    fun settingSurfaceToNullThenPreviousSurfaceWillReacquireSurfaceToken() {
         surfaceGraph[stream1.id] = fakeSurface1
         surfaceGraph[stream1.id] = null
         surfaceGraph[stream1.id] = fakeSurface1
@@ -248,4 +250,52 @@
         surfaceGraph[stream1.id] = fakeSurface1
         assertThrows<Exception> { surfaceGraph[stream2.id] = fakeSurface1 }
     }
+
+    @Test
+    fun disconnectSurfaceGraphReleasesSurfaceTokens() {
+        surfaceGraph[stream1.id] = fakeSurface1
+        surfaceGraph[stream2.id] = fakeSurface2
+        verify(fakeSurfaceListener, times(1)).onSurfaceActive(fakeSurface1)
+        verify(fakeSurfaceListener, times(1)).onSurfaceActive(fakeSurface2)
+
+        surfaceGraph.unregisterAllSurfaces()
+        verify(fakeSurfaceListener, times(1)).onSurfaceInactive(fakeSurface1)
+        verify(fakeSurfaceListener, times(1)).onSurfaceInactive(fakeSurface2)
+    }
+
+    @Test
+    fun reconnectSurfaceGraphThenSurfaceWillReacquireSurfaceTokens() {
+        surfaceGraph[stream1.id] = fakeSurface1
+        surfaceGraph[stream2.id] = fakeSurface2
+        verify(fakeSurfaceListener, times(1)).onSurfaceActive(fakeSurface1)
+        verify(fakeSurfaceListener, times(1)).onSurfaceActive(fakeSurface2)
+
+        surfaceGraph.unregisterAllSurfaces()
+        verify(fakeSurfaceListener, times(1)).onSurfaceInactive(fakeSurface1)
+        verify(fakeSurfaceListener, times(1)).onSurfaceInactive(fakeSurface2)
+
+        surfaceGraph.registerAllSurfaces()
+        verify(fakeSurfaceListener, times(2)).onSurfaceActive(fakeSurface1)
+        verify(fakeSurfaceListener, times(2)).onSurfaceActive(fakeSurface2)
+    }
+
+    @Test
+    fun reconnectSurfaceGraphWhenSurfaceChangedThenOnlyNewSurfaceWillReacquireSurfaceTokens() {
+        surfaceGraph[stream1.id] = fakeSurface1
+        surfaceGraph[stream2.id] = fakeSurface2
+        verify(fakeSurfaceListener, times(1)).onSurfaceActive(fakeSurface1)
+        verify(fakeSurfaceListener, times(1)).onSurfaceActive(fakeSurface2)
+
+        surfaceGraph.unregisterAllSurfaces()
+        verify(fakeSurfaceListener, times(1)).onSurfaceInactive(fakeSurface1)
+        verify(fakeSurfaceListener, times(1)).onSurfaceInactive(fakeSurface2)
+
+        surfaceGraph[stream2.id] = fakeSurface3
+        verify(fakeSurfaceListener, times(1)).onSurfaceInactive(fakeSurface2)
+        verify(fakeSurfaceListener, never()).onSurfaceActive(fakeSurface3)
+
+        surfaceGraph.registerAllSurfaces()
+        verify(fakeSurfaceListener, times(2)).onSurfaceActive(fakeSurface1)
+        verify(fakeSurfaceListener, times(1)).onSurfaceActive(fakeSurface3)
+    }
 }
diff --git a/camera/integration-tests/coretestapp/src/androidTest/java/androidx/camera/integration/core/CameraDisconnectTest.kt b/camera/integration-tests/coretestapp/src/androidTest/java/androidx/camera/integration/core/CameraDisconnectTest.kt
index 34f4db5..f9ff936 100644
--- a/camera/integration-tests/coretestapp/src/androidTest/java/androidx/camera/integration/core/CameraDisconnectTest.kt
+++ b/camera/integration-tests/coretestapp/src/androidTest/java/androidx/camera/integration/core/CameraDisconnectTest.kt
@@ -58,7 +58,6 @@
 import kotlinx.coroutines.runBlocking
 import kotlinx.coroutines.withContext
 import org.junit.After
-import org.junit.Assume.assumeFalse
 import org.junit.Assume.assumeTrue
 import org.junit.Before
 import org.junit.Rule
@@ -244,11 +243,6 @@
     @Test
     @SdkSuppress(minSdkVersion = Build.VERSION_CODES.M) // Known issue, checkout b/147393563.
     fun canRecovered_afterReceivingCameraOnDisconnectedEvent() {
-        // TODO(b/344749041) The tests can run failed on API 27 devices in camera-pipe config
-        assumeFalse(
-            Build.VERSION.SDK_INT == Build.VERSION_CODES.O_MR1 &&
-                implName == CameraPipeConfig::class.simpleName
-        )
         // Launch CameraX activity
         cameraXActivityScenario = launchCameraXActivity(cameraId)
         with(cameraXActivityScenario) {
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 69e5e0e..fe020c4 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,6 +71,7 @@
 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;
@@ -193,6 +194,7 @@
     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<>();
 
     static {
         // From Android T, skips the permission check of WRITE_EXTERNAL_STORAGE since it won't be
@@ -238,6 +240,15 @@
                 DynamicRange.DOLBY_VISION_10_BIT,
                 "HDR (Dolby Vision, 10-bit)",
                 R.string.toggle_video_dyn_rng_hdr_dolby_vision_10));
+
+        // TODO - Indicate whether the FPS ranges are supported with
+        //  `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"));
     }
 
     //Use this activity title when Camera Pipe configuration is used by core test app
@@ -355,6 +366,7 @@
     private Button mZoomIn2XToggle;
     private Button mZoomResetToggle;
     private Button mButtonImageOutputFormat;
+    private Button mButtonFps;
     private Toast mEvToast = null;
     private Toast mPSToast = null;
     private ToggleButton mPreviewStabilizationToggle;
@@ -371,6 +383,7 @@
     private final Set<DynamicRange> mSelectableDynamicRanges = new HashSet<>();
     private int mVideoMirrorMode = MIRROR_MODE_ON_FRONT_ONLY;
     private boolean mIsPreviewStabilizationOn = false;
+    private int mFpsMenuId = 0;
 
     SessionMediaUriSet mSessionImagesUriSet = new SessionMediaUriSet();
     SessionMediaUriSet mSessionVideosUriSet = new SessionMediaUriSet();
@@ -1277,6 +1290,7 @@
         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);
@@ -1389,6 +1403,7 @@
         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();
@@ -1592,6 +1607,42 @@
                 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();
@@ -1911,6 +1962,7 @@
                     .setPreviewStabilizationEnabled(mIsPreviewStabilizationOn)
                     .setDynamicRange(
                             mVideoToggle.isChecked() ? DynamicRange.UNSPECIFIED : mDynamicRange)
+                    .setTargetFrameRate(FPS_OPTIONS.get(mFpsMenuId).first)
                     .build();
             resetViewIdlingResource();
             // Use the listener of the future to make sure the Preview setup the new surface.
@@ -1959,6 +2011,7 @@
                 mVideoCapture = new VideoCapture.Builder<>(mRecorder)
                         .setMirrorMode(mVideoMirrorMode)
                         .setDynamicRange(mDynamicRange)
+                        .setTargetFrameRate(FPS_OPTIONS.get(mFpsMenuId).first)
                         .build();
             }
             useCases.add(mVideoCapture);
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 c65182e..bc7e3db 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,6 +437,20 @@
         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/compose/foundation/foundation/api/current.ignore b/compose/foundation/foundation/api/current.ignore
index 3694fd4..5333980 100644
--- a/compose/foundation/foundation/api/current.ignore
+++ b/compose/foundation/foundation/api/current.ignore
@@ -1,6 +1,4 @@
 // Baseline format: 1.0
-AddedAbstractMethod: androidx.compose.foundation.OverscrollEffect#getNode():
-    Added method androidx.compose.foundation.OverscrollEffect.getNode()
 AddedAbstractMethod: androidx.compose.foundation.gestures.DraggableAnchors#anchorAt(int):
     Added method androidx.compose.foundation.gestures.DraggableAnchors.anchorAt(int)
 AddedAbstractMethod: androidx.compose.foundation.gestures.DraggableAnchors#hasPositionFor(T):
@@ -25,9 +23,5 @@
     Attempted to change parameter name from value to anchor in method androidx.compose.foundation.gestures.DraggableAnchors.positionOf
 
 
-RemovedClass: androidx.compose.foundation.OverscrollConfiguration_androidKt:
-    Removed class androidx.compose.foundation.OverscrollConfiguration_androidKt
-
-
 RemovedMethod: androidx.compose.foundation.lazy.grid.GridItemSpan#getCurrentLineSpan():
     Removed method androidx.compose.foundation.lazy.grid.GridItemSpan.getCurrentLineSpan()
diff --git a/compose/foundation/foundation/api/current.txt b/compose/foundation/foundation/api/current.txt
index b0cbdc8..30beb65 100644
--- a/compose/foundation/foundation/api/current.txt
+++ b/compose/foundation/foundation/api/current.txt
@@ -221,13 +221,29 @@
     method public inline boolean tryMutate(kotlin.jvm.functions.Function0<kotlin.Unit> block);
   }
 
+  @Deprecated @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi @androidx.compose.runtime.Stable public final class OverscrollConfiguration {
+    ctor @Deprecated public OverscrollConfiguration();
+    ctor @Deprecated public OverscrollConfiguration(optional long glowColor, optional androidx.compose.foundation.layout.PaddingValues drawPadding);
+    method @Deprecated public androidx.compose.foundation.layout.PaddingValues getDrawPadding();
+    method @Deprecated public long getGlowColor();
+    property @Deprecated public final androidx.compose.foundation.layout.PaddingValues drawPadding;
+    property @Deprecated public final long glowColor;
+  }
+
+  public final class OverscrollConfiguration_androidKt {
+    method @Deprecated @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi public static androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.foundation.OverscrollConfiguration?> getLocalOverscrollConfiguration();
+    property @Deprecated @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi public static final androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.foundation.OverscrollConfiguration?> LocalOverscrollConfiguration;
+  }
+
   @androidx.compose.runtime.Stable public interface OverscrollEffect {
     method public suspend Object? applyToFling(long velocity, kotlin.jvm.functions.Function2<? super androidx.compose.ui.unit.Velocity,? super kotlin.coroutines.Continuation<? super androidx.compose.ui.unit.Velocity>,? extends java.lang.Object?> performFling, kotlin.coroutines.Continuation<? super kotlin.Unit>);
     method public long applyToScroll(long delta, int source, kotlin.jvm.functions.Function1<? super androidx.compose.ui.geometry.Offset,androidx.compose.ui.geometry.Offset> performScroll);
-    method public androidx.compose.ui.node.DelegatableNode getNode();
+    method @Deprecated public default androidx.compose.ui.Modifier getEffectModifier();
+    method public default androidx.compose.ui.node.DelegatableNode getNode();
     method public boolean isInProgress();
+    property @Deprecated public default androidx.compose.ui.Modifier effectModifier;
     property public abstract boolean isInProgress;
-    property public abstract androidx.compose.ui.node.DelegatableNode node;
+    property public default androidx.compose.ui.node.DelegatableNode node;
   }
 
   public interface OverscrollFactory {
@@ -594,6 +610,7 @@
 
   public final class ScrollableDefaults {
     method @androidx.compose.runtime.Composable public androidx.compose.foundation.gestures.FlingBehavior flingBehavior();
+    method @Deprecated @androidx.compose.runtime.Composable public androidx.compose.foundation.OverscrollEffect overscrollEffect();
     method public boolean reverseDirection(androidx.compose.ui.unit.LayoutDirection layoutDirection, androidx.compose.foundation.gestures.Orientation orientation, boolean reverseScrolling);
     field public static final androidx.compose.foundation.gestures.ScrollableDefaults INSTANCE;
   }
diff --git a/compose/foundation/foundation/api/restricted_current.ignore b/compose/foundation/foundation/api/restricted_current.ignore
index 3694fd4..5333980 100644
--- a/compose/foundation/foundation/api/restricted_current.ignore
+++ b/compose/foundation/foundation/api/restricted_current.ignore
@@ -1,6 +1,4 @@
 // Baseline format: 1.0
-AddedAbstractMethod: androidx.compose.foundation.OverscrollEffect#getNode():
-    Added method androidx.compose.foundation.OverscrollEffect.getNode()
 AddedAbstractMethod: androidx.compose.foundation.gestures.DraggableAnchors#anchorAt(int):
     Added method androidx.compose.foundation.gestures.DraggableAnchors.anchorAt(int)
 AddedAbstractMethod: androidx.compose.foundation.gestures.DraggableAnchors#hasPositionFor(T):
@@ -25,9 +23,5 @@
     Attempted to change parameter name from value to anchor in method androidx.compose.foundation.gestures.DraggableAnchors.positionOf
 
 
-RemovedClass: androidx.compose.foundation.OverscrollConfiguration_androidKt:
-    Removed class androidx.compose.foundation.OverscrollConfiguration_androidKt
-
-
 RemovedMethod: androidx.compose.foundation.lazy.grid.GridItemSpan#getCurrentLineSpan():
     Removed method androidx.compose.foundation.lazy.grid.GridItemSpan.getCurrentLineSpan()
diff --git a/compose/foundation/foundation/api/restricted_current.txt b/compose/foundation/foundation/api/restricted_current.txt
index 6923448..4e11e6c9 100644
--- a/compose/foundation/foundation/api/restricted_current.txt
+++ b/compose/foundation/foundation/api/restricted_current.txt
@@ -223,13 +223,29 @@
     method @kotlin.PublishedApi internal void unlock();
   }
 
+  @Deprecated @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi @androidx.compose.runtime.Stable public final class OverscrollConfiguration {
+    ctor @Deprecated public OverscrollConfiguration();
+    ctor @Deprecated public OverscrollConfiguration(optional long glowColor, optional androidx.compose.foundation.layout.PaddingValues drawPadding);
+    method @Deprecated public androidx.compose.foundation.layout.PaddingValues getDrawPadding();
+    method @Deprecated public long getGlowColor();
+    property @Deprecated public final androidx.compose.foundation.layout.PaddingValues drawPadding;
+    property @Deprecated public final long glowColor;
+  }
+
+  public final class OverscrollConfiguration_androidKt {
+    method @Deprecated @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi public static androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.foundation.OverscrollConfiguration?> getLocalOverscrollConfiguration();
+    property @Deprecated @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi public static final androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.foundation.OverscrollConfiguration?> LocalOverscrollConfiguration;
+  }
+
   @androidx.compose.runtime.Stable public interface OverscrollEffect {
     method public suspend Object? applyToFling(long velocity, kotlin.jvm.functions.Function2<? super androidx.compose.ui.unit.Velocity,? super kotlin.coroutines.Continuation<? super androidx.compose.ui.unit.Velocity>,? extends java.lang.Object?> performFling, kotlin.coroutines.Continuation<? super kotlin.Unit>);
     method public long applyToScroll(long delta, int source, kotlin.jvm.functions.Function1<? super androidx.compose.ui.geometry.Offset,androidx.compose.ui.geometry.Offset> performScroll);
-    method public androidx.compose.ui.node.DelegatableNode getNode();
+    method @Deprecated public default androidx.compose.ui.Modifier getEffectModifier();
+    method public default androidx.compose.ui.node.DelegatableNode getNode();
     method public boolean isInProgress();
+    property @Deprecated public default androidx.compose.ui.Modifier effectModifier;
     property public abstract boolean isInProgress;
-    property public abstract androidx.compose.ui.node.DelegatableNode node;
+    property public default androidx.compose.ui.node.DelegatableNode node;
   }
 
   public interface OverscrollFactory {
@@ -596,6 +612,7 @@
 
   public final class ScrollableDefaults {
     method @androidx.compose.runtime.Composable public androidx.compose.foundation.gestures.FlingBehavior flingBehavior();
+    method @Deprecated @androidx.compose.runtime.Composable public androidx.compose.foundation.OverscrollEffect overscrollEffect();
     method public boolean reverseDirection(androidx.compose.ui.unit.LayoutDirection layoutDirection, androidx.compose.foundation.gestures.Orientation orientation, boolean reverseScrolling);
     field public static final androidx.compose.foundation.gestures.ScrollableDefaults INSTANCE;
   }
diff --git a/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/PointerIconDemo.kt b/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/PointerIconDemo.kt
index aae1809..6bf9176 100644
--- a/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/PointerIconDemo.kt
+++ b/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/PointerIconDemo.kt
@@ -33,6 +33,7 @@
 import androidx.compose.ui.graphics.SolidColor
 import androidx.compose.ui.input.pointer.PointerIcon
 import androidx.compose.ui.input.pointer.pointerHoverIcon
+import androidx.compose.ui.input.pointer.stylusHoverIcon
 import androidx.compose.ui.tooling.preview.Preview
 import androidx.compose.ui.unit.dp
 
@@ -62,6 +63,7 @@
             Modifier.fillMaxSize()
                 .border(BorderStroke(2.dp, SolidColor(Color.Red)))
                 .pointerHoverIcon(PointerIcon.Crosshair)
+                .stylusHoverIcon(PointerIcon.Crosshair)
     ) {
         Text(text = "expected crosshair")
         Box(
@@ -69,6 +71,7 @@
                 .fillMaxWidth(0.6f)
                 .border(BorderStroke(2.dp, SolidColor(Color.Black)))
                 .pointerHoverIcon(PointerIcon.Hand, true)
+                .stylusHoverIcon(PointerIcon.Hand, true)
         ) {
             Text(text = "expected hand")
         }
@@ -83,12 +86,14 @@
             Modifier.fillMaxSize()
                 .border(BorderStroke(2.dp, SolidColor(Color.Red)))
                 .pointerHoverIcon(PointerIcon.Crosshair)
+                .stylusHoverIcon(PointerIcon.Crosshair)
     ) {
         Text(text = "expected crosshair")
         Box(
             Modifier.fillMaxSize()
                 .border(BorderStroke(2.dp, SolidColor(Color.Black)))
                 .pointerHoverIcon(PointerIcon.Hand)
+                .stylusHoverIcon(PointerIcon.Hand)
         ) {
             Text(text = "expected hand")
         }
@@ -108,6 +113,7 @@
                     .requiredSize(50.dp)
                     .border(BorderStroke(2.dp, SolidColor(Color.Black)))
                     .pointerHoverIcon(PointerIcon.Hand)
+                    .stylusHoverIcon(PointerIcon.Hand)
             ) {
                 Text("hand")
             }
@@ -116,6 +122,7 @@
                     .requiredSize(50.dp)
                     .border(BorderStroke(2.dp, SolidColor(Color.Blue)))
                     .pointerHoverIcon(PointerIcon.Crosshair)
+                    .stylusHoverIcon(PointerIcon.Crosshair)
             ) {
                 Text("crosshair")
             }
@@ -135,6 +142,7 @@
                 .requiredSize(120.dp, 60.dp)
                 .border(BorderStroke(2.dp, SolidColor(Color.Black)))
                 .pointerHoverIcon(PointerIcon.Hand)
+                .stylusHoverIcon(PointerIcon.Hand)
         ) {
             Text(text = "expected hand")
         }
@@ -143,6 +151,7 @@
                 .requiredSize(120.dp, 20.dp)
                 .border(BorderStroke(2.dp, SolidColor(Color.Blue)))
                 .pointerHoverIcon(PointerIcon.Crosshair)
+                .stylusHoverIcon(PointerIcon.Crosshair)
         ) {
             Text(text = "expected crosshair")
         }
@@ -157,6 +166,7 @@
             Modifier.requiredSize(200.dp)
                 .border(BorderStroke(2.dp, SolidColor(Color.Red)))
                 .pointerHoverIcon(PointerIcon.Crosshair)
+                .stylusHoverIcon(PointerIcon.Crosshair)
     ) {
         Text(text = "expected crosshair")
         Box(
@@ -164,6 +174,7 @@
                 .requiredSize(150.dp)
                 .border(BorderStroke(2.dp, SolidColor(Color.Black)))
                 .pointerHoverIcon(PointerIcon.Text)
+                .stylusHoverIcon(PointerIcon.Text)
         ) {
             Text(text = "expected text")
             Box(
@@ -171,6 +182,7 @@
                     .requiredSize(100.dp)
                     .border(BorderStroke(2.dp, SolidColor(Color.Blue)))
                     .pointerHoverIcon(PointerIcon.Hand)
+                    .stylusHoverIcon(PointerIcon.Hand)
             ) {
                 Text(text = "expected hand")
             }
@@ -189,6 +201,7 @@
                     .requiredSize(width = 200.dp, height = 150.dp)
                     .border(BorderStroke(2.dp, SolidColor(Color.Red)))
                     .pointerHoverIcon(PointerIcon.Crosshair, overrideDescendants = false)
+                    .stylusHoverIcon(PointerIcon.Crosshair, overrideDescendants = false)
         ) {
             Text(text = "expected crosshair")
             Box(
@@ -196,6 +209,7 @@
                     .requiredSize(width = 150.dp, height = 125.dp)
                     .border(BorderStroke(2.dp, SolidColor(Color.Black)))
                     .pointerHoverIcon(PointerIcon.Text, overrideDescendants = false)
+                    .stylusHoverIcon(PointerIcon.Text, overrideDescendants = false)
             ) {
                 Text(text = "expected text")
                 Box(
@@ -204,6 +218,7 @@
                         .offset(x = 100.dp)
                         .border(BorderStroke(2.dp, SolidColor(Color.Blue)))
                         .pointerHoverIcon(PointerIcon.Hand, overrideDescendants = false)
+                        .stylusHoverIcon(PointerIcon.Hand, overrideDescendants = false)
                 ) {
                     Text(text = "expected hand")
                 }
diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/ScrollTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/ScrollTest.kt
index bcc76c8..728d34b 100644
--- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/ScrollTest.kt
+++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/ScrollTest.kt
@@ -663,33 +663,21 @@
     fun testInspectorValue_withoutOverscrollParameter() {
         val state = ScrollState(initial = 0)
         rule.setContent {
-            val modifiers =
+            val modifier =
                 when (config.orientation) {
                     Vertical -> Modifier.verticalScroll(state)
                     Horizontal -> Modifier.horizontalScroll(state)
-                }.toList()
-
-            val scrollableContainer = modifiers[0] as InspectableValue
-            val scroll = modifiers[1] as InspectableValue
-            assertThat(scrollableContainer.nameFallback).isEqualTo("scrollingContainer")
-            assertThat(scrollableContainer.valueOverride).isNull()
-            assertThat(scrollableContainer.inspectableElements.map { it.name }.asIterable())
-                .containsExactly(
-                    "state",
-                    "orientation",
-                    "enabled",
-                    "reverseScrolling",
-                    "flingBehavior",
-                    "interactionSource",
-                    "bringIntoViewSpec",
-                    "useLocalOverscrollFactory",
-                    "overscrollEffect"
-                )
-
-            assertThat(scroll.nameFallback).isEqualTo("scroll")
-            assertThat(scroll.valueOverride).isNull()
-            assertThat(scroll.inspectableElements.map { it.name }.asIterable())
-                .containsExactly("state", "reverseScrolling", "isVertical")
+                }
+                    as InspectableValue
+            val expectedName =
+                when (config.orientation) {
+                    Vertical -> "verticalScroll"
+                    Horizontal -> "horizontalScroll"
+                }
+            assertThat(modifier.nameFallback).isEqualTo(expectedName)
+            assertThat(modifier.valueOverride).isNull()
+            assertThat(modifier.inspectableElements.map { it.name }.asIterable())
+                .containsExactly("state", "enabled", "flingBehavior", "reverseScrolling")
         }
     }
 
@@ -717,7 +705,6 @@
                     "flingBehavior",
                     "interactionSource",
                     "bringIntoViewSpec",
-                    "useLocalOverscrollFactory",
                     "overscrollEffect"
                 )
 
diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/ScrollingContainerTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/ScrollingContainerTest.kt
index b5abe10..1b359e5 100644
--- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/ScrollingContainerTest.kt
+++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/ScrollingContainerTest.kt
@@ -81,7 +81,6 @@
                     reverseScrolling = false,
                     flingBehavior = null,
                     interactionSource = null,
-                    useLocalOverscrollFactory = false,
                     overscrollEffect = null,
                     bringIntoViewSpec = null
                 ) as InspectableValue
@@ -95,7 +94,6 @@
                     "reverseScrolling",
                     "flingBehavior",
                     "interactionSource",
-                    "useLocalOverscrollFactory",
                     "overscrollEffect",
                     "bringIntoViewSpec"
                 )
@@ -119,7 +117,6 @@
                             reverseScrolling = false,
                             flingBehavior = null,
                             interactionSource = null,
-                            useLocalOverscrollFactory = false,
                             overscrollEffect = null
                         )
                 ) {
@@ -177,7 +174,6 @@
                                 reverseScrolling = false,
                                 flingBehavior = null,
                                 interactionSource = null,
-                                useLocalOverscrollFactory = false,
                                 overscrollEffect = null
                             )
                     )
@@ -211,7 +207,6 @@
                     reverseScrolling = false,
                     flingBehavior = null,
                     interactionSource = null,
-                    useLocalOverscrollFactory = false,
                     overscrollEffect = overscrollEffect,
                     bringIntoViewSpec = null
                 )
@@ -236,7 +231,6 @@
                     reverseScrolling = false,
                     flingBehavior = null,
                     interactionSource = null,
-                    useLocalOverscrollFactory = false,
                     overscrollEffect = effect,
                     bringIntoViewSpec = null
                 )
@@ -253,6 +247,7 @@
         rule.runOnIdle {
             assertThat(overscrollEffect1.node.node.isAttached).isFalse()
             assertThat(overscrollEffect2.node.node.isAttached).isTrue()
+            effect = overscrollEffect2
         }
     }
 
@@ -289,7 +284,6 @@
                             reverseScrolling = false,
                             flingBehavior = null,
                             interactionSource = null,
-                            useLocalOverscrollFactory = false,
                             overscrollEffect = overscrollEffect,
                             bringIntoViewSpec = null
                         )
@@ -307,207 +301,6 @@
         rule.waitForIdle()
     }
 
-    @Test
-    fun attachesLocalOverscrollFactoryOverscrollEffectNode() {
-        val overscrollEffect = TestOverscrollEffect()
-        val factory =
-            object : OverscrollFactory {
-                override fun createOverscrollEffect(): OverscrollEffect = overscrollEffect
-
-                override fun equals(other: Any?): Boolean = other === this
-
-                override fun hashCode(): Int = -1
-            }
-
-        rule.setContent {
-            CompositionLocalProvider(LocalOverscrollFactory provides factory) {
-                Box(
-                    Modifier.scrollingContainer(
-                        rememberScrollState(),
-                        orientation = Horizontal,
-                        enabled = true,
-                        reverseScrolling = false,
-                        flingBehavior = null,
-                        interactionSource = null,
-                        useLocalOverscrollFactory = true,
-                        overscrollEffect = null,
-                        bringIntoViewSpec = null
-                    )
-                )
-            }
-        }
-
-        rule.runOnIdle { assertThat(overscrollEffect.node.node.isAttached).isTrue() }
-    }
-
-    @Test
-    fun updatesToNewLocalOverscrollFactory() {
-        val overscrollEffect1 = TestOverscrollEffect()
-        val overscrollEffect2 = TestOverscrollEffect()
-
-        val factory1 =
-            object : OverscrollFactory {
-                override fun createOverscrollEffect(): OverscrollEffect = overscrollEffect1
-
-                override fun equals(other: Any?): Boolean = other === this
-
-                override fun hashCode(): Int = -1
-            }
-
-        val factory2 =
-            object : OverscrollFactory {
-                override fun createOverscrollEffect(): OverscrollEffect = overscrollEffect2
-
-                override fun equals(other: Any?): Boolean = other === this
-
-                override fun hashCode(): Int = -2
-            }
-
-        var factory by mutableStateOf<OverscrollFactory>(factory1)
-
-        rule.setContent {
-            CompositionLocalProvider(LocalOverscrollFactory provides factory) {
-                Box(
-                    Modifier.scrollingContainer(
-                        rememberScrollState(),
-                        orientation = Horizontal,
-                        enabled = true,
-                        reverseScrolling = false,
-                        flingBehavior = null,
-                        interactionSource = null,
-                        useLocalOverscrollFactory = true,
-                        overscrollEffect = null,
-                        bringIntoViewSpec = null
-                    )
-                )
-            }
-        }
-
-        rule.runOnIdle {
-            assertThat(overscrollEffect1.node.node.isAttached).isTrue()
-            assertThat(overscrollEffect2.node.node.isAttached).isFalse()
-            factory = factory2
-        }
-
-        // The old node should be detached, and the new one should be attached
-        rule.runOnIdle {
-            assertThat(overscrollEffect1.node.node.isAttached).isFalse()
-            assertThat(overscrollEffect2.node.node.isAttached).isTrue()
-        }
-    }
-
-    @Test
-    fun updatesBetweenProvidedOverscrollEffectAndLocalOverscrollFactory() {
-        val overscrollEffect1 = TestOverscrollEffect()
-        val overscrollEffect2 = TestOverscrollEffect()
-
-        val factory =
-            object : OverscrollFactory {
-                override fun createOverscrollEffect(): OverscrollEffect = overscrollEffect1
-
-                override fun equals(other: Any?): Boolean = other === this
-
-                override fun hashCode(): Int = -1
-            }
-
-        var useLocalOverscrollFactory by mutableStateOf(true)
-
-        rule.setContent {
-            CompositionLocalProvider(LocalOverscrollFactory provides factory) {
-                Box(
-                    Modifier.scrollingContainer(
-                        rememberScrollState(),
-                        orientation = Horizontal,
-                        enabled = true,
-                        reverseScrolling = false,
-                        flingBehavior = null,
-                        interactionSource = null,
-                        useLocalOverscrollFactory = useLocalOverscrollFactory,
-                        overscrollEffect = overscrollEffect2,
-                        bringIntoViewSpec = null
-                    )
-                )
-            }
-        }
-
-        // useLocalOverscrollFactory = true, so it will override the overscrollEffect2 we set
-        // on the modifier
-        rule.runOnIdle {
-            assertThat(overscrollEffect1.node.node.isAttached).isTrue()
-            assertThat(overscrollEffect2.node.node.isAttached).isFalse()
-            useLocalOverscrollFactory = false
-        }
-
-        // The factory-provided node should be detached, and the explicit node should be attached
-        rule.runOnIdle {
-            assertThat(overscrollEffect1.node.node.isAttached).isFalse()
-            assertThat(overscrollEffect2.node.node.isAttached).isTrue()
-            // Use the factory again
-            useLocalOverscrollFactory = true
-        }
-
-        // useLocalOverscrollFactory = true, so it should be used again
-        rule.runOnIdle {
-            assertThat(overscrollEffect1.node.node.isAttached).isTrue()
-            assertThat(overscrollEffect2.node.node.isAttached).isFalse()
-        }
-    }
-
-    @Test
-    fun changesToProvidedOverscrollEffectIgnoredIfUseLocalOverscrollFactoryTrue() {
-        val overscrollEffect1 = TestOverscrollEffect()
-        val overscrollEffect2 = TestOverscrollEffect()
-        var creationCalls = 0
-
-        val factory =
-            object : OverscrollFactory {
-                override fun createOverscrollEffect(): OverscrollEffect {
-                    creationCalls++
-                    return overscrollEffect1
-                }
-
-                override fun equals(other: Any?): Boolean = other === this
-
-                override fun hashCode(): Int = -1
-            }
-
-        var overscrollEffect by mutableStateOf<OverscrollEffect?>(null)
-
-        rule.setContent {
-            CompositionLocalProvider(LocalOverscrollFactory provides factory) {
-                Box(
-                    Modifier.scrollingContainer(
-                        rememberScrollState(),
-                        orientation = Horizontal,
-                        enabled = true,
-                        reverseScrolling = false,
-                        flingBehavior = null,
-                        interactionSource = null,
-                        useLocalOverscrollFactory = true,
-                        overscrollEffect = overscrollEffect,
-                        bringIntoViewSpec = null
-                    )
-                )
-            }
-        }
-
-        rule.runOnIdle {
-            assertThat(creationCalls).isEqualTo(1)
-            assertThat(overscrollEffect1.node.node.isAttached).isTrue()
-            assertThat(overscrollEffect2.node.node.isAttached).isFalse()
-            // Change the provided overscrollEffect - this should no-op as useLocalOverscrollFactory
-            // is true
-            overscrollEffect = overscrollEffect2
-        }
-
-        rule.runOnIdle {
-            // create should not be called again on the factory
-            assertThat(creationCalls).isEqualTo(1)
-            assertThat(overscrollEffect1.node.node.isAttached).isTrue()
-            assertThat(overscrollEffect2.node.node.isAttached).isFalse()
-        }
-    }
-
     private fun Modifier.drawOutsideOfBounds() = drawBehind {
         val inflate = 20.dp.roundToPx().toFloat()
         drawRect(
diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/contextmenu/ContextMenuCommon.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/contextmenu/ContextMenuCommon.kt
index 52d4514..5719d943 100644
--- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/contextmenu/ContextMenuCommon.kt
+++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/contextmenu/ContextMenuCommon.kt
@@ -161,6 +161,7 @@
     internal const val COPY = "Copy"
     internal const val PASTE = "Paste"
     internal const val SELECT_ALL = "Select all"
+    internal const val AUTOFILL = "Autofill"
 }
 
 internal fun ComposeTestRule.contextMenuItemInteraction(
@@ -183,6 +184,7 @@
     copyState: ContextMenuItemState,
     pasteState: ContextMenuItemState,
     selectAllState: ContextMenuItemState,
+    autofillState: ContextMenuItemState,
 ) {
     val contextMenuInteraction = onNode(isPopup())
     contextMenuInteraction.assertExists("Context Menu should exist.")
@@ -191,6 +193,7 @@
     assertContextMenuItem(label = ContextMenuItemLabels.COPY, state = copyState)
     assertContextMenuItem(label = ContextMenuItemLabels.PASTE, state = pasteState)
     assertContextMenuItem(label = ContextMenuItemLabels.SELECT_ALL, state = selectAllState)
+    assertContextMenuItem(label = ContextMenuItemLabels.AUTOFILL, state = autofillState)
 }
 
 private fun ComposeTestRule.assertContextMenuItem(label: String, state: ContextMenuItemState) {
diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/selection/SelectionContainerContextMenuTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/selection/SelectionContainerContextMenuTest.kt
index 3280db6..6ebdd3f 100644
--- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/selection/SelectionContainerContextMenuTest.kt
+++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/selection/SelectionContainerContextMenuTest.kt
@@ -182,6 +182,7 @@
                 copyState = ContextMenuItemState.DOES_NOT_EXIST,
                 pasteState = ContextMenuItemState.DOES_NOT_EXIST,
                 selectAllState = ContextMenuItemState.ENABLED,
+                autofillState = ContextMenuItemState.DOES_NOT_EXIST,
             )
         }
 
@@ -197,6 +198,7 @@
                 copyState = ContextMenuItemState.ENABLED,
                 pasteState = ContextMenuItemState.DOES_NOT_EXIST,
                 selectAllState = ContextMenuItemState.ENABLED,
+                autofillState = ContextMenuItemState.DOES_NOT_EXIST,
             )
         }
 
@@ -212,6 +214,7 @@
                 copyState = ContextMenuItemState.ENABLED,
                 pasteState = ContextMenuItemState.DOES_NOT_EXIST,
                 selectAllState = ContextMenuItemState.DOES_NOT_EXIST,
+                autofillState = ContextMenuItemState.DOES_NOT_EXIST,
             )
         }
 
diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/textfield/TextFieldContextMenuTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/textfield/TextFieldContextMenuTest.kt
index a4c56a3..f79f7644 100644
--- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/textfield/TextFieldContextMenuTest.kt
+++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/textfield/TextFieldContextMenuTest.kt
@@ -50,6 +50,7 @@
 import androidx.compose.ui.unit.lerp
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.MediumTest
+import androidx.test.filters.SdkSuppress
 import com.google.common.truth.Truth.assertThat
 import org.junit.Rule
 import org.junit.Test
@@ -230,7 +231,8 @@
 
     // region BTF1 Context Menu Correct Item Tests
     @Test
-    fun btf1_contextMenu_emptyClipboard_noSelection_itemsMatch() =
+    @SdkSuppress(maxSdkVersion = 25)
+    fun btf1_contextMenu_emptyClipboard_noSelection_itemsMatch_beforeApi26() =
         runBtf1CorrectItemsTest(
             isEmptyClipboard = true,
             selectionAmount = SelectionAmount.NONE,
@@ -240,6 +242,23 @@
                 copyState = ContextMenuItemState.DOES_NOT_EXIST,
                 pasteState = ContextMenuItemState.DOES_NOT_EXIST,
                 selectAllState = ContextMenuItemState.ENABLED,
+                autofillState = ContextMenuItemState.DOES_NOT_EXIST,
+            )
+        }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 26)
+    fun btf1_contextMenu_emptyClipboard_noSelection_itemsMatch_afterApi26() =
+        runBtf1CorrectItemsTest(
+            isEmptyClipboard = true,
+            selectionAmount = SelectionAmount.NONE,
+        ) {
+            rule.assertContextMenuItems(
+                cutState = ContextMenuItemState.DOES_NOT_EXIST,
+                copyState = ContextMenuItemState.DOES_NOT_EXIST,
+                pasteState = ContextMenuItemState.DOES_NOT_EXIST,
+                selectAllState = ContextMenuItemState.ENABLED,
+                autofillState = ContextMenuItemState.ENABLED,
             )
         }
 
@@ -254,6 +273,7 @@
                 copyState = ContextMenuItemState.ENABLED,
                 pasteState = ContextMenuItemState.DOES_NOT_EXIST,
                 selectAllState = ContextMenuItemState.ENABLED,
+                autofillState = ContextMenuItemState.DOES_NOT_EXIST,
             )
         }
 
@@ -268,11 +288,13 @@
                 copyState = ContextMenuItemState.ENABLED,
                 pasteState = ContextMenuItemState.DOES_NOT_EXIST,
                 selectAllState = ContextMenuItemState.DOES_NOT_EXIST,
+                autofillState = ContextMenuItemState.DOES_NOT_EXIST,
             )
         }
 
     @Test
-    fun btf1_contextMenu_nonEmptyClipboard_noSelection_itemsMatch() =
+    @SdkSuppress(maxSdkVersion = 25)
+    fun btf1_contextMenu_nonEmptyClipboard_noSelection_itemsMatch_beforeApi26() =
         runBtf1CorrectItemsTest(
             isEmptyClipboard = false,
             selectionAmount = SelectionAmount.NONE,
@@ -282,6 +304,23 @@
                 copyState = ContextMenuItemState.DOES_NOT_EXIST,
                 pasteState = ContextMenuItemState.ENABLED,
                 selectAllState = ContextMenuItemState.ENABLED,
+                autofillState = ContextMenuItemState.DOES_NOT_EXIST,
+            )
+        }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 26)
+    fun btf1_contextMenu_nonEmptyClipboard_noSelection_itemsMatch_afterApi26() =
+        runBtf1CorrectItemsTest(
+            isEmptyClipboard = false,
+            selectionAmount = SelectionAmount.NONE,
+        ) {
+            rule.assertContextMenuItems(
+                cutState = ContextMenuItemState.DOES_NOT_EXIST,
+                copyState = ContextMenuItemState.DOES_NOT_EXIST,
+                pasteState = ContextMenuItemState.ENABLED,
+                selectAllState = ContextMenuItemState.ENABLED,
+                autofillState = ContextMenuItemState.ENABLED,
             )
         }
 
@@ -298,6 +337,7 @@
                 copyState = ContextMenuItemState.ENABLED,
                 pasteState = ContextMenuItemState.ENABLED,
                 selectAllState = ContextMenuItemState.ENABLED,
+                autofillState = ContextMenuItemState.DOES_NOT_EXIST,
             )
         }
 
@@ -312,11 +352,13 @@
                 copyState = ContextMenuItemState.ENABLED,
                 pasteState = ContextMenuItemState.ENABLED,
                 selectAllState = ContextMenuItemState.DOES_NOT_EXIST,
+                autofillState = ContextMenuItemState.DOES_NOT_EXIST,
             )
         }
 
     @Test
-    fun btf1_contextMenu_password_noSelection_itemsMatch() =
+    @SdkSuppress(maxSdkVersion = 25)
+    fun btf1_contextMenu_password_noSelection_itemsMatch_beforeApi26() =
         runBtf1CorrectItemsTest(
             isPassword = true,
             selectionAmount = SelectionAmount.NONE,
@@ -326,6 +368,23 @@
                 copyState = ContextMenuItemState.DOES_NOT_EXIST,
                 pasteState = ContextMenuItemState.ENABLED,
                 selectAllState = ContextMenuItemState.ENABLED,
+                autofillState = ContextMenuItemState.DOES_NOT_EXIST,
+            )
+        }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 26)
+    fun btf1_contextMenu_password_noSelection_itemsMatch_afterApi26() =
+        runBtf1CorrectItemsTest(
+            isPassword = true,
+            selectionAmount = SelectionAmount.NONE,
+        ) {
+            rule.assertContextMenuItems(
+                cutState = ContextMenuItemState.DOES_NOT_EXIST,
+                copyState = ContextMenuItemState.DOES_NOT_EXIST,
+                pasteState = ContextMenuItemState.ENABLED,
+                selectAllState = ContextMenuItemState.ENABLED,
+                autofillState = ContextMenuItemState.ENABLED,
             )
         }
 
@@ -340,6 +399,7 @@
                 copyState = ContextMenuItemState.DOES_NOT_EXIST,
                 pasteState = ContextMenuItemState.ENABLED,
                 selectAllState = ContextMenuItemState.ENABLED,
+                autofillState = ContextMenuItemState.DOES_NOT_EXIST,
             )
         }
 
@@ -354,6 +414,7 @@
                 copyState = ContextMenuItemState.DOES_NOT_EXIST,
                 pasteState = ContextMenuItemState.ENABLED,
                 selectAllState = ContextMenuItemState.DOES_NOT_EXIST,
+                autofillState = ContextMenuItemState.DOES_NOT_EXIST,
             )
         }
 
@@ -369,6 +430,7 @@
                 copyState = ContextMenuItemState.DOES_NOT_EXIST,
                 pasteState = ContextMenuItemState.DOES_NOT_EXIST,
                 selectAllState = ContextMenuItemState.ENABLED,
+                autofillState = ContextMenuItemState.DOES_NOT_EXIST,
             )
         }
 
@@ -384,6 +446,7 @@
                 copyState = ContextMenuItemState.ENABLED,
                 pasteState = ContextMenuItemState.DOES_NOT_EXIST,
                 selectAllState = ContextMenuItemState.ENABLED,
+                autofillState = ContextMenuItemState.DOES_NOT_EXIST,
             )
         }
 
@@ -399,6 +462,7 @@
                 copyState = ContextMenuItemState.ENABLED,
                 pasteState = ContextMenuItemState.DOES_NOT_EXIST,
                 selectAllState = ContextMenuItemState.DOES_NOT_EXIST,
+                autofillState = ContextMenuItemState.DOES_NOT_EXIST,
             )
         }
 
@@ -597,7 +661,8 @@
 
     // region BTF2 Context Menu Correct Item Tests
     @Test
-    fun btf2_contextMenu_emptyClipboard_noSelection_itemsMatch() =
+    @SdkSuppress(maxSdkVersion = 25)
+    fun btf2_contextMenu_emptyClipboard_noSelection_itemsMatch_beforeApi26() =
         runBtf2CorrectItemsTest(
             isEmptyClipboard = true,
             selectionAmount = SelectionAmount.NONE,
@@ -607,6 +672,23 @@
                 copyState = ContextMenuItemState.DOES_NOT_EXIST,
                 pasteState = ContextMenuItemState.DOES_NOT_EXIST,
                 selectAllState = ContextMenuItemState.ENABLED,
+                autofillState = ContextMenuItemState.DOES_NOT_EXIST,
+            )
+        }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 26)
+    fun btf2_contextMenu_emptyClipboard_noSelection_itemsMatch_afterApi26() =
+        runBtf2CorrectItemsTest(
+            isEmptyClipboard = true,
+            selectionAmount = SelectionAmount.NONE,
+        ) {
+            rule.assertContextMenuItems(
+                cutState = ContextMenuItemState.DOES_NOT_EXIST,
+                copyState = ContextMenuItemState.DOES_NOT_EXIST,
+                pasteState = ContextMenuItemState.DOES_NOT_EXIST,
+                selectAllState = ContextMenuItemState.ENABLED,
+                autofillState = ContextMenuItemState.ENABLED,
             )
         }
 
@@ -621,6 +703,7 @@
                 copyState = ContextMenuItemState.ENABLED,
                 pasteState = ContextMenuItemState.DOES_NOT_EXIST,
                 selectAllState = ContextMenuItemState.ENABLED,
+                autofillState = ContextMenuItemState.DOES_NOT_EXIST,
             )
         }
 
@@ -635,11 +718,13 @@
                 copyState = ContextMenuItemState.ENABLED,
                 pasteState = ContextMenuItemState.DOES_NOT_EXIST,
                 selectAllState = ContextMenuItemState.DOES_NOT_EXIST,
+                autofillState = ContextMenuItemState.DOES_NOT_EXIST,
             )
         }
 
     @Test
-    fun btf2_contextMenu_nonEmptyClipboard_noSelection_itemsMatch() =
+    @SdkSuppress(maxSdkVersion = 25)
+    fun btf2_contextMenu_nonEmptyClipboard_noSelection_itemsMatch_beforeApi26() =
         runBtf2CorrectItemsTest(
             isEmptyClipboard = false,
             selectionAmount = SelectionAmount.NONE,
@@ -649,6 +734,23 @@
                 copyState = ContextMenuItemState.DOES_NOT_EXIST,
                 pasteState = ContextMenuItemState.ENABLED,
                 selectAllState = ContextMenuItemState.ENABLED,
+                autofillState = ContextMenuItemState.DOES_NOT_EXIST,
+            )
+        }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 26)
+    fun btf2_contextMenu_nonEmptyClipboard_noSelection_itemsMatch_afterApi26() =
+        runBtf2CorrectItemsTest(
+            isEmptyClipboard = false,
+            selectionAmount = SelectionAmount.NONE,
+        ) {
+            rule.assertContextMenuItems(
+                cutState = ContextMenuItemState.DOES_NOT_EXIST,
+                copyState = ContextMenuItemState.DOES_NOT_EXIST,
+                pasteState = ContextMenuItemState.ENABLED,
+                selectAllState = ContextMenuItemState.ENABLED,
+                autofillState = ContextMenuItemState.ENABLED,
             )
         }
 
@@ -663,6 +765,7 @@
                 copyState = ContextMenuItemState.ENABLED,
                 pasteState = ContextMenuItemState.ENABLED,
                 selectAllState = ContextMenuItemState.ENABLED,
+                autofillState = ContextMenuItemState.DOES_NOT_EXIST,
             )
         }
 
@@ -677,11 +780,13 @@
                 copyState = ContextMenuItemState.ENABLED,
                 pasteState = ContextMenuItemState.ENABLED,
                 selectAllState = ContextMenuItemState.DOES_NOT_EXIST,
+                autofillState = ContextMenuItemState.DOES_NOT_EXIST,
             )
         }
 
     @Test
-    fun btf2_contextMenu_password_noSelection_itemsMatch() =
+    @SdkSuppress(maxSdkVersion = 25)
+    fun btf2_contextMenu_password_noSelection_itemsMatch_beforeApi26() =
         runBtf2CorrectItemsTest(
             isPassword = true,
             selectionAmount = SelectionAmount.NONE,
@@ -691,6 +796,23 @@
                 copyState = ContextMenuItemState.DOES_NOT_EXIST,
                 pasteState = ContextMenuItemState.ENABLED,
                 selectAllState = ContextMenuItemState.ENABLED,
+                autofillState = ContextMenuItemState.DOES_NOT_EXIST,
+            )
+        }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 26)
+    fun btf2_contextMenu_password_noSelection_itemsMatch_afterApi26() =
+        runBtf2CorrectItemsTest(
+            isPassword = true,
+            selectionAmount = SelectionAmount.NONE,
+        ) {
+            rule.assertContextMenuItems(
+                cutState = ContextMenuItemState.DOES_NOT_EXIST,
+                copyState = ContextMenuItemState.DOES_NOT_EXIST,
+                pasteState = ContextMenuItemState.ENABLED,
+                selectAllState = ContextMenuItemState.ENABLED,
+                autofillState = ContextMenuItemState.ENABLED,
             )
         }
 
@@ -705,6 +827,7 @@
                 copyState = ContextMenuItemState.DOES_NOT_EXIST,
                 pasteState = ContextMenuItemState.ENABLED,
                 selectAllState = ContextMenuItemState.ENABLED,
+                autofillState = ContextMenuItemState.DOES_NOT_EXIST,
             )
         }
 
@@ -719,6 +842,7 @@
                 copyState = ContextMenuItemState.DOES_NOT_EXIST,
                 pasteState = ContextMenuItemState.ENABLED,
                 selectAllState = ContextMenuItemState.DOES_NOT_EXIST,
+                autofillState = ContextMenuItemState.DOES_NOT_EXIST,
             )
         }
 
@@ -734,6 +858,7 @@
                 copyState = ContextMenuItemState.DOES_NOT_EXIST,
                 pasteState = ContextMenuItemState.DOES_NOT_EXIST,
                 selectAllState = ContextMenuItemState.ENABLED,
+                autofillState = ContextMenuItemState.DOES_NOT_EXIST,
             )
         }
 
@@ -749,6 +874,7 @@
                 copyState = ContextMenuItemState.ENABLED,
                 pasteState = ContextMenuItemState.DOES_NOT_EXIST,
                 selectAllState = ContextMenuItemState.ENABLED,
+                autofillState = ContextMenuItemState.DOES_NOT_EXIST,
             )
         }
 
@@ -764,6 +890,7 @@
                 copyState = ContextMenuItemState.ENABLED,
                 pasteState = ContextMenuItemState.DOES_NOT_EXIST,
                 selectAllState = ContextMenuItemState.DOES_NOT_EXIST,
+                autofillState = ContextMenuItemState.DOES_NOT_EXIST,
             )
         }
 
diff --git a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/AndroidOverscroll.android.kt b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/AndroidOverscroll.android.kt
index a47732e..410f064 100644
--- a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/AndroidOverscroll.android.kt
+++ b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/AndroidOverscroll.android.kt
@@ -36,6 +36,7 @@
 import androidx.compose.runtime.CompositionLocalAccessorScope
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.neverEqualPolicy
+import androidx.compose.runtime.remember
 import androidx.compose.ui.geometry.Offset
 import androidx.compose.ui.geometry.Size
 import androidx.compose.ui.geometry.center
@@ -88,10 +89,38 @@
     return AndroidEdgeEffectOverscrollFactory(context, density, glowColor, glowDrawPadding)
 }
 
+@OptIn(ExperimentalFoundationApi::class)
+@Suppress("DEPRECATION")
 internal actual fun CompositionLocalAccessorScope.defaultOverscrollFactory(): OverscrollFactory? {
     val context = LocalContext.currentValue
     val density = LocalDensity.currentValue
-    return AndroidEdgeEffectOverscrollFactory(context, density)
+    val config = LocalOverscrollConfiguration.currentValue
+    return if (config == null) {
+        null
+    } else {
+        AndroidEdgeEffectOverscrollFactory(context, density, config.glowColor, config.drawPadding)
+    }
+}
+
+@OptIn(ExperimentalFoundationApi::class)
+@Suppress("DEPRECATION")
+@Composable
+internal actual fun rememberPlatformOverscrollEffect(): OverscrollEffect? {
+    val context = LocalContext.current
+    val density = LocalDensity.current
+    val config = LocalOverscrollConfiguration.current
+    return if (config == null) {
+        null
+    } else {
+        remember(context, density, config) {
+            AndroidEdgeEffectOverscrollEffect(
+                context,
+                density,
+                config.glowColor,
+                config.drawPadding
+            )
+        }
+    }
 }
 
 private class AndroidEdgeEffectOverscrollFactory(
diff --git a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/OverscrollConfiguration.android.kt b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/OverscrollConfiguration.android.kt
new file mode 100644
index 0000000..a99fecf
--- /dev/null
+++ b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/OverscrollConfiguration.android.kt
@@ -0,0 +1,79 @@
+/*
+ * Copyright 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+@file:Suppress("DEPRECATION")
+
+package androidx.compose.foundation
+
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.compositionLocalOf
+import androidx.compose.ui.graphics.Color
+
+/**
+ * Metadata for overscroll effects for android platform.
+ *
+ * @param glowColor color for the glow effect, if the platform effect is a glow effect, otherwise
+ *   ignored.
+ * @param drawPadding the amount of padding to apply from scrollable container bounds to the effect
+ *   before drawing it, if the platform effect is a glow effect, otherwise ignored.
+ */
+@Deprecated(
+    "Providing `OverscrollConfiguration` through `LocalOverscrollConfiguration` to disable / configure overscroll has been replaced with `LocalOverscrollFactory` and `rememberPlatformOverscrollFactory`. To disable overscroll, instead of `LocalOverscrollConfiguration provides null`, use `LocalOverscrollFactory provides null`. To change the glow color / padding, instead of `LocalOverscrollConfiguration provides OverscrollConfiguration(myColor, myPadding)`, use `LocalOverscrollFactory provides rememberPlatformOverscrollFactory(myColor, myPadding)`"
+)
+@ExperimentalFoundationApi
+@Stable
+class OverscrollConfiguration(
+    val glowColor: Color = Color(0xff666666), // taken from EdgeEffect.java defaults
+    val drawPadding: PaddingValues = PaddingValues()
+) {
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        if (javaClass != other?.javaClass) return false
+
+        other as OverscrollConfiguration
+
+        if (glowColor != other.glowColor) return false
+        if (drawPadding != other.drawPadding) return false
+
+        return true
+    }
+
+    override fun hashCode(): Int {
+        var result = glowColor.hashCode()
+        result = 31 * result + drawPadding.hashCode()
+        return result
+    }
+
+    override fun toString(): String {
+        return "OverscrollConfiguration(glowColor=$glowColor, drawPadding=$drawPadding)"
+    }
+}
+
+/**
+ * Composition local to provide configuration for scrolling containers down the hierarchy. `null`
+ * means there will be no overscroll at all.
+ */
+@Deprecated(
+    "Providing `OverscrollConfiguration` through `LocalOverscrollConfiguration` to disable / configure overscroll has been replaced with `LocalOverscrollFactory` and `rememberPlatformOverscrollFactory`. To disable overscroll, instead of `LocalOverscrollConfiguration provides null`, use `LocalOverscrollFactory provides null`. To change the glow color / padding, instead of `LocalOverscrollConfiguration provides OverscrollConfiguration(myColor, myPadding)`, use `LocalOverscrollFactory provides rememberPlatformOverscrollFactory(myColor, myPadding)`",
+    replaceWith =
+        ReplaceWith("LocalOverscrollFactory", "androidx.compose.foundation.LocalOverscrollFactory")
+)
+@Suppress("OPT_IN_MARKER_ON_WRONG_TARGET")
+@ExperimentalFoundationApi
+@get:ExperimentalFoundationApi
+val LocalOverscrollConfiguration =
+    compositionLocalOf<OverscrollConfiguration?> { OverscrollConfiguration() }
diff --git a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/ContextMenu.android.kt b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/ContextMenu.android.kt
index afda250..5eb18f5 100644
--- a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/ContextMenu.android.kt
+++ b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/ContextMenu.android.kt
@@ -16,6 +16,8 @@
 
 package androidx.compose.foundation.text
 
+import android.os.Build
+import androidx.compose.foundation.R
 import androidx.compose.foundation.contextmenu.ContextMenuScope
 import androidx.compose.foundation.contextmenu.ContextMenuState
 import androidx.compose.foundation.contextmenu.close
@@ -80,7 +82,14 @@
     Cut(android.R.string.cut),
     Copy(android.R.string.copy),
     Paste(android.R.string.paste),
-    SelectAll(android.R.string.selectAll);
+    SelectAll(android.R.string.selectAll),
+    Autofill(
+        if (Build.VERSION.SDK_INT <= 26) {
+            R.string.autofill
+        } else {
+            android.R.string.autofill
+        }
+    );
 
     @ReadOnlyComposable @Composable fun resolvedString(): String = stringResource(stringId)
 }
diff --git a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldSelectionState.android.kt b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldSelectionState.android.kt
index e770393..d1445cd 100644
--- a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldSelectionState.android.kt
+++ b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldSelectionState.android.kt
@@ -16,6 +16,7 @@
 
 package androidx.compose.foundation.text.input.internal.selection
 
+import android.os.Build
 import androidx.compose.foundation.contextmenu.ContextMenuScope
 import androidx.compose.foundation.contextmenu.ContextMenuState
 import androidx.compose.foundation.text.TextContextMenuItems
@@ -30,4 +31,7 @@
     }
     TextItem(state, TextContextMenuItems.Paste, enabled = canPaste()) { paste() }
     TextItem(state, TextContextMenuItems.SelectAll, enabled = canSelectAll()) { selectAll() }
+    if (Build.VERSION.SDK_INT >= 26) {
+        TextItem(state, TextContextMenuItems.Autofill, enabled = canAutofill()) { autofill() }
+    }
 }
diff --git a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/selection/TextFieldSelectionManager.android.kt b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/selection/TextFieldSelectionManager.android.kt
index d04bdd6..6e08546 100644
--- a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/selection/TextFieldSelectionManager.android.kt
+++ b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/selection/TextFieldSelectionManager.android.kt
@@ -16,6 +16,7 @@
 
 package androidx.compose.foundation.text.selection
 
+import android.os.Build
 import androidx.compose.foundation.PlatformMagnifierFactory
 import androidx.compose.foundation.contextmenu.ContextMenuScope
 import androidx.compose.foundation.contextmenu.ContextMenuState
@@ -100,4 +101,13 @@
     ) {
         selectAll()
     }
+    if (Build.VERSION.SDK_INT >= 26) {
+        TextItem(
+            state = contextMenuState,
+            label = TextContextMenuItems.Autofill,
+            enabled = editable && value.selection.collapsed
+        ) {
+            autofill()
+        }
+    }
 }
diff --git a/compose/foundation/foundation/src/androidMain/res/values/strings.xml b/compose/foundation/foundation/src/androidMain/res/values/strings.xml
index cb6255c..f1a2c47 100644
--- a/compose/foundation/foundation/src/androidMain/res/values/strings.xml
+++ b/compose/foundation/foundation/src/androidMain/res/values/strings.xml
@@ -17,4 +17,6 @@
 <resources>
     <string name="tooltip_description">tooltip</string>
     <string name="tooltip_label">show tooltip</string>
+    <!-- Item on context menu. This action is used to request autofill. -->
+    <string name="autofill">Autofill</string>
 </resources>
\ No newline at end of file
diff --git a/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/selection/TextFieldSelectionManagerTest.kt b/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/selection/TextFieldSelectionManagerTest.kt
index af1ba42..4de2745 100644
--- a/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/selection/TextFieldSelectionManagerTest.kt
+++ b/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/selection/TextFieldSelectionManagerTest.kt
@@ -16,10 +16,13 @@
 
 package androidx.compose.foundation.text.selection
 
+import android.os.Build
+import androidx.annotation.RequiresApi
 import androidx.compose.foundation.text.HandleState
 import androidx.compose.foundation.text.LegacyTextFieldState
 import androidx.compose.foundation.text.TextDelegate
 import androidx.compose.foundation.text.TextLayoutResultProxy
+import androidx.compose.ui.autofill.AutofillManager
 import androidx.compose.ui.focus.FocusRequester
 import androidx.compose.ui.geometry.Offset
 import androidx.compose.ui.geometry.Rect
@@ -90,6 +93,7 @@
     private val hapticFeedback = mock<HapticFeedback>()
     private val focusRequester = mock<FocusRequester>()
     private val multiParagraph = mock<MultiParagraph>()
+    private val autofillManager = mock<AutofillManager>()
 
     @Before
     fun setup() {
@@ -101,6 +105,7 @@
         manager.textToolbar = textToolbar
         manager.hapticFeedBack = hapticFeedback
         manager.focusRequester = focusRequester
+        manager.autofillManager = autofillManager
 
         whenever(layoutResult.layoutInput)
             .thenReturn(
@@ -355,6 +360,17 @@
         assertThat(state.handleState).isEqualTo(HandleState.None)
     }
 
+    @RequiresApi(Build.VERSION_CODES.O)
+    @Test
+    fun autofill_selection_collapse() {
+        manager.value = TextFieldValue(text = text, selection = TextRange(4, 4))
+
+        manager.autofill()
+
+        verify(autofillManager, times(1)).requestAutofillForActiveElement()
+        assertThat(state.handleState).isEqualTo(HandleState.None)
+    }
+
     @Test
     fun copy_selection_collapse() {
         manager.value = TextFieldValue(text = text, selection = TextRange(4, 4))
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/Overscroll.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/Overscroll.kt
index 12c6ba4..03760b9 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/Overscroll.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/Overscroll.kt
@@ -37,6 +37,9 @@
  * container have been reached with a scroll or fling. To create an instance of the default /
  * currently provided [OverscrollFactory], use [rememberOverscrollEffect].
  *
+ * To implement, make sure to override [node] - this has a default implementation for compatibility
+ * reasons, but is required for an OverscrollEffect to render.
+ *
  * OverscrollEffect conceptually 'decorates' scroll / fling events: consuming some of the delta or
  * velocity before and/or after the event is consumed by the scrolling container. [applyToScroll]
  * applies overscroll to a scroll event, and [applyToFling] applies overscroll to a fling.
@@ -121,6 +124,21 @@
     val isInProgress: Boolean
 
     /**
+     * A [Modifier] that will draw this OverscrollEffect
+     *
+     * This API is deprecated- implementers should instead override [node]. Callers should use
+     * [Modifier.overscroll].
+     */
+    @Deprecated(
+        "This has been replaced with `node`. If you are calling this property to render overscroll, use Modifier.overscroll() instead. If you are implementing OverscrollEffect, override `node` instead to render your overscroll.",
+        level = DeprecationLevel.ERROR,
+        replaceWith =
+            ReplaceWith("Modifier.overscroll(this)", "androidx.compose.foundation.overscroll")
+    )
+    val effectModifier: Modifier
+        get() = Modifier
+
+    /**
      * The [DelegatableNode] that will render this OverscrollEffect and provide any required size or
      * other information to this effect.
      *
@@ -132,6 +150,7 @@
      * [DelegatableNode]s.
      */
     val node: DelegatableNode
+        get() = object : Modifier.Node() {}
 }
 
 /**
@@ -250,8 +269,14 @@
  * @sample androidx.compose.foundation.samples.OverscrollSample
  * @param overscrollEffect the [OverscrollEffect] to render
  */
-fun Modifier.overscroll(overscrollEffect: OverscrollEffect?): Modifier =
-    this.then(OverscrollModifierElement(overscrollEffect))
+@Suppress("DEPRECATION_ERROR")
+fun Modifier.overscroll(overscrollEffect: OverscrollEffect?): Modifier {
+    val effectModifier = overscrollEffect?.effectModifier ?: Modifier
+    val modifier =
+        if (effectModifier !== Modifier) effectModifier
+        else OverscrollModifierElement(overscrollEffect)
+    return this.then(modifier)
+}
 
 private class OverscrollModifierElement(
     private val overscrollEffect: OverscrollEffect?,
@@ -316,6 +341,14 @@
 }
 
 /**
+ * Needed for behavioral backwards compatibility for
+ * [androidx.compose.foundation.gestures.ScrollableDefaults.overscrollEffect]. New code should use
+ * [rememberOverscrollEffect] instead, which takes into account theme provided overscroll, rather
+ * than always using the platform default, without any customizations.
+ */
+@Composable internal expect fun rememberPlatformOverscrollEffect(): OverscrollEffect?
+
+/**
  * A factory for creating [OverscrollEffect]s. You can provide a factory instance to
  * [LocalOverscrollFactory] to globally change the factory, and hence effect, used by components
  * within the hierarchy.
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/Scroll.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/Scroll.kt
index 7a034224..0e256334 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/Scroll.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/Scroll.kt
@@ -38,6 +38,7 @@
 import androidx.compose.runtime.setValue
 import androidx.compose.runtime.snapshots.Snapshot
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.composed
 import androidx.compose.ui.layout.IntrinsicMeasurable
 import androidx.compose.ui.layout.IntrinsicMeasureScope
 import androidx.compose.ui.layout.Measurable
@@ -47,6 +48,7 @@
 import androidx.compose.ui.node.ModifierNodeElement
 import androidx.compose.ui.node.SemanticsModifierNode
 import androidx.compose.ui.platform.InspectorInfo
+import androidx.compose.ui.platform.debugInspectorInfo
 import androidx.compose.ui.semantics.ScrollAxisRange
 import androidx.compose.ui.semantics.SemanticsPropertyReceiver
 import androidx.compose.ui.semantics.horizontalScrollAxisRange
@@ -215,13 +217,24 @@
     flingBehavior: FlingBehavior? = null,
     reverseScrolling: Boolean = false
 ) =
-    scroll(
-        state = state,
-        isScrollable = enabled,
-        reverseScrolling = reverseScrolling,
-        flingBehavior = flingBehavior,
-        isVertical = true,
-        useLocalOverscrollFactory = true
+    composed(
+        factory = {
+            verticalScroll(
+                state = state,
+                enabled = enabled,
+                flingBehavior = flingBehavior,
+                reverseScrolling = reverseScrolling,
+                overscrollEffect = rememberOverscrollEffect(),
+            )
+        },
+        inspectorInfo =
+            debugInspectorInfo {
+                name = "verticalScroll"
+                properties["state"] = state
+                properties["enabled"] = enabled
+                properties["flingBehavior"] = flingBehavior
+                properties["reverseScrolling"] = reverseScrolling
+            }
     )
 
 /**
@@ -256,7 +269,6 @@
         reverseScrolling = reverseScrolling,
         flingBehavior = flingBehavior,
         isVertical = true,
-        useLocalOverscrollFactory = false,
         overscrollEffect = overscrollEffect
     )
 
@@ -284,13 +296,24 @@
     flingBehavior: FlingBehavior? = null,
     reverseScrolling: Boolean = false
 ) =
-    scroll(
-        state = state,
-        isScrollable = enabled,
-        reverseScrolling = reverseScrolling,
-        flingBehavior = flingBehavior,
-        isVertical = false,
-        useLocalOverscrollFactory = true
+    composed(
+        factory = {
+            horizontalScroll(
+                state = state,
+                enabled = enabled,
+                flingBehavior = flingBehavior,
+                reverseScrolling = reverseScrolling,
+                overscrollEffect = rememberOverscrollEffect(),
+            )
+        },
+        inspectorInfo =
+            debugInspectorInfo {
+                name = "horizontalScroll"
+                properties["state"] = state
+                properties["enabled"] = enabled
+                properties["flingBehavior"] = flingBehavior
+                properties["reverseScrolling"] = reverseScrolling
+            }
     )
 
 /**
@@ -325,7 +348,6 @@
         reverseScrolling = reverseScrolling,
         flingBehavior = flingBehavior,
         isVertical = false,
-        useLocalOverscrollFactory = false,
         overscrollEffect = overscrollEffect
     )
 
@@ -335,8 +357,7 @@
     flingBehavior: FlingBehavior?,
     isScrollable: Boolean,
     isVertical: Boolean,
-    useLocalOverscrollFactory: Boolean,
-    overscrollEffect: OverscrollEffect? = null
+    overscrollEffect: OverscrollEffect?
 ): Modifier {
     val orientation = if (isVertical) Orientation.Vertical else Orientation.Horizontal
     return scrollingContainer(
@@ -346,7 +367,6 @@
             reverseScrolling = reverseScrolling,
             flingBehavior = flingBehavior,
             interactionSource = state.internalInteractionSource,
-            useLocalOverscrollFactory = useLocalOverscrollFactory,
             overscrollEffect = overscrollEffect
         )
         .then(ScrollingLayoutElement(state, reverseScrolling, isVertical))
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/ScrollingContainer.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/ScrollingContainer.kt
index ec1a0c6..33a3730 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/ScrollingContainer.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/ScrollingContainer.kt
@@ -28,27 +28,18 @@
 import androidx.compose.ui.layout.Measurable
 import androidx.compose.ui.layout.MeasureResult
 import androidx.compose.ui.layout.MeasureScope
-import androidx.compose.ui.node.CompositionLocalConsumerModifierNode
 import androidx.compose.ui.node.DelegatableNode
 import androidx.compose.ui.node.DelegatingNode
 import androidx.compose.ui.node.LayoutModifierNode
 import androidx.compose.ui.node.ModifierNodeElement
-import androidx.compose.ui.node.ObserverModifierNode
-import androidx.compose.ui.node.currentValueOf
 import androidx.compose.ui.node.invalidatePlacement
-import androidx.compose.ui.node.observeReads
 import androidx.compose.ui.node.requireLayoutDirection
 import androidx.compose.ui.platform.InspectorInfo
 import androidx.compose.ui.unit.Constraints
 import androidx.compose.ui.unit.LayoutDirection
 
 // TODO b/316559454 to make it public
-/**
- * Scrolling related information to transform a layout into a "Scrollable Container" If
- * [useLocalOverscrollFactory] is false, [overscrollEffect] will be used. If
- * [useLocalOverscrollFactory] is true, [overscrollEffect] will be ignored and
- * [LocalOverscrollFactory] will be used instead internally.
- */
+/** Scrolling related information to transform a layout into a "Scrollable Container" */
 internal fun Modifier.scrollingContainer(
     state: ScrollableState,
     orientation: Orientation,
@@ -56,7 +47,6 @@
     reverseScrolling: Boolean,
     flingBehavior: FlingBehavior?,
     interactionSource: MutableInteractionSource?,
-    useLocalOverscrollFactory: Boolean,
     overscrollEffect: OverscrollEffect?,
     bringIntoViewSpec: BringIntoViewSpec? = null
 ): Modifier {
@@ -69,7 +59,6 @@
             flingBehavior = flingBehavior,
             interactionSource = interactionSource,
             bringIntoViewSpec = bringIntoViewSpec,
-            useLocalOverscrollFactory = useLocalOverscrollFactory,
             overscrollEffect = overscrollEffect
         )
     )
@@ -88,7 +77,6 @@
     private val flingBehavior: FlingBehavior?,
     private val interactionSource: MutableInteractionSource?,
     private val bringIntoViewSpec: BringIntoViewSpec?,
-    private val useLocalOverscrollFactory: Boolean,
     private val overscrollEffect: OverscrollEffect?
 ) : ModifierNodeElement<ScrollingContainerNode>() {
     override fun create(): ScrollingContainerNode {
@@ -100,7 +88,6 @@
             flingBehavior = flingBehavior,
             interactionSource = interactionSource,
             bringIntoViewSpec = bringIntoViewSpec,
-            useLocalOverscrollFactory = useLocalOverscrollFactory,
             overscrollEffect = overscrollEffect
         )
     }
@@ -109,7 +96,6 @@
         node.update(
             state = state,
             orientation = orientation,
-            useLocalOverscrollFactory = useLocalOverscrollFactory,
             overscrollEffect = overscrollEffect,
             enabled = enabled,
             reverseScrolling = reverseScrolling,
@@ -128,7 +114,6 @@
         properties["flingBehavior"] = flingBehavior
         properties["interactionSource"] = interactionSource
         properties["bringIntoViewSpec"] = bringIntoViewSpec
-        properties["useLocalOverscrollFactory"] = useLocalOverscrollFactory
         properties["overscrollEffect"] = overscrollEffect
     }
 
@@ -145,7 +130,6 @@
         if (flingBehavior != other.flingBehavior) return false
         if (interactionSource != other.interactionSource) return false
         if (bringIntoViewSpec != other.bringIntoViewSpec) return false
-        if (useLocalOverscrollFactory != other.useLocalOverscrollFactory) return false
         if (overscrollEffect != other.overscrollEffect) return false
 
         return true
@@ -159,7 +143,6 @@
         result = 31 * result + (flingBehavior?.hashCode() ?: 0)
         result = 31 * result + (interactionSource?.hashCode() ?: 0)
         result = 31 * result + (bringIntoViewSpec?.hashCode() ?: 0)
-        result = 31 * result + useLocalOverscrollFactory.hashCode()
         result = 31 * result + (overscrollEffect?.hashCode() ?: 0)
         return result
     }
@@ -173,17 +156,11 @@
     private var flingBehavior: FlingBehavior?,
     private var interactionSource: MutableInteractionSource?,
     private var bringIntoViewSpec: BringIntoViewSpec?,
-    private var useLocalOverscrollFactory: Boolean,
     private var overscrollEffect: OverscrollEffect?
-) :
-    DelegatingNode(),
-    LayoutModifierNode,
-    CompositionLocalConsumerModifierNode,
-    ObserverModifierNode {
+) : DelegatingNode(), LayoutModifierNode {
     override val shouldAutoInvalidate = false
     private var scrollableNode: ScrollableNode? = null
     private var overscrollNode: DelegatableNode? = null
-    private var localOverscrollFactory: OverscrollFactory? = null
     private var shouldReverseDirection = false
 
     // Needs to be mutated to properly update the underlying layer, which relies on instance
@@ -233,7 +210,6 @@
             update(
                 state,
                 orientation,
-                useLocalOverscrollFactory,
                 overscrollEffect,
                 enabled,
                 reverseScrolling,
@@ -247,7 +223,6 @@
     fun update(
         state: ScrollableState,
         orientation: Orientation,
-        useLocalOverscrollFactory: Boolean,
         overscrollEffect: OverscrollEffect?,
         enabled: Boolean,
         reverseScrolling: Boolean,
@@ -266,22 +241,8 @@
             }
             invalidatePlacement()
         }
-        var useLocalOverscrollFactoryChanged = false
-        if (this.useLocalOverscrollFactory != useLocalOverscrollFactory) {
-            useLocalOverscrollFactoryChanged = true
-            this.useLocalOverscrollFactory = useLocalOverscrollFactory
-        }
-        var overscrollEffectChanged = false
         if (this.overscrollEffect != overscrollEffect) {
-            overscrollEffectChanged = true
             this.overscrollEffect = overscrollEffect
-        }
-        if (
-            useLocalOverscrollFactoryChanged ||
-                // If the overscroll effect changed but we are still using the local factory, this
-                // should no-op
-                overscrollEffectChanged && !useLocalOverscrollFactory
-        ) {
             overscrollNode?.let { undelegate(it) }
             overscrollNode = null
             attachOverscrollNodeIfNeeded()
@@ -314,31 +275,11 @@
     }
 
     private fun attachOverscrollNodeIfNeeded() {
-        if (overscrollNode == null) {
-            var effect: OverscrollEffect? = overscrollEffect
-            // Overrides overscrollEffect if set
-            if (useLocalOverscrollFactory) {
-                observeReads {
-                    localOverscrollFactory = currentValueOf(LocalOverscrollFactory)
-                    effect = localOverscrollFactory?.createOverscrollEffect()
-                }
+        if (overscrollNode == null && overscrollEffect != null) {
+            val node = overscrollEffect!!.node
+            if (!node.node.isAttached) {
+                overscrollNode = delegate(node)
             }
-            if (effect != null) {
-                val node = effect!!.node
-                if (!node.node.isAttached) {
-                    overscrollNode = delegate(node)
-                }
-            }
-        }
-    }
-
-    override fun onObservedReadsChanged() {
-        val factory = currentValueOf(LocalOverscrollFactory)
-        if (factory != localOverscrollFactory) {
-            localOverscrollFactory = factory
-            overscrollNode?.let { undelegate(it) }
-            overscrollNode = null
-            attachOverscrollNodeIfNeeded()
         }
     }
 }
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/Scrollable.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/Scrollable.kt
index f8388e5..93725b7 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/Scrollable.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/Scrollable.kt
@@ -25,12 +25,15 @@
 import androidx.compose.foundation.ComposeFoundationFlags.NewNestedFlingPropagationEnabled
 import androidx.compose.foundation.ExperimentalFoundationApi
 import androidx.compose.foundation.FocusedBoundsObserverNode
+import androidx.compose.foundation.LocalOverscrollFactory
 import androidx.compose.foundation.MutatePriority
 import androidx.compose.foundation.OverscrollEffect
 import androidx.compose.foundation.gestures.Orientation.Horizontal
 import androidx.compose.foundation.gestures.Orientation.Vertical
 import androidx.compose.foundation.interaction.MutableInteractionSource
 import androidx.compose.foundation.relocation.BringIntoViewResponderNode
+import androidx.compose.foundation.rememberOverscrollEffect
+import androidx.compose.foundation.rememberPlatformOverscrollEffect
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.Stable
 import androidx.compose.runtime.remember
@@ -58,6 +61,7 @@
 import androidx.compose.ui.input.pointer.PointerInputChange
 import androidx.compose.ui.input.pointer.PointerType
 import androidx.compose.ui.node.CompositionLocalConsumerModifierNode
+import androidx.compose.ui.node.DelegatableNode
 import androidx.compose.ui.node.ModifierNodeElement
 import androidx.compose.ui.node.SemanticsModifierNode
 import androidx.compose.ui.node.TraversableNode
@@ -541,6 +545,46 @@
     }
 
     /**
+     * Returns a remembered [OverscrollEffect] created from the current value of
+     * [LocalOverscrollFactory].
+     *
+     * This API has been deprpecated, and replaced with [rememberOverscrollEffect]
+     */
+    @Deprecated(
+        "This API has been replaced with rememberOverscrollEffect, which queries theme provided OverscrollFactory values instead of the 'platform default' without customization.",
+        replaceWith =
+            ReplaceWith(
+                "rememberOverscrollEffect()",
+                "androidx.compose.foundation.rememberOverscrollEffect"
+            )
+    )
+    @Composable
+    fun overscrollEffect(): OverscrollEffect {
+        return rememberPlatformOverscrollEffect() ?: NoOpOverscrollEffect
+    }
+
+    private object NoOpOverscrollEffect : OverscrollEffect {
+        override fun applyToScroll(
+            delta: Offset,
+            source: NestedScrollSource,
+            performScroll: (Offset) -> Offset
+        ): Offset = performScroll(delta)
+
+        override suspend fun applyToFling(
+            velocity: Velocity,
+            performFling: suspend (Velocity) -> Velocity
+        ) {
+            performFling(velocity)
+        }
+
+        override val isInProgress: Boolean
+            get() = false
+
+        override val node: DelegatableNode
+            get() = object : Modifier.Node() {}
+    }
+
+    /**
      * Used to determine the value of `reverseDirection` parameter of [Modifier.scrollable] in
      * scrollable layouts.
      *
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyList.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyList.kt
index ea7e5c0..bc592cc 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyList.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyList.kt
@@ -146,7 +146,6 @@
                     reverseScrolling = reverseLayout,
                     flingBehavior = flingBehavior,
                     interactionSource = state.internalInteractionSource,
-                    useLocalOverscrollFactory = false,
                     overscrollEffect = overscrollEffect
                 ),
         prefetchState = state.prefetchState,
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGrid.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGrid.kt
index 37a7183..27c5b21 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGrid.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGrid.kt
@@ -139,7 +139,6 @@
                     reverseScrolling = reverseLayout,
                     flingBehavior = flingBehavior,
                     interactionSource = state.internalInteractionSource,
-                    useLocalOverscrollFactory = false,
                     overscrollEffect = overscrollEffect
                 ),
         prefetchState = state.prefetchState,
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGrid.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGrid.kt
index ff86e6da..9a6acc9 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGrid.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGrid.kt
@@ -112,7 +112,6 @@
                     reverseScrolling = reverseLayout,
                     flingBehavior = flingBehavior,
                     interactionSource = state.mutableInteractionSource,
-                    useLocalOverscrollFactory = false,
                     overscrollEffect = overscrollEffect
                 ),
         prefetchState = state.prefetchState,
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/LazyLayoutPager.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/LazyLayoutPager.kt
index 5c971cc..0b5d584 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/LazyLayoutPager.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/LazyLayoutPager.kt
@@ -185,7 +185,6 @@
                     flingBehavior = resolvedFlingBehavior,
                     interactionSource = state.internalInteractionSource,
                     overscrollEffect = overscrollEffect,
-                    useLocalOverscrollFactory = false,
                     bringIntoViewSpec = pagerBringIntoViewSpec
                 )
                 .dragDirectionDetector(state)
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 3123ffa..d00eb59 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
@@ -82,6 +82,7 @@
 import androidx.compose.ui.layout.MeasureResult
 import androidx.compose.ui.layout.MeasureScope
 import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.platform.LocalAutofillManager
 import androidx.compose.ui.platform.LocalClipboardManager
 import androidx.compose.ui.platform.LocalDensity
 import androidx.compose.ui.platform.LocalFocusManager
@@ -311,6 +312,7 @@
     manager.clipboardManager = LocalClipboardManager.current
     manager.textToolbar = LocalTextToolbar.current
     manager.hapticFeedBack = LocalHapticFeedback.current
+    manager.autofillManager = LocalAutofillManager.current
     manager.focusRequester = focusRequester
     manager.editable = !readOnly
     manager.enabled = enabled
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/modifiers/TextStringSimpleNode.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/modifiers/TextStringSimpleNode.kt
index d22b7c5..0801ba7 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/modifiers/TextStringSimpleNode.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/modifiers/TextStringSimpleNode.kt
@@ -58,8 +58,8 @@
 import androidx.compose.ui.text.style.TextOverflow
 import androidx.compose.ui.unit.Constraints
 import androidx.compose.ui.unit.Constraints.Companion.fitPrioritizingWidth
-import androidx.compose.ui.unit.Density
 import androidx.compose.ui.util.fastRoundToInt
+import kotlin.jvm.JvmName
 
 /**
  * Node that implements Text for [String].
@@ -105,20 +105,36 @@
         }
 
     /**
-     * Get the layout cache for the current state of the node.
+     * Get the layout cache for the current state of the node during layout.
+     *
+     * If text substitution is active, this will return the layout cache for the substitution.
+     * Otherwise, it will return the layout cache for the original text.
+     *
+     * @receiver Current measure scope that requests the layout cache. This scope is used to update
+     *   the density value of the returned cache.
+     */
+    private fun IntrinsicMeasureScope.getLayoutCacheForMeasure(): ParagraphLayoutCache {
+        val activeCache = getLayoutCache()
+        activeCache.density = this@getLayoutCacheForMeasure
+        return activeCache
+    }
+
+    /**
+     * Get the layout cache for the current state of the node without updating the density.
+     *
+     * Warning; DO NOT USE this function from a MeasureScope. Instead please use
+     * [getLayoutCacheForMeasure].
+     *
+     * The reason this function does not update the density value is because the density should not
+     * change between layout and draw phases. This is a micro optimization to skip the unnecessary
+     * density comparison.
      *
      * If text substitution is active, this will return the layout cache for the substitution.
      * Otherwise, it will return the layout cache for the original text.
      */
-    private fun getLayoutCache(density: Density): ParagraphLayoutCache {
-        textSubstitution?.let { textSubstitutionValue ->
-            if (textSubstitutionValue.isShowingSubstitution) {
-                textSubstitutionValue.layoutCache?.let { cache ->
-                    return cache.also { it.density = density }
-                }
-            }
-        }
-        return layoutCache.also { it.density = density }
+    @JvmName("getLayoutCacheOrSubstitute")
+    private fun getLayoutCache(): ParagraphLayoutCache {
+        return textSubstitution?.takeIf { it.isShowingSubstitution }?.layoutCache ?: layoutCache
     }
 
     fun updateDraw(color: ColorProducer?, style: TextStyle): Boolean {
@@ -343,7 +359,7 @@
         measurable: Measurable,
         constraints: Constraints
     ): MeasureResult {
-        val layoutCache = getLayoutCache(this)
+        val layoutCache = getLayoutCacheForMeasure()
 
         val didChangeLayout = layoutCache.layoutWithConstraints(constraints, layoutDirection)
         // ensure measure restarts when hasStaleResolvedFonts by reading in measure
@@ -383,23 +399,23 @@
         measurable: IntrinsicMeasurable,
         height: Int
     ): Int {
-        return getLayoutCache(this).minIntrinsicWidth(layoutDirection)
+        return getLayoutCacheForMeasure().minIntrinsicWidth(layoutDirection)
     }
 
     override fun IntrinsicMeasureScope.minIntrinsicHeight(
         measurable: IntrinsicMeasurable,
         width: Int
-    ): Int = getLayoutCache(this).intrinsicHeight(width, layoutDirection)
+    ): Int = getLayoutCacheForMeasure().intrinsicHeight(width, layoutDirection)
 
     override fun IntrinsicMeasureScope.maxIntrinsicWidth(
         measurable: IntrinsicMeasurable,
         height: Int
-    ): Int = getLayoutCache(this).maxIntrinsicWidth(layoutDirection)
+    ): Int = getLayoutCacheForMeasure().maxIntrinsicWidth(layoutDirection)
 
     override fun IntrinsicMeasureScope.maxIntrinsicHeight(
         measurable: IntrinsicMeasurable,
         width: Int
-    ): Int = getLayoutCache(this).intrinsicHeight(width, layoutDirection)
+    ): Int = getLayoutCacheForMeasure().intrinsicHeight(width, layoutDirection)
 
     /** Optimized Text draw. */
     override fun ContentDrawScope.draw() {
@@ -408,7 +424,7 @@
             return
         }
 
-        val layoutCache = getLayoutCache(this)
+        val layoutCache = getLayoutCache()
         val localParagraph =
             requirePreconditionNotNull(layoutCache.paragraph) {
                 "no paragraph (layoutCache=$_layoutCache, textSubstitution=$textSubstitution)"
diff --git a/compose/foundation/foundation/src/commonStubsMain/kotlin/androidx/compose/foundation/DesktopOverscroll.commonStubs.kt b/compose/foundation/foundation/src/commonStubsMain/kotlin/androidx/compose/foundation/DesktopOverscroll.commonStubs.kt
index fd46e2b..d65c067 100644
--- a/compose/foundation/foundation/src/commonStubsMain/kotlin/androidx/compose/foundation/DesktopOverscroll.commonStubs.kt
+++ b/compose/foundation/foundation/src/commonStubsMain/kotlin/androidx/compose/foundation/DesktopOverscroll.commonStubs.kt
@@ -16,7 +16,12 @@
 
 package androidx.compose.foundation
 
+import androidx.compose.runtime.Composable
 import androidx.compose.runtime.CompositionLocalAccessorScope
 
 internal actual fun CompositionLocalAccessorScope.defaultOverscrollFactory(): OverscrollFactory? =
     implementedInJetBrainsFork()
+
+@Composable
+internal actual fun rememberPlatformOverscrollEffect(): OverscrollEffect? =
+    implementedInJetBrainsFork()
diff --git a/compose/material3/material3/api/current.txt b/compose/material3/material3/api/current.txt
index c8872fd..78e3cbc 100644
--- a/compose/material3/material3/api/current.txt
+++ b/compose/material3/material3/api/current.txt
@@ -1076,22 +1076,26 @@
     method @SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @androidx.compose.runtime.Composable public androidx.compose.ui.graphics.Shape getXSmallSquareShape();
     method @androidx.compose.runtime.Composable public androidx.compose.material3.IconButtonColors iconButtonColors();
     method @androidx.compose.runtime.Composable public androidx.compose.material3.IconButtonColors iconButtonColors(optional long containerColor, optional long contentColor, optional long disabledContainerColor, optional long disabledContentColor);
-    method @androidx.compose.runtime.Composable public androidx.compose.material3.IconButtonColors iconButtonLocalContentColors();
+    method @androidx.compose.runtime.Composable public androidx.compose.material3.IconButtonColors iconButtonVibrantColors();
+    method @androidx.compose.runtime.Composable public androidx.compose.material3.IconButtonColors iconButtonVibrantColors(optional long containerColor, optional long contentColor, optional long disabledContainerColor, optional long disabledContentColor);
     method @androidx.compose.runtime.Composable public androidx.compose.material3.IconToggleButtonColors iconToggleButtonColors();
     method @androidx.compose.runtime.Composable public androidx.compose.material3.IconToggleButtonColors iconToggleButtonColors(optional long containerColor, optional long contentColor, optional long disabledContainerColor, optional long disabledContentColor, optional long checkedContainerColor, optional long checkedContentColor);
-    method @androidx.compose.runtime.Composable public androidx.compose.material3.IconToggleButtonColors iconToggleButtonLocalContentColors();
+    method @androidx.compose.runtime.Composable public androidx.compose.material3.IconToggleButtonColors iconToggleButtonVibrantColors();
+    method @androidx.compose.runtime.Composable public androidx.compose.material3.IconToggleButtonColors iconToggleButtonVibrantColors(optional long containerColor, optional long contentColor, optional long disabledContainerColor, optional long disabledContentColor, optional long checkedContainerColor, optional long checkedContentColor);
     method @SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3ExpressiveApi public long largeContainerSize(optional int widthOption);
     method @SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3ExpressiveApi public long mediumContainerSize(optional int widthOption);
     method @androidx.compose.runtime.Composable public androidx.compose.foundation.BorderStroke outlinedIconButtonBorder(boolean enabled);
     method @androidx.compose.runtime.Composable public androidx.compose.material3.IconButtonColors outlinedIconButtonColors();
     method @androidx.compose.runtime.Composable public androidx.compose.material3.IconButtonColors outlinedIconButtonColors(optional long containerColor, optional long contentColor, optional long disabledContainerColor, optional long disabledContentColor);
-    method @androidx.compose.runtime.Composable public androidx.compose.foundation.BorderStroke? outlinedIconButtonLocalContentColorBorder(boolean enabled);
-    method @androidx.compose.runtime.Composable public androidx.compose.material3.IconButtonColors outlinedIconButtonLocalContentColors();
+    method @androidx.compose.runtime.Composable public androidx.compose.foundation.BorderStroke outlinedIconButtonVibrantBorder(boolean enabled);
+    method @androidx.compose.runtime.Composable public androidx.compose.material3.IconButtonColors outlinedIconButtonVibrantColors();
+    method @androidx.compose.runtime.Composable public androidx.compose.material3.IconButtonColors outlinedIconButtonVibrantColors(optional long containerColor, optional long contentColor, optional long disabledContainerColor, optional long disabledContentColor);
     method @androidx.compose.runtime.Composable public androidx.compose.foundation.BorderStroke? outlinedIconToggleButtonBorder(boolean enabled, boolean checked);
     method @androidx.compose.runtime.Composable public androidx.compose.material3.IconToggleButtonColors outlinedIconToggleButtonColors();
     method @androidx.compose.runtime.Composable public androidx.compose.material3.IconToggleButtonColors outlinedIconToggleButtonColors(optional long containerColor, optional long contentColor, optional long disabledContainerColor, optional long disabledContentColor, optional long checkedContainerColor, optional long checkedContentColor);
-    method @androidx.compose.runtime.Composable public androidx.compose.foundation.BorderStroke? outlinedIconToggleButtonLocalContentColorBorder(boolean enabled, boolean checked);
-    method @androidx.compose.runtime.Composable public androidx.compose.material3.IconToggleButtonColors outlinedIconToggleButtonLocalContentColors();
+    method @androidx.compose.runtime.Composable public androidx.compose.foundation.BorderStroke? outlinedIconToggleButtonVibrantBorder(boolean enabled, boolean checked);
+    method @androidx.compose.runtime.Composable public androidx.compose.material3.IconToggleButtonColors outlinedIconToggleButtonVibrantColors();
+    method @androidx.compose.runtime.Composable public androidx.compose.material3.IconToggleButtonColors outlinedIconToggleButtonVibrantColors(optional long containerColor, optional long contentColor, optional long disabledContainerColor, optional long disabledContentColor, optional long checkedContainerColor, optional long checkedContentColor);
     method @SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @androidx.compose.runtime.Composable public androidx.compose.material3.IconButtonShapes shapes(androidx.compose.ui.graphics.Shape shape, androidx.compose.ui.graphics.Shape pressedShape, androidx.compose.ui.graphics.Shape checkedShape);
     method @SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3ExpressiveApi public long smallContainerSize(optional int widthOption);
     method @SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3ExpressiveApi public long xLargeContainerSize(optional int widthOption);
diff --git a/compose/material3/material3/api/restricted_current.txt b/compose/material3/material3/api/restricted_current.txt
index c8872fd..78e3cbc 100644
--- a/compose/material3/material3/api/restricted_current.txt
+++ b/compose/material3/material3/api/restricted_current.txt
@@ -1076,22 +1076,26 @@
     method @SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @androidx.compose.runtime.Composable public androidx.compose.ui.graphics.Shape getXSmallSquareShape();
     method @androidx.compose.runtime.Composable public androidx.compose.material3.IconButtonColors iconButtonColors();
     method @androidx.compose.runtime.Composable public androidx.compose.material3.IconButtonColors iconButtonColors(optional long containerColor, optional long contentColor, optional long disabledContainerColor, optional long disabledContentColor);
-    method @androidx.compose.runtime.Composable public androidx.compose.material3.IconButtonColors iconButtonLocalContentColors();
+    method @androidx.compose.runtime.Composable public androidx.compose.material3.IconButtonColors iconButtonVibrantColors();
+    method @androidx.compose.runtime.Composable public androidx.compose.material3.IconButtonColors iconButtonVibrantColors(optional long containerColor, optional long contentColor, optional long disabledContainerColor, optional long disabledContentColor);
     method @androidx.compose.runtime.Composable public androidx.compose.material3.IconToggleButtonColors iconToggleButtonColors();
     method @androidx.compose.runtime.Composable public androidx.compose.material3.IconToggleButtonColors iconToggleButtonColors(optional long containerColor, optional long contentColor, optional long disabledContainerColor, optional long disabledContentColor, optional long checkedContainerColor, optional long checkedContentColor);
-    method @androidx.compose.runtime.Composable public androidx.compose.material3.IconToggleButtonColors iconToggleButtonLocalContentColors();
+    method @androidx.compose.runtime.Composable public androidx.compose.material3.IconToggleButtonColors iconToggleButtonVibrantColors();
+    method @androidx.compose.runtime.Composable public androidx.compose.material3.IconToggleButtonColors iconToggleButtonVibrantColors(optional long containerColor, optional long contentColor, optional long disabledContainerColor, optional long disabledContentColor, optional long checkedContainerColor, optional long checkedContentColor);
     method @SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3ExpressiveApi public long largeContainerSize(optional int widthOption);
     method @SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3ExpressiveApi public long mediumContainerSize(optional int widthOption);
     method @androidx.compose.runtime.Composable public androidx.compose.foundation.BorderStroke outlinedIconButtonBorder(boolean enabled);
     method @androidx.compose.runtime.Composable public androidx.compose.material3.IconButtonColors outlinedIconButtonColors();
     method @androidx.compose.runtime.Composable public androidx.compose.material3.IconButtonColors outlinedIconButtonColors(optional long containerColor, optional long contentColor, optional long disabledContainerColor, optional long disabledContentColor);
-    method @androidx.compose.runtime.Composable public androidx.compose.foundation.BorderStroke? outlinedIconButtonLocalContentColorBorder(boolean enabled);
-    method @androidx.compose.runtime.Composable public androidx.compose.material3.IconButtonColors outlinedIconButtonLocalContentColors();
+    method @androidx.compose.runtime.Composable public androidx.compose.foundation.BorderStroke outlinedIconButtonVibrantBorder(boolean enabled);
+    method @androidx.compose.runtime.Composable public androidx.compose.material3.IconButtonColors outlinedIconButtonVibrantColors();
+    method @androidx.compose.runtime.Composable public androidx.compose.material3.IconButtonColors outlinedIconButtonVibrantColors(optional long containerColor, optional long contentColor, optional long disabledContainerColor, optional long disabledContentColor);
     method @androidx.compose.runtime.Composable public androidx.compose.foundation.BorderStroke? outlinedIconToggleButtonBorder(boolean enabled, boolean checked);
     method @androidx.compose.runtime.Composable public androidx.compose.material3.IconToggleButtonColors outlinedIconToggleButtonColors();
     method @androidx.compose.runtime.Composable public androidx.compose.material3.IconToggleButtonColors outlinedIconToggleButtonColors(optional long containerColor, optional long contentColor, optional long disabledContainerColor, optional long disabledContentColor, optional long checkedContainerColor, optional long checkedContentColor);
-    method @androidx.compose.runtime.Composable public androidx.compose.foundation.BorderStroke? outlinedIconToggleButtonLocalContentColorBorder(boolean enabled, boolean checked);
-    method @androidx.compose.runtime.Composable public androidx.compose.material3.IconToggleButtonColors outlinedIconToggleButtonLocalContentColors();
+    method @androidx.compose.runtime.Composable public androidx.compose.foundation.BorderStroke? outlinedIconToggleButtonVibrantBorder(boolean enabled, boolean checked);
+    method @androidx.compose.runtime.Composable public androidx.compose.material3.IconToggleButtonColors outlinedIconToggleButtonVibrantColors();
+    method @androidx.compose.runtime.Composable public androidx.compose.material3.IconToggleButtonColors outlinedIconToggleButtonVibrantColors(optional long containerColor, optional long contentColor, optional long disabledContainerColor, optional long disabledContentColor, optional long checkedContainerColor, optional long checkedContentColor);
     method @SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @androidx.compose.runtime.Composable public androidx.compose.material3.IconButtonShapes shapes(androidx.compose.ui.graphics.Shape shape, androidx.compose.ui.graphics.Shape pressedShape, androidx.compose.ui.graphics.Shape checkedShape);
     method @SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3ExpressiveApi public long smallContainerSize(optional int widthOption);
     method @SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3ExpressiveApi public long xLargeContainerSize(optional int widthOption);
diff --git a/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/IconButtonScreenshotTest.kt b/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/IconButtonScreenshotTest.kt
index da0d64f..896ab0a 100644
--- a/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/IconButtonScreenshotTest.kt
+++ b/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/IconButtonScreenshotTest.kt
@@ -58,7 +58,6 @@
 @MediumTest
 @RunWith(AndroidJUnit4::class)
 @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
-@OptIn(ExperimentalMaterial3Api::class)
 class IconButtonScreenshotTest {
 
     @get:Rule val rule = createComposeRule()
diff --git a/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/IconButtonTest.kt b/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/IconButtonTest.kt
index f2e370a..dbb49b3 100644
--- a/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/IconButtonTest.kt
+++ b/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/IconButtonTest.kt
@@ -16,6 +16,7 @@
 package androidx.compose.material3
 
 import android.os.Build
+import androidx.compose.foundation.BorderStroke
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.requiredSize
@@ -27,6 +28,7 @@
 import androidx.compose.material3.tokens.FilledIconButtonTokens
 import androidx.compose.material3.tokens.FilledTonalIconButtonTokens
 import androidx.compose.material3.tokens.OutlinedIconButtonTokens
+import androidx.compose.material3.tokens.SmallIconButtonTokens
 import androidx.compose.material3.tokens.StandardIconButtonTokens
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.CompositionLocalProvider
@@ -309,7 +311,7 @@
     fun iconButton_defaultLocalContentColors() {
         rule.setMaterialContent(lightColorScheme()) {
             CompositionLocalProvider(LocalContentColor provides Color.Blue) {
-                Truth.assertThat(IconButtonDefaults.iconButtonLocalContentColors())
+                Truth.assertThat(IconButtonDefaults.iconButtonColors())
                     .isEqualTo(
                         IconButtonColors(
                             containerColor = Color.Transparent,
@@ -324,9 +326,9 @@
     }
 
     @Test
-    fun iconButton_defaultColors() {
+    fun iconButton_defaultVibrantColors() {
         rule.setMaterialContent(lightColorScheme()) {
-            Truth.assertThat(IconButtonDefaults.iconButtonColors())
+            Truth.assertThat(IconButtonDefaults.iconButtonVibrantColors())
                 .isEqualTo(
                     IconButtonColors(
                         containerColor = Color.Transparent,
@@ -342,38 +344,32 @@
     }
 
     @Test
-    fun iconButtonColors_localContentColor() {
+    fun iconButtonColors_useLocalContentColor() {
         rule.setMaterialContent(lightColorScheme()) {
             CompositionLocalProvider(LocalContentColor provides Color.Blue) {
-                val colors = IconButtonDefaults.iconButtonLocalContentColors()
+                val colors = IconButtonDefaults.iconButtonColors()
                 assert(colors.contentColor == Color.Blue)
             }
 
             CompositionLocalProvider(LocalContentColor provides Color.Red) {
                 val colors =
-                    IconButtonDefaults.iconButtonLocalContentColors()
-                        .copy(containerColor = Color.Green)
+                    IconButtonDefaults.iconButtonColors().copy(containerColor = Color.Green)
                 assert(colors.containerColor == Color.Green)
                 assert(colors.contentColor == Color.Red)
+                assert(
+                    colors.disabledContentColor ==
+                        Color.Red.copy(StandardIconButtonTokens.DisabledOpacity)
+                )
             }
         }
     }
 
     @Test
-    fun iconButtonColors_customValues_useLocalContentColor() {
+    fun iconButtonVibrantColors_ignoreLocalContentColor() {
         rule.setMaterialContent(lightColorScheme()) {
-            CompositionLocalProvider(LocalContentColor provides Color.Blue) {
-                val colors = IconButtonDefaults.iconButtonLocalContentColors()
-                assert(colors.contentColor == Color.Blue)
-                assert(
-                    colors.disabledContentColor ==
-                        Color.Blue.copy(StandardIconButtonTokens.DisabledOpacity)
-                )
-            }
-
             CompositionLocalProvider(LocalContentColor provides Color.Red) {
                 val colors =
-                    IconButtonDefaults.iconButtonColors(
+                    IconButtonDefaults.iconButtonVibrantColors(
                         containerColor = Color.Blue,
                         contentColor = Color.Green
                     )
@@ -392,7 +388,7 @@
         rule.setMaterialContent(lightColorScheme()) {
             CompositionLocalProvider(LocalContentColor provides Color.Red) {
                 val colors =
-                    IconButtonDefaults.iconButtonColors(
+                    IconButtonDefaults.iconButtonVibrantColors(
                         containerColor = Color.Blue,
                         contentColor = Color.Green
                     )
@@ -409,8 +405,8 @@
     @Test
     fun iconButtonColors_copy() {
         rule.setMaterialContent(lightColorScheme()) {
-            val colors = IconButtonDefaults.iconButtonColors().copy()
-            assert(colors == IconButtonDefaults.iconButtonColors())
+            val colors = IconButtonDefaults.iconButtonVibrantColors().copy()
+            assert(colors == IconButtonDefaults.iconButtonVibrantColors())
         }
     }
 
@@ -543,7 +539,7 @@
     fun iconToggleButton_defaultLocalContentColors() {
         rule.setMaterialContent(lightColorScheme()) {
             val localContentColor = LocalContentColor.current
-            Truth.assertThat(IconButtonDefaults.iconToggleButtonLocalContentColors())
+            Truth.assertThat(IconButtonDefaults.iconToggleButtonColors())
                 .isEqualTo(
                     IconToggleButtonColors(
                         containerColor = Color.Transparent,
@@ -561,9 +557,9 @@
     }
 
     @Test
-    fun iconToggleButton_defaultColors() {
+    fun iconToggleButton_defaultVibrantColors() {
         rule.setMaterialContent(lightColorScheme()) {
-            Truth.assertThat(IconButtonDefaults.iconToggleButtonColors())
+            Truth.assertThat(IconButtonDefaults.iconToggleButtonVibrantColors())
                 .isEqualTo(
                     IconToggleButtonColors(
                         containerColor = Color.Transparent,
@@ -741,7 +737,9 @@
                                 .size(IconButtonDefaults.mediumContainerSize()),
                         shape = shape,
                         colors =
-                            IconButtonDefaults.iconButtonColors(containerColor = iconButtonColor)
+                            IconButtonDefaults.iconButtonVibrantColors(
+                                containerColor = iconButtonColor
+                            )
                     ) {}
                 }
             }
@@ -1051,7 +1049,7 @@
                                 .testTag(IconTestTag)
                                 .size(IconButtonDefaults.mediumContainerSize()),
                         colors =
-                            IconButtonDefaults.iconToggleButtonColors(
+                            IconButtonDefaults.iconToggleButtonVibrantColors(
                                 checkedContainerColor = iconButtonColor
                             )
                     ) {}
@@ -1305,7 +1303,7 @@
     fun outlinedIconButton_defaultLocalContentColors() {
         rule.setMaterialContent(lightColorScheme()) {
             val localContentColor = LocalContentColor.current
-            Truth.assertThat(IconButtonDefaults.outlinedIconButtonLocalContentColors())
+            Truth.assertThat(IconButtonDefaults.outlinedIconButtonColors())
                 .isEqualTo(
                     IconButtonColors(
                         containerColor = Color.Transparent,
@@ -1319,9 +1317,9 @@
     }
 
     @Test
-    fun outlinedIconButton_defaultColors() {
+    fun outlinedIconButton_defaultVibrantColors() {
         rule.setMaterialContent(lightColorScheme()) {
-            Truth.assertThat(IconButtonDefaults.outlinedIconButtonColors())
+            Truth.assertThat(IconButtonDefaults.outlinedIconButtonVibrantColors())
                 .isEqualTo(
                     IconButtonColors(
                         containerColor = Color.Transparent,
@@ -1337,9 +1335,30 @@
     }
 
     @Test
-    fun outlinedIconToggleButton_defaultColors() {
+    fun outlinedIconToggleButton_useLocalContentColors() {
         rule.setMaterialContent(lightColorScheme()) {
-            Truth.assertThat(IconButtonDefaults.outlinedIconToggleButtonColors())
+            CompositionLocalProvider(LocalContentColor provides Color.Blue) {
+                val colors = IconButtonDefaults.outlinedIconButtonColors()
+                assert(colors.contentColor == Color.Blue)
+            }
+
+            CompositionLocalProvider(LocalContentColor provides Color.Red) {
+                val colors =
+                    IconButtonDefaults.outlinedIconButtonColors().copy(containerColor = Color.Green)
+                assert(colors.containerColor == Color.Green)
+                assert(colors.contentColor == Color.Red)
+                assert(
+                    colors.disabledContentColor ==
+                        Color.Red.copy(OutlinedIconButtonTokens.DisabledOpacity)
+                )
+            }
+        }
+    }
+
+    @Test
+    fun outlinedIconToggleButton_defaultVibrantColors() {
+        rule.setMaterialContent(lightColorScheme()) {
+            Truth.assertThat(IconButtonDefaults.outlinedIconToggleButtonVibrantColors())
                 .isEqualTo(
                     IconToggleButtonColors(
                         containerColor = Color.Transparent,
@@ -1358,55 +1377,29 @@
     }
 
     @Test
-    fun outlinedIconToggleButton_defaultLocalContentColors_reuse() {
+    fun outlinedIconButton_borderStroke_defaultLocalContentColor() {
         rule.setMaterialContent(lightColorScheme()) {
-            val localContentColor = LocalContentColor.current
-            Truth.assertThat(MaterialTheme.colorScheme.defaultOutlinedIconToggleButtonColorsCached)
-                .isNull()
-            val colors = IconButtonDefaults.outlinedIconToggleButtonLocalContentColors()
-            Truth.assertThat(MaterialTheme.colorScheme.defaultOutlinedIconToggleButtonColorsCached)
-                .isNotNull()
-            Truth.assertThat(colors)
-                .isEqualTo(
-                    IconToggleButtonColors(
-                        containerColor = Color.Transparent,
-                        contentColor = localContentColor,
-                        disabledContainerColor = Color.Transparent,
-                        disabledContentColor =
-                            localContentColor.copy(
-                                alpha = OutlinedIconButtonTokens.DisabledOpacity
-                            ),
-                        checkedContainerColor =
-                            OutlinedIconButtonTokens.SelectedContainerColor.value,
-                        checkedContentColor =
-                            contentColorFor(OutlinedIconButtonTokens.SelectedContainerColor.value)
-                    )
-                )
+            CompositionLocalProvider(LocalContentColor provides Color.Blue) {
+                Truth.assertThat(IconButtonDefaults.outlinedIconButtonBorder(enabled = true))
+                    .isEqualTo(BorderStroke(SmallIconButtonTokens.OutlinedOutlineWidth, Color.Blue))
+            }
         }
     }
 
     @Test
-    fun outlinedIconToggleButton_defaultColors_reuse() {
+    fun outlinedIconToggleButton_borderStroke_defaultVibrantColor() {
         rule.setMaterialContent(lightColorScheme()) {
-            val localContentColor = LocalContentColor.current
-            Truth.assertThat(MaterialTheme.colorScheme.defaultOutlinedIconToggleButtonColorsCached)
-                .isNull()
-            val colors = IconButtonDefaults.outlinedIconToggleButtonColors()
-            Truth.assertThat(MaterialTheme.colorScheme.defaultOutlinedIconToggleButtonColorsCached)
-                .isNotNull()
-            Truth.assertThat(colors)
+            val outlineColor = OutlinedIconButtonTokens.OutlineColor.value
+            Truth.assertThat(
+                    IconButtonDefaults.outlinedIconToggleButtonVibrantBorder(
+                        enabled = false,
+                        checked = false
+                    )
+                )
                 .isEqualTo(
-                    IconToggleButtonColors(
-                        containerColor = Color.Transparent,
-                        contentColor = OutlinedIconButtonTokens.UnselectedColor.value,
-                        disabledContainerColor = Color.Transparent,
-                        disabledContentColor =
-                            localContentColor.copy(
-                                alpha = OutlinedIconButtonTokens.DisabledOpacity
-                            ),
-                        checkedContainerColor =
-                            OutlinedIconButtonTokens.SelectedContainerColor.value,
-                        checkedContentColor = OutlinedIconButtonTokens.SelectedColor.value
+                    BorderStroke(
+                        SmallIconButtonTokens.OutlinedOutlineWidth,
+                        outlineColor.copy(alpha = OutlinedIconButtonTokens.DisabledContainerOpacity)
                     )
                 )
         }
diff --git a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/ColorScheme.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/ColorScheme.kt
index bd16b81..4b952d8 100644
--- a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/ColorScheme.kt
+++ b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/ColorScheme.kt
@@ -492,13 +492,17 @@
     internal var defaultDatePickerColorsCached: DatePickerColors? = null
 
     internal var defaultIconButtonColorsCached: IconButtonColors? = null
+    internal var defaultIconButtonVibrantColorsCached: IconButtonColors? = null
     internal var defaultIconToggleButtonColorsCached: IconToggleButtonColors? = null
+    internal var defaultIconToggleButtonVibrantColorsCached: IconToggleButtonColors? = null
     internal var defaultFilledIconButtonColorsCached: IconButtonColors? = null
     internal var defaultFilledIconToggleButtonColorsCached: IconToggleButtonColors? = null
     internal var defaultFilledTonalIconButtonColorsCached: IconButtonColors? = null
     internal var defaultFilledTonalIconToggleButtonColorsCached: IconToggleButtonColors? = null
     internal var defaultOutlinedIconButtonColorsCached: IconButtonColors? = null
+    internal var defaultOutlinedIconButtonVibrantColorsCached: IconButtonColors? = null
     internal var defaultOutlinedIconToggleButtonColorsCached: IconToggleButtonColors? = null
+    internal var defaultOutlinedIconToggleButtonVibrantColorsCached: IconToggleButtonColors? = null
 
     internal var defaultToggleButtonColorsCached: ToggleButtonColors? = null
     internal var defaultElevatedToggleButtonColorsCached: ToggleButtonColors? = null
diff --git a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/IconButton.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/IconButton.kt
index 6337974..35ba030 100644
--- a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/IconButton.kt
+++ b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/IconButton.kt
@@ -90,7 +90,7 @@
  *   not respond to user input, and it will appear visually disabled and disabled to accessibility
  *   services.
  * @param colors [IconButtonColors] that will be used to resolve the colors used for this icon
- *   button in different states. See [IconButtonDefaults.iconButtonColors].
+ *   button in different states. See [IconButtonDefaults.iconButtonVibrantColors].
  * @param interactionSource an optional hoisted [MutableInteractionSource] for observing and
  *   emitting [Interaction]s for this icon button. You can use this to change the icon button's
  *   appearance or preview the icon button in different states. Note that if `null` is provided,
@@ -110,7 +110,7 @@
     onClick: () -> Unit,
     modifier: Modifier = Modifier,
     enabled: Boolean = true,
-    colors: IconButtonColors = IconButtonDefaults.iconButtonLocalContentColors(),
+    colors: IconButtonColors = IconButtonDefaults.iconButtonColors(),
     interactionSource: MutableInteractionSource? = null,
     content: @Composable () -> Unit
 ) {
@@ -160,8 +160,8 @@
  *   not respond to user input, and it will appear visually disabled and disabled to accessibility
  *   services.
  * @param colors [IconButtonColors] that will be used to resolve the colors used for this icon
- *   button in different states. See [IconButtonDefaults.iconButtonColors] and
- *   [IconButtonDefaults.iconButtonLocalContentColors] .
+ *   button in different states. See [IconButtonDefaults.iconButtonVibrantColors] and
+ *   [IconButtonDefaults.iconButtonColors] .
  * @param interactionSource an optional hoisted [MutableInteractionSource] for observing and
  *   emitting [Interaction]s for this icon button. You can use this to change the icon button's
  *   appearance or preview the icon button in different states. Note that if `null` is provided,
@@ -175,7 +175,7 @@
     onClick: () -> Unit,
     modifier: Modifier = Modifier,
     enabled: Boolean = true,
-    colors: IconButtonColors = IconButtonDefaults.iconButtonLocalContentColors(),
+    colors: IconButtonColors = IconButtonDefaults.iconButtonColors(),
     interactionSource: MutableInteractionSource? = null,
     shape: Shape = IconButtonDefaults.standardShape,
     content: @Composable () -> Unit
@@ -227,7 +227,7 @@
  *   not respond to user input, and it will appear visually disabled and disabled to accessibility
  *   services.
  * @param colors [IconToggleButtonColors] that will be used to resolve the colors used for this icon
- *   button in different states. See [IconButtonDefaults.iconToggleButtonColors].
+ *   button in different states. See [IconButtonDefaults.iconToggleButtonVibrantColors].
  * @param interactionSource an optional hoisted [MutableInteractionSource] for observing and
  *   emitting [Interaction]s for this icon button. You can use this to change the icon button's
  *   appearance or preview the icon button in different states. Note that if `null` is provided,
@@ -249,7 +249,7 @@
     onCheckedChange: (Boolean) -> Unit,
     modifier: Modifier = Modifier,
     enabled: Boolean = true,
-    colors: IconToggleButtonColors = IconButtonDefaults.iconToggleButtonLocalContentColors(),
+    colors: IconToggleButtonColors = IconButtonDefaults.iconToggleButtonColors(),
     interactionSource: MutableInteractionSource? = null,
     content: @Composable () -> Unit
 ) {
@@ -287,8 +287,8 @@
  *   not respond to user input, and it will appear visually disabled and disabled to accessibility
  *   services.
  * @param colors [IconToggleButtonColors] that will be used to resolve the colors used for this icon
- *   button in different states. See [IconButtonDefaults.iconToggleButtonColors] and
- *   [IconButtonDefaults.iconToggleButtonLocalContentColors].
+ *   button in different states. See [IconButtonDefaults.iconToggleButtonVibrantColors] and
+ *   [IconButtonDefaults.iconToggleButtonColors].
  * @param interactionSource an optional hoisted [MutableInteractionSource] for observing and
  *   emitting [Interaction]s for this icon button. You can use this to change the icon button's
  *   appearance or preview the icon button in different states. Note that if `null` is provided,
@@ -303,7 +303,7 @@
     onCheckedChange: (Boolean) -> Unit,
     modifier: Modifier = Modifier,
     enabled: Boolean = true,
-    colors: IconToggleButtonColors = IconButtonDefaults.iconToggleButtonLocalContentColors(),
+    colors: IconToggleButtonColors = IconButtonDefaults.iconToggleButtonColors(),
     interactionSource: MutableInteractionSource? = null,
     shape: Shape = IconButtonDefaults.standardShape,
     content: @Composable () -> Unit
@@ -343,7 +343,7 @@
  *   not respond to user input, and it will appear visually disabled and disabled to accessibility
  *   services.
  * @param colors [IconToggleButtonColors] that will be used to resolve the colors used for this icon
- *   button in different states. See [IconButtonDefaults.iconToggleButtonColors].
+ *   button in different states. See [IconButtonDefaults.iconToggleButtonVibrantColors].
  * @param interactionSource an optional hoisted [MutableInteractionSource] for observing and
  *   emitting [Interaction]s for this icon button. You can use this to change the icon button's
  *   appearance or preview the icon button in different states. Note that if `null` is provided,
@@ -358,7 +358,7 @@
     shapes: IconButtonShapes,
     modifier: Modifier = Modifier,
     enabled: Boolean = true,
-    colors: IconToggleButtonColors = IconButtonDefaults.iconToggleButtonColors(),
+    colors: IconToggleButtonColors = IconButtonDefaults.iconToggleButtonVibrantColors(),
     interactionSource: MutableInteractionSource? = null,
     content: @Composable () -> Unit
 ) {
@@ -383,7 +383,7 @@
     onCheckedChange: (Boolean) -> Unit,
     modifier: Modifier = Modifier,
     enabled: Boolean = true,
-    colors: IconToggleButtonColors = IconButtonDefaults.iconToggleButtonColors(),
+    colors: IconToggleButtonColors = IconButtonDefaults.iconToggleButtonVibrantColors(),
     interactionSource: MutableInteractionSource? = null,
     shape: Shape = IconButtonDefaults.standardShape,
     content: @Composable () -> Unit
@@ -788,11 +788,11 @@
  * @param shape defines the shape of this icon button's container and border (when [border] is not
  *   null)
  * @param colors [IconButtonColors] that will be used to resolve the colors used for this icon
- *   button in different states. See [IconButtonDefaults.outlinedIconButtonColors] and
- *   [IconButtonDefaults.outlinedIconButtonLocalContentColors].
+ *   button in different states. See [IconButtonDefaults.outlinedIconButtonVibrantColors] and
+ *   [IconButtonDefaults.outlinedIconButtonColors].
  * @param border the border to draw around the container of this icon button. Pass `null` for no
  *   border. See [IconButtonDefaults.outlinedIconButtonBorder] and
- *   [IconButtonDefaults.outlinedIconButtonLocalContentColorBorder].
+ *   [IconButtonDefaults.outlinedIconButtonBorder].
  * @param interactionSource an optional hoisted [MutableInteractionSource] for observing and
  *   emitting [Interaction]s for this icon button. You can use this to change the icon button's
  *   appearance or preview the icon button in different states. Note that if `null` is provided,
@@ -805,8 +805,8 @@
     modifier: Modifier = Modifier,
     enabled: Boolean = true,
     shape: Shape = IconButtonDefaults.outlinedShape,
-    colors: IconButtonColors = IconButtonDefaults.outlinedIconButtonLocalContentColors(),
-    border: BorderStroke? = IconButtonDefaults.outlinedIconButtonLocalContentColorBorder(enabled),
+    colors: IconButtonColors = IconButtonDefaults.outlinedIconButtonColors(),
+    border: BorderStroke? = IconButtonDefaults.outlinedIconButtonBorder(enabled),
     interactionSource: MutableInteractionSource? = null,
     content: @Composable () -> Unit
 ) =
@@ -845,11 +845,11 @@
  * @param shape defines the shape of this icon button's container and border (when [border] is not
  *   null)
  * @param colors [IconToggleButtonColors] that will be used to resolve the colors used for this icon
- *   button in different states. See [IconButtonDefaults.outlinedIconToggleButtonColors] and
- *   [IconButtonDefaults.outlinedIconToggleButtonLocalContentColors].
+ *   button in different states. See [IconButtonDefaults.outlinedIconToggleButtonVibrantColors] and
+ *   [IconButtonDefaults.outlinedIconToggleButtonColors].
  * @param border the border to draw around the container of this icon button. Pass `null` for no
- *   border. See [IconButtonDefaults.outlinedIconToggleButtonBorder] and
- *   [IconButtonDefaults.outlinedIconToggleButtonLocalContentColorBorder].
+ *   border. See [IconButtonDefaults.outlinedIconToggleButtonVibrantBorder] and
+ *   [IconButtonDefaults.outlinedIconToggleButtonBorder].
  * @param interactionSource an optional hoisted [MutableInteractionSource] for observing and
  *   emitting [Interaction]s for this icon button. You can use this to change the icon button's
  *   appearance or preview the icon button in different states. Note that if `null` is provided,
@@ -863,10 +863,8 @@
     modifier: Modifier = Modifier,
     enabled: Boolean = true,
     shape: Shape = IconButtonDefaults.outlinedShape,
-    colors: IconToggleButtonColors =
-        IconButtonDefaults.outlinedIconToggleButtonLocalContentColors(),
-    border: BorderStroke? =
-        IconButtonDefaults.outlinedIconToggleButtonLocalContentColorBorder(enabled, checked),
+    colors: IconToggleButtonColors = IconButtonDefaults.outlinedIconToggleButtonColors(),
+    border: BorderStroke? = IconButtonDefaults.outlinedIconToggleButtonBorder(enabled, checked),
     interactionSource: MutableInteractionSource? = null,
     content: @Composable () -> Unit
 ) =
@@ -906,9 +904,9 @@
  *   not respond to user input, and it will appear visually disabled and disabled to accessibility
  *   services.
  * @param colors [IconToggleButtonColors] that will be used to resolve the colors used for this icon
- *   button in different states. See [IconButtonDefaults.outlinedIconToggleButtonColors].
+ *   button in different states. See [IconButtonDefaults.outlinedIconToggleButtonVibrantColors].
  * @param border the border to draw around the container of this icon button. Pass `null` for no
- *   border. See [IconButtonDefaults.outlinedIconToggleButtonBorder].
+ *   border. See [IconButtonDefaults.outlinedIconToggleButtonVibrantBorder].
  * @param interactionSource an optional hoisted [MutableInteractionSource] for observing and
  *   emitting [Interaction]s for this icon button. You can use this to change the icon button's
  *   appearance or preview the icon button in different states. Note that if `null` is provided,
@@ -923,8 +921,9 @@
     shapes: IconButtonShapes,
     modifier: Modifier = Modifier,
     enabled: Boolean = true,
-    colors: IconToggleButtonColors = IconButtonDefaults.outlinedIconToggleButtonColors(),
-    border: BorderStroke? = IconButtonDefaults.outlinedIconToggleButtonBorder(enabled, checked),
+    colors: IconToggleButtonColors = IconButtonDefaults.outlinedIconToggleButtonVibrantColors(),
+    border: BorderStroke? =
+        IconButtonDefaults.outlinedIconToggleButtonVibrantBorder(enabled, checked),
     interactionSource: MutableInteractionSource? = null,
     content: @Composable () -> Unit
 ) =
@@ -1076,9 +1075,12 @@
     /**
      * Contains the default values used by [IconButton]. [LocalContentColor] will be applied to the
      * icon and down the UI tree.
+     *
+     * See [iconButtonVibrantColors] for default values that applies the recommended high contrast
+     * colors.
      */
     @Composable
-    fun iconButtonLocalContentColors(): IconButtonColors {
+    fun iconButtonColors(): IconButtonColors {
         val contentColor = LocalContentColor.current
         val colors = MaterialTheme.colorScheme.defaultIconButtonColors(contentColor)
         return if (colors.contentColor == contentColor) {
@@ -1093,31 +1095,29 @@
     }
 
     /**
-     * Creates a [IconButtonColors] that represents the default colors used in a [IconButton]. See
-     * [iconButtonLocalContentColors] for default values that applies [LocalContentColor] to the
-     * icon and down the UI tree.
-     */
-    @Composable
-    fun iconButtonColors(): IconButtonColors = MaterialTheme.colorScheme.defaultIconButtonColors()
-
-    /**
      * Creates a [IconButtonColors] that represents the default colors used in a [IconButton].
+     * [LocalContentColor] will be applied to the icon and down the UI tree unless a custom
+     * [contentColor] is provided.
+     *
+     * See [iconButtonVibrantColors] for default values that applies the recommended high contrast
+     * colors.
      *
      * @param containerColor the container color of this icon button when enabled.
-     * @param contentColor the content color of this icon button when enabled.
+     * @param contentColor the content color of this icon button when enabled. By default, this will
+     *   use the current LocalContentColor value.
      * @param disabledContainerColor the container color of this icon button when not enabled.
      * @param disabledContentColor the content color of this icon button when not enabled.
      */
     @Composable
     fun iconButtonColors(
         containerColor: Color = Color.Unspecified,
-        contentColor: Color = Color.Unspecified,
+        contentColor: Color = LocalContentColor.current,
         disabledContainerColor: Color = Color.Unspecified,
         disabledContentColor: Color =
             contentColor.copy(alpha = StandardIconButtonTokens.DisabledOpacity)
     ): IconButtonColors =
         MaterialTheme.colorScheme
-            .defaultIconButtonColors()
+            .defaultIconButtonColors(LocalContentColor.current)
             .copy(
                 containerColor = containerColor,
                 contentColor = contentColor,
@@ -1125,33 +1125,84 @@
                 disabledContentColor = disabledContentColor,
             )
 
-    internal fun ColorScheme.defaultIconButtonColors(
-        localContentColor: Color? = null,
-    ): IconButtonColors {
+    internal fun ColorScheme.defaultIconButtonColors(localContentColor: Color): IconButtonColors {
         return defaultIconButtonColorsCached
             ?: run {
                 IconButtonColors(
                         containerColor = Color.Transparent,
-                        contentColor =
-                            localContentColor ?: fromToken(StandardIconButtonTokens.Color),
+                        contentColor = localContentColor,
                         disabledContainerColor = Color.Transparent,
                         disabledContentColor =
-                            localContentColor?.copy(
-                                alpha = StandardIconButtonTokens.DisabledOpacity
-                            )
-                                ?: fromToken(StandardIconButtonTokens.DisabledColor)
-                                    .copy(alpha = StandardIconButtonTokens.DisabledOpacity)
+                            localContentColor.copy(alpha = StandardIconButtonTokens.DisabledOpacity)
                     )
                     .also { defaultIconButtonColorsCached = it }
             }
     }
 
     /**
-     * Creates a [IconToggleButtonColors] that represents the default colors used in a
-     * [IconToggleButton]. [LocalContentColor] will be applied to the icon and down the UI tree.
+     * Creates a [IconButtonColors] that represents the recommended high contrast colors used in an
+     * [IconButton].
+     *
+     * See [iconButtonColors] for default values that applies [LocalContentColor] to the icon and
+     * down the UI tree.
      */
     @Composable
-    fun iconToggleButtonLocalContentColors(): IconToggleButtonColors {
+    fun iconButtonVibrantColors(): IconButtonColors =
+        MaterialTheme.colorScheme.defaultIconButtonVibrantColors()
+
+    /**
+     * Creates a [IconButtonColors] that represents the recommended high contrast colors used in an
+     * [IconButton].
+     *
+     * See [iconButtonColors] for default values that applies [LocalContentColor] to the icon and
+     * down the UI tree.
+     *
+     * @param containerColor the container color of this icon button when enabled.
+     * @param contentColor the content color of this icon button when enabled.
+     * @param disabledContainerColor the container color of this icon button when not enabled.
+     * @param disabledContentColor the content color of this icon button when not enabled.
+     */
+    @Composable
+    fun iconButtonVibrantColors(
+        containerColor: Color = Color.Unspecified,
+        contentColor: Color = Color.Unspecified,
+        disabledContainerColor: Color = Color.Unspecified,
+        disabledContentColor: Color =
+            contentColor.copy(alpha = StandardIconButtonTokens.DisabledOpacity)
+    ): IconButtonColors =
+        MaterialTheme.colorScheme
+            .defaultIconButtonVibrantColors()
+            .copy(
+                containerColor = containerColor,
+                contentColor = contentColor,
+                disabledContainerColor = disabledContainerColor,
+                disabledContentColor = disabledContentColor,
+            )
+
+    internal fun ColorScheme.defaultIconButtonVibrantColors(): IconButtonColors {
+        return defaultIconButtonVibrantColorsCached
+            ?: run {
+                IconButtonColors(
+                        containerColor = Color.Transparent,
+                        contentColor = fromToken(StandardIconButtonTokens.Color),
+                        disabledContainerColor = Color.Transparent,
+                        disabledContentColor =
+                            fromToken(StandardIconButtonTokens.DisabledColor)
+                                .copy(alpha = StandardIconButtonTokens.DisabledOpacity)
+                    )
+                    .also { defaultIconButtonVibrantColorsCached = it }
+            }
+    }
+
+    /**
+     * Creates a [IconToggleButtonColors] that represents the default colors used in a
+     * [IconToggleButton]. [LocalContentColor] will be applied to the icon and down the UI tree.
+     *
+     * See [iconToggleButtonVibrantColors] for default values that applies the recommended high
+     * contrast colors.
+     */
+    @Composable
+    fun iconToggleButtonColors(): IconToggleButtonColors {
         val contentColor = LocalContentColor.current
         val colors = MaterialTheme.colorScheme.defaultIconToggleButtonColors(contentColor)
         if (colors.contentColor == contentColor) {
@@ -1167,16 +1218,11 @@
 
     /**
      * Creates a [IconToggleButtonColors] that represents the default colors used in a
-     * [IconToggleButton]. See [iconToggleButtonLocalContentColors] for default values that applies
-     * [LocalContentColor] to the icon and down the UI tree.
-     */
-    @Composable
-    fun iconToggleButtonColors(): IconToggleButtonColors =
-        MaterialTheme.colorScheme.defaultIconToggleButtonColors()
-
-    /**
-     * Creates a [IconToggleButtonColors] that represents the default colors used in a
-     * [IconToggleButton].
+     * [IconToggleButton]. [LocalContentColor] will be applied to the icon and down the UI tree
+     * unless a custom [contentColor] is provided.
+     *
+     * See [iconToggleButtonVibrantColors] for default values that applies the recommended high
+     * contrast colors.
      *
      * @param containerColor the container color of this icon button when enabled.
      * @param contentColor the content color of this icon button when enabled.
@@ -1188,7 +1234,7 @@
     @Composable
     fun iconToggleButtonColors(
         containerColor: Color = Color.Unspecified,
-        contentColor: Color = Color.Unspecified,
+        contentColor: Color = LocalContentColor.current,
         disabledContainerColor: Color = Color.Unspecified,
         disabledContentColor: Color =
             contentColor.copy(alpha = StandardIconButtonTokens.DisabledOpacity),
@@ -1196,7 +1242,7 @@
         checkedContentColor: Color = Color.Unspecified
     ): IconToggleButtonColors =
         MaterialTheme.colorScheme
-            .defaultIconToggleButtonColors()
+            .defaultIconToggleButtonColors(LocalContentColor.current)
             .copy(
                 containerColor = containerColor,
                 contentColor = contentColor,
@@ -1207,22 +1253,18 @@
             )
 
     internal fun ColorScheme.defaultIconToggleButtonColors(
-        localContentColor: Color? = null,
+        localContentColor: Color
     ): IconToggleButtonColors {
         return defaultIconToggleButtonColorsCached
             ?: run {
                 IconToggleButtonColors(
                         containerColor = Color.Transparent,
-                        contentColor =
-                            localContentColor
-                                ?: fromToken(StandardIconButtonTokens.UnselectedColor),
+                        contentColor = localContentColor,
                         disabledContainerColor = Color.Transparent,
                         disabledContentColor =
-                            localContentColor?.copy(
+                            localContentColor.copy(
                                 alpha = StandardIconButtonTokens.DisabledOpacity
-                            )
-                                ?: fromToken(StandardIconButtonTokens.DisabledColor)
-                                    .copy(alpha = StandardIconButtonTokens.DisabledOpacity),
+                            ),
                         checkedContainerColor = Color.Transparent,
                         checkedContentColor = fromToken(StandardIconButtonTokens.SelectedColor)
                     )
@@ -1231,6 +1273,67 @@
     }
 
     /**
+     * Creates a [IconToggleButtonColors] that represents the recommended high contrast colors used
+     * in a [IconToggleButton]. See [iconToggleButtonColors] for default values that applies
+     * [LocalContentColor] to the icon and down the UI tree.
+     */
+    @Composable
+    fun iconToggleButtonVibrantColors(): IconToggleButtonColors =
+        MaterialTheme.colorScheme.defaultIconToggleButtonVibrantColors()
+
+    /**
+     * Creates a [IconToggleButtonColors] that represents the recommended high contrast colors used
+     * in a [IconToggleButton].
+     *
+     * See [iconToggleButtonColors] for default values that applies [LocalContentColor] to the icon
+     * and down the UI tree.
+     *
+     * @param containerColor the container color of this icon button when enabled.
+     * @param contentColor the content color of this icon button when enabled.
+     * @param disabledContainerColor the container color of this icon button when not enabled.
+     * @param disabledContentColor the content color of this icon button when not enabled.
+     * @param checkedContainerColor the container color of this icon button when checked.
+     * @param checkedContentColor the content color of this icon button when checked.
+     */
+    @Composable
+    fun iconToggleButtonVibrantColors(
+        containerColor: Color = Color.Unspecified,
+        contentColor: Color = Color.Unspecified,
+        disabledContainerColor: Color = Color.Unspecified,
+        disabledContentColor: Color =
+            contentColor.copy(alpha = StandardIconButtonTokens.DisabledOpacity),
+        checkedContainerColor: Color = Color.Unspecified,
+        checkedContentColor: Color = Color.Unspecified
+    ): IconToggleButtonColors =
+        MaterialTheme.colorScheme
+            .defaultIconToggleButtonVibrantColors()
+            .copy(
+                containerColor = containerColor,
+                contentColor = contentColor,
+                disabledContainerColor = disabledContainerColor,
+                disabledContentColor = disabledContentColor,
+                checkedContainerColor = checkedContainerColor,
+                checkedContentColor = checkedContentColor,
+            )
+
+    internal fun ColorScheme.defaultIconToggleButtonVibrantColors(): IconToggleButtonColors {
+        return defaultIconToggleButtonVibrantColorsCached
+            ?: run {
+                IconToggleButtonColors(
+                        containerColor = Color.Transparent,
+                        contentColor = fromToken(StandardIconButtonTokens.UnselectedColor),
+                        disabledContainerColor = Color.Transparent,
+                        disabledContentColor =
+                            fromToken(StandardIconButtonTokens.DisabledColor)
+                                .copy(alpha = StandardIconButtonTokens.DisabledOpacity),
+                        checkedContainerColor = Color.Transparent,
+                        checkedContentColor = fromToken(StandardIconButtonTokens.SelectedColor)
+                    )
+                    .also { defaultIconToggleButtonVibrantColorsCached = it }
+            }
+    }
+
+    /**
      * Creates a [IconButtonColors] that represents the default colors used in a [FilledIconButton].
      */
     @Composable
@@ -1443,9 +1546,12 @@
     /**
      * Creates a [IconButtonColors] that represents the default colors used in a
      * [OutlinedIconButton]. [LocalContentColor] will be applied to the icon and down the UI tree.
+     *
+     * See [outlinedIconButtonVibrantColors] for default values that applies the recommended high
+     * contrast colors.
      */
     @Composable
-    fun outlinedIconButtonLocalContentColors(): IconButtonColors {
+    fun outlinedIconButtonColors(): IconButtonColors {
         val contentColor = LocalContentColor.current
         val colors = MaterialTheme.colorScheme.defaultOutlinedIconButtonColors(contentColor)
         if (colors.contentColor == contentColor) {
@@ -1463,16 +1569,8 @@
      * Creates a [IconButtonColors] that represents the default colors used in a
      * [OutlinedIconButton].
      *
-     * See [outlinedIconButtonLocalContentColors] for default values that applies
-     * [LocalContentColor] to the icon and down the UI tree.
-     */
-    @Composable
-    fun outlinedIconButtonColors(): IconButtonColors =
-        MaterialTheme.colorScheme.defaultOutlinedIconButtonColors()
-
-    /**
-     * Creates a [IconButtonColors] that represents the default colors used in a
-     * [OutlinedIconButton].
+     * See [outlinedIconButtonVibrantColors] for default values that applies the recommended high
+     * contrast colors.
      *
      * @param containerColor the container color of this icon button when enabled.
      * @param contentColor the content color of this icon button when enabled.
@@ -1482,13 +1580,13 @@
     @Composable
     fun outlinedIconButtonColors(
         containerColor: Color = Color.Unspecified,
-        contentColor: Color = Color.Unspecified,
+        contentColor: Color = LocalContentColor.current,
         disabledContainerColor: Color = Color.Unspecified,
         disabledContentColor: Color =
             contentColor.copy(alpha = OutlinedIconButtonTokens.DisabledOpacity)
     ): IconButtonColors =
         MaterialTheme.colorScheme
-            .defaultOutlinedIconButtonColors()
+            .defaultOutlinedIconButtonColors(LocalContentColor.current)
             .copy(
                 containerColor = containerColor,
                 contentColor = contentColor,
@@ -1497,33 +1595,86 @@
             )
 
     internal fun ColorScheme.defaultOutlinedIconButtonColors(
-        localContentColor: Color? = null
+        localContentColor: Color
     ): IconButtonColors {
         return defaultOutlinedIconButtonColorsCached
             ?: run {
                 IconButtonColors(
                         containerColor = Color.Transparent,
-                        contentColor =
-                            localContentColor ?: fromToken(OutlinedIconButtonTokens.Color),
+                        contentColor = localContentColor,
                         disabledContainerColor = Color.Transparent,
                         disabledContentColor =
-                            localContentColor?.copy(
-                                alpha = OutlinedIconButtonTokens.DisabledOpacity
-                            )
-                                ?: fromToken(OutlinedIconButtonTokens.DisabledColor)
-                                    .copy(alpha = OutlinedIconButtonTokens.DisabledOpacity)
+                            localContentColor.copy(alpha = OutlinedIconButtonTokens.DisabledOpacity)
                     )
                     .also { defaultOutlinedIconButtonColorsCached = it }
             }
     }
 
     /**
+     * Creates a [IconButtonColors] that represents the default colors used in a
+     * [OutlinedIconButton].
+     *
+     * See [outlinedIconButtonColors] for default values that applies [LocalContentColor] to the
+     * icon and down the UI tree.
+     */
+    @Composable
+    fun outlinedIconButtonVibrantColors(): IconButtonColors =
+        MaterialTheme.colorScheme.defaultOutlinedIconButtonVibrantColors()
+
+    /**
+     * Creates a [IconButtonColors] that represents the default colors used in a
+     * [OutlinedIconButton].
+     *
+     * See [outlinedIconButtonColors] for default values that applies [LocalContentColor] to the
+     * icon and down the UI tree.
+     *
+     * @param containerColor the container color of this icon button when enabled.
+     * @param contentColor the content color of this icon button when enabled.
+     * @param disabledContainerColor the container color of this icon button when not enabled.
+     * @param disabledContentColor the content color of this icon button when not enabled.
+     */
+    @Composable
+    fun outlinedIconButtonVibrantColors(
+        containerColor: Color = Color.Unspecified,
+        contentColor: Color = Color.Unspecified,
+        disabledContainerColor: Color = Color.Unspecified,
+        disabledContentColor: Color =
+            contentColor.copy(alpha = OutlinedIconButtonTokens.DisabledOpacity)
+    ): IconButtonColors =
+        MaterialTheme.colorScheme
+            .defaultOutlinedIconButtonVibrantColors()
+            .copy(
+                containerColor = containerColor,
+                contentColor = contentColor,
+                disabledContainerColor = disabledContainerColor,
+                disabledContentColor = disabledContentColor,
+            )
+
+    internal fun ColorScheme.defaultOutlinedIconButtonVibrantColors(): IconButtonColors {
+        return defaultOutlinedIconButtonVibrantColorsCached
+            ?: run {
+                IconButtonColors(
+                        containerColor = Color.Transparent,
+                        contentColor = fromToken(OutlinedIconButtonTokens.Color),
+                        disabledContainerColor = Color.Transparent,
+                        disabledContentColor =
+                            fromToken(OutlinedIconButtonTokens.DisabledColor)
+                                .copy(alpha = OutlinedIconButtonTokens.DisabledOpacity)
+                    )
+                    .also { defaultOutlinedIconButtonVibrantColorsCached = it }
+            }
+    }
+
+    /**
      * Creates a [IconToggleButtonColors] that represents the default colors used in a
      * [OutlinedIconToggleButton]. [LocalContentColor] will be applied to the icon and down the UI
      * tree.
+     *
+     * See [outlinedIconButtonVibrantColors] for default values that applies the recommended high
+     * contrast colors.
      */
     @Composable
-    fun outlinedIconToggleButtonLocalContentColors(): IconToggleButtonColors {
+    fun outlinedIconToggleButtonColors(): IconToggleButtonColors {
         val contentColor = LocalContentColor.current
         val colors = MaterialTheme.colorScheme.defaultOutlinedIconToggleButtonColors(contentColor)
         if (colors.contentColor == contentColor) {
@@ -1539,18 +1690,11 @@
 
     /**
      * Creates a [IconToggleButtonColors] that represents the default colors used in a
-     * [OutlinedIconToggleButton].
+     * [OutlinedIconToggleButton]. [LocalContentColor] will be applied to the icon and down the UI
+     * tree.
      *
-     * See [outlinedIconToggleButtonLocalContentColors] for default values that applies
-     * [LocalContentColor] to the icon and down the UI tree.
-     */
-    @Composable
-    fun outlinedIconToggleButtonColors(): IconToggleButtonColors =
-        MaterialTheme.colorScheme.defaultOutlinedIconToggleButtonColors()
-
-    /**
-     * Creates a [IconToggleButtonColors] that represents the default colors used in a
-     * [OutlinedIconToggleButton].
+     * See [outlinedIconButtonVibrantColors] for default values that applies the recommended high
+     * contrast colors.
      *
      * @param containerColor the container color of this icon button when enabled.
      * @param contentColor the content color of this icon button when enabled.
@@ -1562,7 +1706,7 @@
     @Composable
     fun outlinedIconToggleButtonColors(
         containerColor: Color = Color.Unspecified,
-        contentColor: Color = Color.Unspecified,
+        contentColor: Color = LocalContentColor.current,
         disabledContainerColor: Color = Color.Unspecified,
         disabledContentColor: Color =
             contentColor.copy(alpha = OutlinedIconButtonTokens.DisabledOpacity),
@@ -1570,7 +1714,7 @@
         checkedContentColor: Color = contentColorFor(checkedContainerColor)
     ): IconToggleButtonColors =
         MaterialTheme.colorScheme
-            .defaultOutlinedIconToggleButtonColors()
+            .defaultOutlinedIconToggleButtonColors(LocalContentColor.current)
             .copy(
                 containerColor = containerColor,
                 contentColor = contentColor,
@@ -1581,22 +1725,86 @@
             )
 
     internal fun ColorScheme.defaultOutlinedIconToggleButtonColors(
-        localContentColor: Color? = null
+        localContentColor: Color
     ): IconToggleButtonColors {
-        return defaultOutlinedIconToggleButtonColorsCached
+        return defaultIconToggleButtonColorsCached
             ?: run {
                 IconToggleButtonColors(
                         containerColor = Color.Transparent,
-                        contentColor =
-                            localContentColor
-                                ?: fromToken(OutlinedIconButtonTokens.UnselectedColor),
+                        contentColor = localContentColor,
                         disabledContainerColor = Color.Transparent,
                         disabledContentColor =
-                            localContentColor?.copy(
+                            localContentColor.copy(
                                 alpha = OutlinedIconButtonTokens.DisabledOpacity
+                            ),
+                        checkedContainerColor =
+                            fromToken(OutlinedIconButtonTokens.SelectedContainerColor),
+                        checkedContentColor =
+                            contentColorFor(
+                                fromToken(OutlinedIconButtonTokens.SelectedContainerColor)
                             )
-                                ?: fromToken(OutlinedIconButtonTokens.DisabledColor)
-                                    .copy(alpha = OutlinedIconButtonTokens.DisabledOpacity),
+                    )
+                    .also { defaultOutlinedIconToggleButtonColorsCached = it }
+            }
+    }
+
+    /**
+     * Creates a [IconToggleButtonColors] that represents the default colors used in a
+     * [OutlinedIconToggleButton].
+     *
+     * See [outlinedIconToggleButtonColors] for default values that applies [LocalContentColor] to
+     * the icon and down the UI tree.
+     */
+    @Composable
+    fun outlinedIconToggleButtonVibrantColors(): IconToggleButtonColors =
+        MaterialTheme.colorScheme.defaultOutlinedIconToggleButtonVibrantColors()
+
+    /**
+     * Creates a [IconToggleButtonColors] that represents the default colors used in a
+     * [OutlinedIconToggleButton].
+     *
+     * See [outlinedIconToggleButtonColors] for default values that applies [LocalContentColor] to
+     * the icon and down the UI tree.
+     *
+     * @param containerColor the container color of this icon button when enabled.
+     * @param contentColor the content color of this icon button when enabled.
+     * @param disabledContainerColor the container color of this icon button when not enabled.
+     * @param disabledContentColor the content color of this icon button when not enabled.
+     * @param checkedContainerColor the container color of this icon button when checked.
+     * @param checkedContentColor the content color of this icon button when checked.
+     */
+    @Composable
+    fun outlinedIconToggleButtonVibrantColors(
+        containerColor: Color = Color.Unspecified,
+        contentColor: Color = Color.Unspecified,
+        disabledContainerColor: Color = Color.Unspecified,
+        disabledContentColor: Color =
+            contentColor.copy(alpha = OutlinedIconButtonTokens.DisabledOpacity),
+        checkedContainerColor: Color = Color.Unspecified,
+        checkedContentColor: Color = contentColorFor(checkedContainerColor)
+    ): IconToggleButtonColors =
+        MaterialTheme.colorScheme
+            .defaultOutlinedIconToggleButtonVibrantColors()
+            .copy(
+                containerColor = containerColor,
+                contentColor = contentColor,
+                disabledContainerColor = disabledContainerColor,
+                disabledContentColor = disabledContentColor,
+                checkedContainerColor = checkedContainerColor,
+                checkedContentColor = checkedContentColor,
+            )
+
+    internal fun ColorScheme.defaultOutlinedIconToggleButtonVibrantColors():
+        IconToggleButtonColors {
+        return defaultOutlinedIconToggleButtonVibrantColorsCached
+            ?: run {
+                IconToggleButtonColors(
+                        containerColor = Color.Transparent,
+                        contentColor = fromToken(OutlinedIconButtonTokens.UnselectedColor),
+                        disabledContainerColor = Color.Transparent,
+                        disabledContentColor =
+                            fromToken(OutlinedIconButtonTokens.DisabledColor)
+                                .copy(alpha = OutlinedIconButtonTokens.DisabledOpacity),
                         checkedContainerColor =
                             fromToken(OutlinedIconButtonTokens.SelectedContainerColor),
                         checkedContentColor = fromToken(OutlinedIconButtonTokens.SelectedColor)
@@ -1607,25 +1815,10 @@
 
     /**
      * Represents the [BorderStroke] for an [OutlinedIconButton], depending on its [enabled] and
-     * [checked] state.
+     * [checked] state. [LocalContentColor] will be used as the border color.
      *
-     * @param enabled whether the icon button is enabled
-     * @param checked whether the icon button is checked
-     */
-    @Composable
-    fun outlinedIconToggleButtonLocalContentColorBorder(
-        enabled: Boolean,
-        checked: Boolean
-    ): BorderStroke? {
-        if (checked) {
-            return null
-        }
-        return outlinedIconButtonLocalContentColorBorder(enabled)
-    }
-
-    /**
-     * Represents the [BorderStroke] for an [OutlinedIconButton], depending on its [enabled] and
-     * [checked] state.
+     * See [outlinedIconToggleButtonVibrantBorder] for a [BorderStroke] that uses the spec
+     * recommended color as the border color.
      *
      * @param enabled whether the icon button is enabled
      * @param checked whether the icon button is checked
@@ -1638,8 +1831,32 @@
         return outlinedIconButtonBorder(enabled)
     }
 
+    /**
+     * Represents the [BorderStroke] for an [OutlinedIconButton], depending on its [enabled] and
+     * [checked] state. The spec recommended color will be used as the border color.
+     *
+     * @param enabled whether the icon button is enabled
+     * @param checked whether the icon button is checked
+     */
     @Composable
-    fun outlinedIconButtonLocalContentColorBorder(enabled: Boolean): BorderStroke? {
+    fun outlinedIconToggleButtonVibrantBorder(enabled: Boolean, checked: Boolean): BorderStroke? {
+        if (checked) {
+            return null
+        }
+        return outlinedIconButtonVibrantBorder(enabled)
+    }
+
+    /**
+     * Represents the [BorderStroke] for an [OutlinedIconButton], depending on its [enabled] state.
+     * [LocalContentColor] will be used as the border color.
+     *
+     * See [outlinedIconToggleButtonVibrantBorder] for a [BorderStroke] that uses the spec
+     * recommended color as the border color.
+     *
+     * @param enabled whether the icon button is enabled
+     */
+    @Composable
+    fun outlinedIconButtonBorder(enabled: Boolean): BorderStroke {
         val outlineColor = LocalContentColor.current
         val color: Color =
             if (enabled) {
@@ -1652,11 +1869,12 @@
 
     /**
      * Represents the [BorderStroke] for an [OutlinedIconButton], depending on its [enabled] state.
+     * The spec recommended color will be used as the border color.
      *
      * @param enabled whether the icon button is enabled
      */
     @Composable
-    fun outlinedIconButtonBorder(enabled: Boolean): BorderStroke {
+    fun outlinedIconButtonVibrantBorder(enabled: Boolean): BorderStroke {
         val outlineColor = OutlinedIconButtonTokens.OutlineColor.value
         val color: Color =
             if (enabled) {
@@ -2010,7 +2228,7 @@
  * - See [IconButtonDefaults.filledIconButtonColors] and
  *   [IconButtonDefaults.filledTonalIconButtonColors] for the default colors used in a
  *   [FilledIconButton].
- * - See [IconButtonDefaults.outlinedIconButtonColors] for the default colors used in an
+ * - See [IconButtonDefaults.outlinedIconButtonVibrantColors] for the default colors used in an
  *   [OutlinedIconButton].
  */
 @Immutable
@@ -2091,7 +2309,7 @@
  * - See [IconButtonDefaults.filledIconToggleButtonColors] and
  *   [IconButtonDefaults.filledTonalIconToggleButtonColors] for the default colors used in a
  *   [FilledIconButton].
- * - See [IconButtonDefaults.outlinedIconToggleButtonColors] for the default colors used in a
+ * - See [IconButtonDefaults.outlinedIconToggleButtonVibrantColors] for the default colors used in a
  *   toggleable [OutlinedIconButton].
  */
 @Immutable
diff --git a/compose/runtime/runtime/api/current.ignore b/compose/runtime/runtime/api/current.ignore
index 7b50ec7..75715a1 100644
--- a/compose/runtime/runtime/api/current.ignore
+++ b/compose/runtime/runtime/api/current.ignore
@@ -1,6 +1,6 @@
 // Baseline format: 1.0
-AddedAbstractMethod: androidx.compose.runtime.ControlledComposition#setShouldPauseCallback(kotlin.jvm.functions.Function0<java.lang.Boolean>):
-    Added method androidx.compose.runtime.ControlledComposition.setShouldPauseCallback(kotlin.jvm.functions.Function0<java.lang.Boolean>)
+AddedAbstractMethod: androidx.compose.runtime.ControlledComposition#getAndSetShouldPauseCallback(androidx.compose.runtime.ShouldPauseCallback):
+    Added method androidx.compose.runtime.ControlledComposition.getAndSetShouldPauseCallback(androidx.compose.runtime.ShouldPauseCallback)
 
 
 BecameUnchecked: androidx.compose.runtime.Composer#compoundKeyHash:
diff --git a/compose/runtime/runtime/api/current.txt b/compose/runtime/runtime/api/current.txt
index 11a7666..e5eee1c 100644
--- a/compose/runtime/runtime/api/current.txt
+++ b/compose/runtime/runtime/api/current.txt
@@ -279,6 +279,7 @@
     method public void composeContent(kotlin.jvm.functions.Function0<kotlin.Unit> content);
     method public <R> R delegateInvalidations(androidx.compose.runtime.ControlledComposition? to, int groupIndex, kotlin.jvm.functions.Function0<? extends R> block);
     method @SuppressCompatibility @androidx.compose.runtime.InternalComposeApi public void disposeUnusedMovableContent(androidx.compose.runtime.MovableContentState state);
+    method public androidx.compose.runtime.ShouldPauseCallback? getAndSetShouldPauseCallback(androidx.compose.runtime.ShouldPauseCallback? shouldPause);
     method public boolean getHasPendingChanges();
     method @SuppressCompatibility @androidx.compose.runtime.InternalComposeApi public void insertMovableContent(java.util.List<kotlin.Pair<androidx.compose.runtime.MovableContentStateReference,androidx.compose.runtime.MovableContentStateReference?>> references);
     method public void invalidateAll();
@@ -289,7 +290,6 @@
     method public void recordModificationsOf(java.util.Set<?> values);
     method public void recordReadOf(Object value);
     method public void recordWriteOf(Object value);
-    method public kotlin.jvm.functions.Function0<java.lang.Boolean>? setShouldPauseCallback(kotlin.jvm.functions.Function0<java.lang.Boolean>? shouldPause);
     method @SuppressCompatibility @androidx.compose.runtime.InternalComposeApi public void verifyConsistent();
     property public abstract boolean hasPendingChanges;
     property public abstract boolean isComposing;
@@ -463,7 +463,7 @@
   @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.SOURCE) @kotlin.annotation.Target(allowedTargets={kotlin.annotation.AnnotationTarget.FUNCTION, kotlin.annotation.AnnotationTarget.PROPERTY_GETTER}) public @interface NonSkippableComposable {
   }
 
-  public interface PausableComposition extends androidx.compose.runtime.ReusableComposition {
+  public sealed interface PausableComposition extends androidx.compose.runtime.ReusableComposition {
     method public androidx.compose.runtime.PausedComposition setPausableContent(kotlin.jvm.functions.Function0<kotlin.Unit> content);
     method public androidx.compose.runtime.PausedComposition setPausableContentWithReuse(kotlin.jvm.functions.Function0<kotlin.Unit> content);
   }
@@ -481,11 +481,11 @@
     property public final boolean isPaused;
   }
 
-  public interface PausedComposition {
+  public sealed interface PausedComposition {
     method public void apply();
     method public void cancel();
     method public boolean isComplete();
-    method public boolean resume(kotlin.jvm.functions.Function0<java.lang.Boolean> shouldPause);
+    method public boolean resume(androidx.compose.runtime.ShouldPauseCallback shouldPause);
     property public abstract boolean isComplete;
   }
 
@@ -587,6 +587,10 @@
     method public void updateScope(kotlin.jvm.functions.Function2<? super androidx.compose.runtime.Composer,? super java.lang.Integer,kotlin.Unit> block);
   }
 
+  public fun interface ShouldPauseCallback {
+    method public boolean shouldPause();
+  }
+
   @kotlin.jvm.JvmInline public final value class SkippableUpdater<T> {
     ctor public SkippableUpdater(@kotlin.PublishedApi androidx.compose.runtime.Composer composer);
     method public inline void update(kotlin.jvm.functions.Function1<? super androidx.compose.runtime.Updater<T>,kotlin.Unit> block);
diff --git a/compose/runtime/runtime/api/restricted_current.ignore b/compose/runtime/runtime/api/restricted_current.ignore
index 7b50ec7..75715a1 100644
--- a/compose/runtime/runtime/api/restricted_current.ignore
+++ b/compose/runtime/runtime/api/restricted_current.ignore
@@ -1,6 +1,6 @@
 // Baseline format: 1.0
-AddedAbstractMethod: androidx.compose.runtime.ControlledComposition#setShouldPauseCallback(kotlin.jvm.functions.Function0<java.lang.Boolean>):
-    Added method androidx.compose.runtime.ControlledComposition.setShouldPauseCallback(kotlin.jvm.functions.Function0<java.lang.Boolean>)
+AddedAbstractMethod: androidx.compose.runtime.ControlledComposition#getAndSetShouldPauseCallback(androidx.compose.runtime.ShouldPauseCallback):
+    Added method androidx.compose.runtime.ControlledComposition.getAndSetShouldPauseCallback(androidx.compose.runtime.ShouldPauseCallback)
 
 
 BecameUnchecked: androidx.compose.runtime.Composer#compoundKeyHash:
diff --git a/compose/runtime/runtime/api/restricted_current.txt b/compose/runtime/runtime/api/restricted_current.txt
index 15f6290..2ebdc05 100644
--- a/compose/runtime/runtime/api/restricted_current.txt
+++ b/compose/runtime/runtime/api/restricted_current.txt
@@ -319,6 +319,7 @@
     method public void composeContent(kotlin.jvm.functions.Function0<kotlin.Unit> content);
     method public <R> R delegateInvalidations(androidx.compose.runtime.ControlledComposition? to, int groupIndex, kotlin.jvm.functions.Function0<? extends R> block);
     method @SuppressCompatibility @androidx.compose.runtime.InternalComposeApi public void disposeUnusedMovableContent(androidx.compose.runtime.MovableContentState state);
+    method public androidx.compose.runtime.ShouldPauseCallback? getAndSetShouldPauseCallback(androidx.compose.runtime.ShouldPauseCallback? shouldPause);
     method public boolean getHasPendingChanges();
     method @SuppressCompatibility @androidx.compose.runtime.InternalComposeApi public void insertMovableContent(java.util.List<kotlin.Pair<androidx.compose.runtime.MovableContentStateReference,androidx.compose.runtime.MovableContentStateReference?>> references);
     method public void invalidateAll();
@@ -329,7 +330,6 @@
     method public void recordModificationsOf(java.util.Set<?> values);
     method public void recordReadOf(Object value);
     method public void recordWriteOf(Object value);
-    method public kotlin.jvm.functions.Function0<java.lang.Boolean>? setShouldPauseCallback(kotlin.jvm.functions.Function0<java.lang.Boolean>? shouldPause);
     method @SuppressCompatibility @androidx.compose.runtime.InternalComposeApi public void verifyConsistent();
     property public abstract boolean hasPendingChanges;
     property public abstract boolean isComposing;
@@ -504,7 +504,7 @@
   @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.SOURCE) @kotlin.annotation.Target(allowedTargets={kotlin.annotation.AnnotationTarget.FUNCTION, kotlin.annotation.AnnotationTarget.PROPERTY_GETTER}) public @interface NonSkippableComposable {
   }
 
-  public interface PausableComposition extends androidx.compose.runtime.ReusableComposition {
+  public sealed interface PausableComposition extends androidx.compose.runtime.ReusableComposition {
     method public androidx.compose.runtime.PausedComposition setPausableContent(kotlin.jvm.functions.Function0<kotlin.Unit> content);
     method public androidx.compose.runtime.PausedComposition setPausableContentWithReuse(kotlin.jvm.functions.Function0<kotlin.Unit> content);
   }
@@ -522,11 +522,11 @@
     property public final boolean isPaused;
   }
 
-  public interface PausedComposition {
+  public sealed interface PausedComposition {
     method public void apply();
     method public void cancel();
     method public boolean isComplete();
-    method public boolean resume(kotlin.jvm.functions.Function0<java.lang.Boolean> shouldPause);
+    method public boolean resume(androidx.compose.runtime.ShouldPauseCallback shouldPause);
     property public abstract boolean isComplete;
   }
 
@@ -632,6 +632,10 @@
     method public void updateScope(kotlin.jvm.functions.Function2<? super androidx.compose.runtime.Composer,? super java.lang.Integer,kotlin.Unit> block);
   }
 
+  public fun interface ShouldPauseCallback {
+    method public boolean shouldPause();
+  }
+
   @kotlin.jvm.JvmInline public final value class SkippableUpdater<T> {
     ctor public SkippableUpdater(@kotlin.PublishedApi androidx.compose.runtime.Composer composer);
     method public inline void update(kotlin.jvm.functions.Function1<? super androidx.compose.runtime.Updater<T>,kotlin.Unit> block);
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 27bad04..5216ba1 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
@@ -993,6 +993,18 @@
      * not execute if parameter has not changed and the nothing else is forcing the function to
      * execute (such as its scope was invalidated or a static composition local it was changed) or
      * the composition is pausable and the composition is pausing.
+     *
+     * @param parametersChanged `true` if the parameters to the composable function have changed.
+     *   This is also `true` if the composition is [inserting] or if content is being reused.
+     * @param flags The `$changed` parameter that contains the forced recompose bit to allow the
+     *   composer to disambiguate when the parameters changed due the execution being forced or if
+     *   the parameters actually changed. This is only ambiguous in a [PausableComposition] and is
+     *   necessary to determine if the function can be paused. The bits, other than 0, are reserved
+     *   for future use (which would required the bit 31, which is unused in `$changed` values, to
+     *   be set to indicate that the flags carry additional information). Passing the `$changed`
+     *   flags directly, instead of masking the 0 bit, is more efficient as it allows less code to
+     *   be generated per call to `shouldExecute` which is every called in every restartable
+     *   function, as well as allowing for the API to be extended without a breaking changed.
      */
     @InternalComposeApi fun shouldExecute(parametersChanged: Boolean, flags: Int): Boolean
 
@@ -1366,7 +1378,7 @@
     private var insertFixups = FixupList()
 
     private var pausable: Boolean = false
-    private var shouldPauseCallback: (() -> Boolean)? = null
+    private var shouldPauseCallback: ShouldPauseCallback? = null
 
     override val applyCoroutineContext: CoroutineContext
         @TestOnly get() = parentContext.effectCoroutineContext
@@ -3082,7 +3094,7 @@
         if (((flags and 1) == 0) && (inserting || reusing)) {
             val callback = shouldPauseCallback ?: return true
             val scope = currentRecomposeScope ?: return true
-            val pausing = callback()
+            val pausing = callback.shouldPause()
             if (pausing) {
                 scope.used = true
                 // Force the composer back into the reusing state when this scope restarts.
@@ -3514,7 +3526,7 @@
     internal fun composeContent(
         invalidationsRequested: ScopeMap<RecomposeScopeImpl, Any>,
         content: @Composable () -> Unit,
-        shouldPause: (() -> Boolean)?
+        shouldPause: ShouldPauseCallback?
     ) {
         runtimeCheck(changes.isEmpty()) { "Expected applyChanges() to have been called" }
         this.shouldPauseCallback = shouldPause
@@ -3541,7 +3553,7 @@
      */
     internal fun recompose(
         invalidationsRequested: ScopeMap<RecomposeScopeImpl, Any>,
-        shouldPause: (() -> Boolean)?
+        shouldPause: ShouldPauseCallback?
     ): Boolean {
         runtimeCheck(changes.isEmpty()) { "Expected applyChanges() to have been called" }
         // even if invalidationsRequested is empty we still need to recompose if the Composer has
@@ -3905,14 +3917,14 @@
 
         override fun composeInitialPaused(
             composition: ControlledComposition,
-            shouldPause: () -> Boolean,
+            shouldPause: ShouldPauseCallback,
             content: @Composable () -> Unit
         ): ScatterSet<RecomposeScopeImpl> =
             parentContext.composeInitialPaused(composition, shouldPause, content)
 
         override fun recomposePaused(
             composition: ControlledComposition,
-            shouldPause: () -> Boolean,
+            shouldPause: ShouldPauseCallback,
             invalidScopes: ScatterSet<RecomposeScopeImpl>
         ): ScatterSet<RecomposeScopeImpl> =
             parentContext.recomposePaused(composition, shouldPause, invalidScopes)
diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Composition.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Composition.kt
index c764476..5938406 100644
--- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Composition.kt
+++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Composition.kt
@@ -297,19 +297,20 @@
      *   longer needed.
      * @see PausableComposition
      */
-    fun setShouldPauseCallback(shouldPause: (() -> Boolean)?): (() -> Boolean)?
+    @Suppress("ExecutorRegistration")
+    fun getAndSetShouldPauseCallback(shouldPause: ShouldPauseCallback?): ShouldPauseCallback?
 }
 
 /** Utility function to set and restore a should pause callback. */
 internal inline fun <R> ControlledComposition.pausable(
-    noinline shouldPause: () -> Boolean,
+    shouldPause: ShouldPauseCallback,
     block: () -> R
 ): R {
-    val previous = setShouldPauseCallback(shouldPause)
+    val previous = getAndSetShouldPauseCallback(shouldPause)
     return try {
         block()
     } finally {
-        setShouldPauseCallback(previous)
+        getAndSetShouldPauseCallback(previous)
     }
 }
 
@@ -551,7 +552,7 @@
      * If the [shouldPause] callback is set the composition is pausable and should pause whenever
      * the [shouldPause] callback returns `true`.
      */
-    private var shouldPause: (() -> Boolean)? = null
+    private var shouldPause: ShouldPauseCallback? = null
 
     private var pendingPausedComposition: PausedCompositionImpl? = null
 
@@ -1152,7 +1153,9 @@
         } else block()
     }
 
-    override fun setShouldPauseCallback(shouldPause: (() -> Boolean)?): (() -> Boolean)? {
+    override fun getAndSetShouldPauseCallback(
+        shouldPause: ShouldPauseCallback?
+    ): ShouldPauseCallback? {
         val previous = this.shouldPause
         this.shouldPause = shouldPause
         return previous
diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/CompositionContext.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/CompositionContext.kt
index e5b6d6b..8f4bcb7 100644
--- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/CompositionContext.kt
+++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/CompositionContext.kt
@@ -55,13 +55,13 @@
 
     internal abstract fun composeInitialPaused(
         composition: ControlledComposition,
-        shouldPause: () -> Boolean,
+        shouldPause: ShouldPauseCallback,
         content: @Composable () -> Unit
     ): ScatterSet<RecomposeScopeImpl>
 
     internal abstract fun recomposePaused(
         composition: ControlledComposition,
-        shouldPause: () -> Boolean,
+        shouldPause: ShouldPauseCallback,
         invalidScopes: ScatterSet<RecomposeScopeImpl>
     ): ScatterSet<RecomposeScopeImpl>
 
diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/PausableComposition.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/PausableComposition.kt
index 0eff8d6..b02efd9 100644
--- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/PausableComposition.kt
+++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/PausableComposition.kt
@@ -48,7 +48,7 @@
  * @see Composition
  * @see ReusableComposition
  */
-interface PausableComposition : ReusableComposition {
+sealed interface PausableComposition : ReusableComposition {
     /**
      * Set the content of the composition. A [PausedComposition] that is currently paused. No
      * composition is performed until [PausedComposition.resume] is called.
@@ -74,6 +74,17 @@
     fun setPausableContentWithReuse(content: @Composable () -> Unit): PausedComposition
 }
 
+/** The callback type used in [PausedComposition.resume]. */
+fun interface ShouldPauseCallback {
+    /**
+     * Called to determine if a resumed [PausedComposition] should pause.
+     *
+     * @return Return `true` to indicate that the composition should pause. Otherwise the
+     *   composition will continue normally.
+     */
+    @Suppress("CallbackMethodName") fun shouldPause(): Boolean
+}
+
 /**
  * [PausedComposition] is the result of calling [PausableComposition.setContent] or
  * [PausableComposition.setContentWithReuse]. It is used to drive the paused composition to
@@ -83,7 +94,7 @@
  * A [PausedComposition] is created paused and will only compose the `content` parameter when
  * [resume] is called the first time.
  */
-interface PausedComposition {
+sealed interface PausedComposition {
     /**
      * Returns `true` when the [PausedComposition] is complete. [isComplete] matches the last value
      * returned from [resume]. Once a [PausedComposition] is [isComplete] the [apply] method should
@@ -109,7 +120,7 @@
      * @return `true` if the composition is complete and `false` if one or more calls to `resume`
      *   are required to complete composition.
      */
-    fun resume(shouldPause: () -> Boolean): Boolean
+    @Suppress("ExecutorRegistration") fun resume(shouldPause: ShouldPauseCallback): Boolean
 
     /**
      * Apply the composition. This is the last step of a paused composition and is required to be
@@ -164,7 +175,7 @@
     override val isComplete: Boolean
         get() = state >= PausedCompositionState.ApplyPending
 
-    override fun resume(shouldPause: () -> Boolean): Boolean {
+    override fun resume(shouldPause: ShouldPauseCallback): Boolean {
         try {
             when (state) {
                 PausedCompositionState.InitialPending -> {
diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Recomposer.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Recomposer.kt
index f5309e8..8cd4e28 100644
--- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Recomposer.kt
+++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Recomposer.kt
@@ -1163,7 +1163,7 @@
 
     internal override fun composeInitialPaused(
         composition: ControlledComposition,
-        shouldPause: () -> Boolean,
+        shouldPause: ShouldPauseCallback,
         content: @Composable () -> Unit
     ): ScatterSet<RecomposeScopeImpl> {
         return try {
@@ -1178,7 +1178,7 @@
 
     internal override fun recomposePaused(
         composition: ControlledComposition,
-        shouldPause: () -> Boolean,
+        shouldPause: ShouldPauseCallback,
         invalidScopes: ScatterSet<RecomposeScopeImpl>
     ): ScatterSet<RecomposeScopeImpl> {
         return try {
diff --git a/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/platform/AndroidTextPaint.android.kt b/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/platform/AndroidTextPaint.android.kt
index 45c3009..469ac38 100644
--- a/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/platform/AndroidTextPaint.android.kt
+++ b/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/platform/AndroidTextPaint.android.kt
@@ -64,6 +64,14 @@
 
     @VisibleForTesting internal var shadow: Shadow = Shadow.None
 
+    /**
+     * Different than other backing properties, this variable only exists to enable easy comparison
+     * between the last set color and the new color that is going to be set. Color conversion from
+     * Compose to platform color primitive integer is expensive, so it is more efficient to skip
+     * this conversion if the color is not going to change at all.
+     */
+    private var lastColor: Color? = null
+
     @VisibleForTesting internal var brush: Brush? = null
 
     internal var shaderState: State<Shader?>? = null
@@ -99,7 +107,8 @@
     }
 
     fun setColor(color: Color) {
-        if (color.isSpecified) {
+        if (lastColor != color && color.isSpecified) {
+            this.lastColor = color
             this.color = color.toArgb()
             clearShader()
         }
@@ -132,6 +141,7 @@
                     }
                 }
                 composePaint.shader = this.shaderState?.value
+                this.lastColor = null
                 setAlpha(alpha)
             }
         }
diff --git a/compose/ui/ui/api/current.txt b/compose/ui/ui/api/current.txt
index 7d35139..261fc8c 100644
--- a/compose/ui/ui/api/current.txt
+++ b/compose/ui/ui/api/current.txt
@@ -2067,6 +2067,7 @@
 
   public final class PointerIconKt {
     method @androidx.compose.runtime.Stable public static androidx.compose.ui.Modifier pointerHoverIcon(androidx.compose.ui.Modifier, androidx.compose.ui.input.pointer.PointerIcon icon, optional boolean overrideDescendants);
+    method public static androidx.compose.ui.Modifier stylusHoverIcon(androidx.compose.ui.Modifier, androidx.compose.ui.input.pointer.PointerIcon icon, optional boolean overrideDescendants, optional androidx.compose.ui.node.DpTouchBoundsExpansion? touchBoundsExpansion);
   }
 
   public final class PointerIcon_androidKt {
@@ -2860,6 +2861,32 @@
     method protected final void undelegate(androidx.compose.ui.node.DelegatableNode instance);
   }
 
+  public final class DpTouchBoundsExpansion {
+    ctor public DpTouchBoundsExpansion(float start, float top, float end, float bottom, boolean isLayoutDirectionAware);
+    method public float component1-D9Ej5fM();
+    method public float component2-D9Ej5fM();
+    method public float component3-D9Ej5fM();
+    method public float component4-D9Ej5fM();
+    method public boolean component5();
+    method public androidx.compose.ui.node.DpTouchBoundsExpansion copy-lDy3nrA(float start, float top, float end, float bottom, boolean isLayoutDirectionAware);
+    method public float getBottom();
+    method public float getEnd();
+    method public float getStart();
+    method public float getTop();
+    method public boolean isLayoutDirectionAware();
+    method public long roundToTouchBoundsExpansion(androidx.compose.ui.unit.Density density);
+    property public final float bottom;
+    property public final float end;
+    property public final boolean isLayoutDirectionAware;
+    property public final float start;
+    property public final float top;
+    field public static final androidx.compose.ui.node.DpTouchBoundsExpansion.Companion Companion;
+  }
+
+  public static final class DpTouchBoundsExpansion.Companion {
+    method public androidx.compose.ui.node.DpTouchBoundsExpansion Absolute(optional float left, optional float top, optional float right, optional float bottom);
+  }
+
   public interface DrawModifierNode extends androidx.compose.ui.node.DelegatableNode {
     method public void draw(androidx.compose.ui.graphics.drawscope.ContentDrawScope);
     method public default void onMeasureResultChanged();
@@ -2941,10 +2968,6 @@
     property public default long touchBoundsExpansion;
   }
 
-  public final class PointerInputModifierNodeKt {
-    method public static long TouchBoundsExpansion(optional int start, optional int top, optional int end, optional int bottom);
-  }
-
   public final class Ref<T> {
     ctor public Ref();
     method public T? getValue();
@@ -2992,6 +3015,11 @@
     property public final long None;
   }
 
+  public final class TouchBoundsExpansionKt {
+    method public static androidx.compose.ui.node.DpTouchBoundsExpansion DpTouchBoundsExpansion(optional float start, optional float top, optional float end, optional float bottom);
+    method public static long TouchBoundsExpansion(optional int start, optional int top, optional int end, optional int bottom);
+  }
+
   public interface TraversableNode extends androidx.compose.ui.node.DelegatableNode {
     method public Object getTraverseKey();
     property public abstract Object traverseKey;
diff --git a/compose/ui/ui/api/restricted_current.txt b/compose/ui/ui/api/restricted_current.txt
index 03f5875..24770bd 100644
--- a/compose/ui/ui/api/restricted_current.txt
+++ b/compose/ui/ui/api/restricted_current.txt
@@ -2068,6 +2068,7 @@
 
   public final class PointerIconKt {
     method @androidx.compose.runtime.Stable public static androidx.compose.ui.Modifier pointerHoverIcon(androidx.compose.ui.Modifier, androidx.compose.ui.input.pointer.PointerIcon icon, optional boolean overrideDescendants);
+    method public static androidx.compose.ui.Modifier stylusHoverIcon(androidx.compose.ui.Modifier, androidx.compose.ui.input.pointer.PointerIcon icon, optional boolean overrideDescendants, optional androidx.compose.ui.node.DpTouchBoundsExpansion? touchBoundsExpansion);
   }
 
   public final class PointerIcon_androidKt {
@@ -2914,6 +2915,32 @@
     method protected final void undelegate(androidx.compose.ui.node.DelegatableNode instance);
   }
 
+  public final class DpTouchBoundsExpansion {
+    ctor public DpTouchBoundsExpansion(float start, float top, float end, float bottom, boolean isLayoutDirectionAware);
+    method public float component1-D9Ej5fM();
+    method public float component2-D9Ej5fM();
+    method public float component3-D9Ej5fM();
+    method public float component4-D9Ej5fM();
+    method public boolean component5();
+    method public androidx.compose.ui.node.DpTouchBoundsExpansion copy-lDy3nrA(float start, float top, float end, float bottom, boolean isLayoutDirectionAware);
+    method public float getBottom();
+    method public float getEnd();
+    method public float getStart();
+    method public float getTop();
+    method public boolean isLayoutDirectionAware();
+    method public long roundToTouchBoundsExpansion(androidx.compose.ui.unit.Density density);
+    property public final float bottom;
+    property public final float end;
+    property public final boolean isLayoutDirectionAware;
+    property public final float start;
+    property public final float top;
+    field public static final androidx.compose.ui.node.DpTouchBoundsExpansion.Companion Companion;
+  }
+
+  public static final class DpTouchBoundsExpansion.Companion {
+    method public androidx.compose.ui.node.DpTouchBoundsExpansion Absolute(optional float left, optional float top, optional float right, optional float bottom);
+  }
+
   public interface DrawModifierNode extends androidx.compose.ui.node.DelegatableNode {
     method public void draw(androidx.compose.ui.graphics.drawscope.ContentDrawScope);
     method public default void onMeasureResultChanged();
@@ -2995,10 +3022,6 @@
     property public default long touchBoundsExpansion;
   }
 
-  public final class PointerInputModifierNodeKt {
-    method public static long TouchBoundsExpansion(optional int start, optional int top, optional int end, optional int bottom);
-  }
-
   public final class Ref<T> {
     ctor public Ref();
     method public T? getValue();
@@ -3046,6 +3069,11 @@
     property public final long None;
   }
 
+  public final class TouchBoundsExpansionKt {
+    method public static androidx.compose.ui.node.DpTouchBoundsExpansion DpTouchBoundsExpansion(optional float start, optional float top, optional float end, optional float bottom);
+    method public static long TouchBoundsExpansion(optional int start, optional int top, optional int end, optional int bottom);
+  }
+
   public interface TraversableNode extends androidx.compose.ui.node.DelegatableNode {
     method public Object getTraverseKey();
     property public abstract Object traverseKey;
diff --git a/compose/ui/ui/samples/src/main/java/androidx/compose/ui/samples/PointerIconSample.kt b/compose/ui/ui/samples/src/main/java/androidx/compose/ui/samples/PointerIconSample.kt
index 432ebf02..1632255 100644
--- a/compose/ui/ui/samples/src/main/java/androidx/compose/ui/samples/PointerIconSample.kt
+++ b/compose/ui/ui/samples/src/main/java/androidx/compose/ui/samples/PointerIconSample.kt
@@ -17,13 +17,22 @@
 package androidx.compose.ui.samples
 
 import androidx.annotation.Sampled
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.requiredSize
 import androidx.compose.foundation.text.selection.SelectionContainer
 import androidx.compose.material.Text
 import androidx.compose.runtime.Composable
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.SolidColor
 import androidx.compose.ui.input.pointer.PointerIcon
 import androidx.compose.ui.input.pointer.pointerHoverIcon
+import androidx.compose.ui.input.pointer.stylusHoverIcon
+import androidx.compose.ui.unit.dp
 
 @Sampled
 @Composable
@@ -41,3 +50,31 @@
         Text("Just text with global pointerIcon")
     }
 }
+
+@Sampled
+@Composable
+fun StylusHoverIconSample() {
+    Box(
+        Modifier.requiredSize(200.dp)
+            .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+            .stylusHoverIcon(PointerIcon.Crosshair)
+    ) {
+        Text(text = "crosshair icon")
+        Box(
+            Modifier.padding(20.dp)
+                .requiredSize(150.dp)
+                .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+                .stylusHoverIcon(PointerIcon.Text)
+        ) {
+            Text(text = "text icon")
+            Box(
+                Modifier.padding(40.dp)
+                    .requiredSize(100.dp)
+                    .border(BorderStroke(2.dp, SolidColor(Color.Blue)))
+                    .stylusHoverIcon(PointerIcon.Hand)
+            ) {
+                Text(text = "hand icon")
+            }
+        }
+    }
+}
diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/pointer/HandwritingTestUtils.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/pointer/HandwritingTestUtils.kt
new file mode 100644
index 0000000..43e2af7
--- /dev/null
+++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/pointer/HandwritingTestUtils.kt
@@ -0,0 +1,176 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.compose.ui.input.pointer
+
+import android.view.MotionEvent
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.platform.ViewConfiguration
+import androidx.compose.ui.platform.ViewRootForTest
+import androidx.compose.ui.semantics.SemanticsNode
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.SemanticsNodeInteraction
+import androidx.compose.ui.test.TouchInjectionScope
+import androidx.compose.ui.test.invokeGlobalAssertions
+import androidx.compose.ui.test.tryPerformAccessibilityChecks
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.IntSize
+import androidx.core.view.InputDeviceCompat
+import androidx.test.platform.app.InstrumentationRegistry
+import kotlin.math.roundToInt
+
+// We don't have StylusInjectionScope at the moment. This is a simplified implementation for
+// the basic use cases in tests. It only supports single stylus pointer, and the pointerId
+// is totally ignored.
+internal class HandwritingTestStylusInjectScope(semanticsNode: SemanticsNode) :
+    TouchInjectionScope, Density by semanticsNode.layoutInfo.density {
+    private val root = semanticsNode.root as ViewRootForTest
+    private val downTime: Long = System.currentTimeMillis()
+
+    private var lastPosition: Offset = Offset.Unspecified
+    private var currentTime: Long = System.currentTimeMillis()
+    private val boundsInRoot = semanticsNode.boundsInRoot
+
+    override val visibleSize: IntSize =
+        IntSize(boundsInRoot.width.roundToInt(), boundsInRoot.height.roundToInt())
+
+    override val viewConfiguration: ViewConfiguration = semanticsNode.layoutInfo.viewConfiguration
+
+    private fun localToRoot(position: Offset): Offset {
+        return position + boundsInRoot.topLeft
+    }
+
+    override fun advanceEventTime(durationMillis: Long) {
+        require(durationMillis >= 0) {
+            "duration of a delay can only be positive, not $durationMillis"
+        }
+        currentTime += durationMillis
+    }
+
+    override fun currentPosition(pointerId: Int): Offset {
+        return lastPosition
+    }
+
+    override fun down(pointerId: Int, position: Offset) {
+        lastPosition = localToRoot(position)
+        sendTouchEvent(MotionEvent.ACTION_DOWN)
+    }
+
+    override fun updatePointerTo(pointerId: Int, position: Offset) {
+        lastPosition = localToRoot(position)
+    }
+
+    override fun move(delayMillis: Long) {
+        advanceEventTime(delayMillis)
+        sendTouchEvent(MotionEvent.ACTION_MOVE)
+    }
+
+    @ExperimentalTestApi
+    override fun moveWithHistoryMultiPointer(
+        relativeHistoricalTimes: List<Long>,
+        historicalCoordinates: List<List<Offset>>,
+        delayMillis: Long
+    ) {
+        // Not needed for this test because Android only supports one stylus pointer.
+    }
+
+    override fun up(pointerId: Int) {
+        sendTouchEvent(MotionEvent.ACTION_UP)
+    }
+
+    fun hoverEnter(position: Offset = lastPosition, delayMillis: Long = eventPeriodMillis) {
+        advanceEventTime(delayMillis)
+        lastPosition = localToRoot(position)
+        sendTouchEvent(MotionEvent.ACTION_HOVER_ENTER)
+    }
+
+    fun hoverMoveTo(position: Offset, delayMillis: Long = eventPeriodMillis) {
+        advanceEventTime(delayMillis)
+        lastPosition = localToRoot(position)
+        sendTouchEvent(MotionEvent.ACTION_HOVER_MOVE)
+    }
+
+    fun hoverExit(position: Offset = lastPosition, delayMillis: Long = eventPeriodMillis) {
+        advanceEventTime(delayMillis)
+        lastPosition = localToRoot(position)
+        sendTouchEvent(MotionEvent.ACTION_HOVER_EXIT)
+    }
+
+    override fun cancel(delayMillis: Long) {
+        sendTouchEvent(MotionEvent.ACTION_CANCEL)
+    }
+
+    private fun sendTouchEvent(action: Int) {
+        val motionEvent =
+            MotionEvent.obtain(
+                /* downTime = */ downTime,
+                /* eventTime = */ currentTime,
+                /* action = */ action,
+                /* pointerCount = */ 1,
+                /* pointerProperties = */ arrayOf(
+                    MotionEvent.PointerProperties().apply {
+                        id = 0
+                        toolType = MotionEvent.TOOL_TYPE_STYLUS
+                    }
+                ),
+                /* pointerCoords = */ arrayOf(
+                    MotionEvent.PointerCoords().apply {
+                        val startOffset = lastPosition
+
+                        // Allows for non-valid numbers/Offsets to be passed along to Compose to
+                        // test if it handles them properly (versus breaking here and we not knowing
+                        // if Compose properly handles these values).
+                        x =
+                            if (startOffset.isValid()) {
+                                startOffset.x
+                            } else {
+                                Float.NaN
+                            }
+
+                        y =
+                            if (startOffset.isValid()) {
+                                startOffset.y
+                            } else {
+                                Float.NaN
+                            }
+                    }
+                ),
+                /* metaState = */ 0,
+                /* buttonState = */ 0,
+                /* xPrecision = */ 1f,
+                /* yPrecision = */ 1f,
+                /* deviceId = */ 0,
+                /* edgeFlags = */ 0,
+                /* source = */ InputDeviceCompat.SOURCE_STYLUS,
+                /* flags = */ 0
+            )
+
+        InstrumentationRegistry.getInstrumentation().runOnMainSync {
+            root.view.dispatchTouchEvent(motionEvent)
+        }
+    }
+}
+
+internal fun SemanticsNodeInteraction.performStylusInput(
+    block: HandwritingTestStylusInjectScope.() -> Unit
+): SemanticsNodeInteraction {
+    @OptIn(ExperimentalTestApi::class) invokeGlobalAssertions()
+    tryPerformAccessibilityChecks()
+    val node = fetchSemanticsNode("Failed to inject stylus input.")
+    val stylusInjectionScope = HandwritingTestStylusInjectScope(node)
+    block.invoke(stylusInjectionScope)
+    return this
+}
diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/pointer/PointerIconTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/pointer/PointerIconTest.kt
index 94a9896..9352d33 100644
--- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/pointer/PointerIconTest.kt
+++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/pointer/PointerIconTest.kt
@@ -79,6 +79,12 @@
                 override fun setIcon(value: PointerIcon?) {
                     currentIcon = value ?: PointerIcon.Default
                 }
+
+                override fun getStylusHoverIcon(): PointerIcon? {
+                    return null
+                }
+
+                override fun setStylusHoverIcon(value: PointerIcon?) {}
             }
     }
 
diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/pointer/StylusHoverIconTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/pointer/StylusHoverIconTest.kt
new file mode 100644
index 0000000..398713c
--- /dev/null
+++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/pointer/StylusHoverIconTest.kt
@@ -0,0 +1,3705 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR 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.input.pointer
+
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.requiredSize
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.movableContentOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.platform.InspectableValue
+import androidx.compose.ui.platform.LocalPointerIconService
+import androidx.compose.ui.platform.isDebugInspectorInfoEnabled
+import androidx.compose.ui.platform.testTag
+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.Truth.assertThat
+import org.junit.Before
+import org.junit.Ignore
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@MediumTest
+@RunWith(AndroidJUnit4::class)
+class StylusHoverIconTest {
+    @get:Rule val rule = createComposeRule()
+    private val parentIconTag = "myParentIcon"
+    private val childIconTag = "myChildIcon"
+    private val grandchildIconTag = "myGrandchildIcon"
+    private val desiredParentIcon = PointerIcon.Crosshair // AndroidPointerIcon(type=1007)
+    private val desiredChildIcon = PointerIcon.Text // AndroidPointerIcon(type=1008)
+    private val desiredGrandchildIcon = PointerIcon.Hand // AndroidPointerIcon(type=1002)
+    private lateinit var iconService: PointerIconService
+
+    @Before
+    fun setup() {
+        iconService =
+            object : PointerIconService {
+                private var currentIcon: PointerIcon = PointerIcon.Default
+                private var currentStylusHoverIcon: PointerIcon? = null
+
+                override fun getIcon(): PointerIcon {
+                    return currentIcon
+                }
+
+                override fun setIcon(value: PointerIcon?) {
+                    currentIcon = value ?: PointerIcon.Default
+                }
+
+                override fun getStylusHoverIcon(): PointerIcon? {
+                    return currentStylusHoverIcon
+                }
+
+                override fun setStylusHoverIcon(value: PointerIcon?) {
+                    currentStylusHoverIcon = value
+                }
+            }
+    }
+
+    @Test
+    fun testInspectorValue() {
+        isDebugInspectorInfoEnabled = true
+        rule.setContent {
+            val modifier =
+                Modifier.stylusHoverIcon(PointerIcon.Hand, overrideDescendants = false)
+                    as InspectableValue
+            assertThat(modifier.nameFallback).isEqualTo("stylusHoverIcon")
+            assertThat(modifier.valueOverride).isNull()
+            assertThat(modifier.inspectableElements.map { it.name }.asIterable())
+                .containsExactly(
+                    "icon",
+                    "overrideDescendants",
+                    "touchBoundsExpansion",
+                )
+        }
+        isDebugInspectorInfoEnabled = false
+    }
+
+    /**
+     * Setup: The hierarchy for this test is setup as: Parent Box (custom icon =
+     * [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Child Box (custom icon =
+     * [PointerIcon.Text], overrideDescendants = FALSE)
+     *
+     * Expected Output: Because we don't move the cursor, the icon will be null. We also want to
+     * check that when using a .stylusHoverIcon modifier with a composable, composition only happens
+     * once (per composable).
+     */
+    @Test
+    fun parentChildFullOverlap_noOverrideDescendants_checkNumberOfCompositions() {
+
+        var numberOfCompositions = 0
+
+        rule.setContent {
+            CompositionLocalProvider(LocalPointerIconService provides iconService) {
+                Box(
+                    modifier =
+                        Modifier.requiredSize(200.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+                            .testTag(parentIconTag)
+                            .stylusHoverIcon(desiredParentIcon, overrideDescendants = false)
+                ) {
+                    numberOfCompositions++
+
+                    Box(
+                        Modifier.requiredSize(200.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+                            .testTag(childIconTag)
+                            .stylusHoverIcon(desiredChildIcon, overrideDescendants = false)
+                    ) {
+                        numberOfCompositions++
+                    }
+                }
+            }
+        }
+        // Verify initial state of pointer icon
+        rule.runOnIdle {
+            assertThat(iconService.getStylusHoverIcon()).isNull()
+            assertThat(numberOfCompositions).isEqualTo(2)
+        }
+    }
+
+    /**
+     * Setup: The hierarchy for this test is setup as: Parent Box (custom icon =
+     * [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Child Box (custom icon =
+     * [PointerIcon.Text], overrideDescendants = FALSE)
+     *
+     * Expected Output: Child Box’s [PointerIcon.Text] wins for the entire Box area because it’s
+     * lower in the hierarchy than Parent Box. If the Parent Box's overrideDescendants = false, the
+     * Child Box takes priority.
+     */
+    @Test
+    fun parentChildFullOverlap_noOverrideDescendants() {
+        rule.setContent {
+            CompositionLocalProvider(LocalPointerIconService provides iconService) {
+                Box(
+                    modifier =
+                        Modifier.requiredSize(200.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+                            .testTag(parentIconTag)
+                            .stylusHoverIcon(desiredParentIcon, overrideDescendants = false)
+                ) {
+                    Box(
+                        Modifier.requiredSize(200.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+                            .testTag(childIconTag)
+                            .stylusHoverIcon(desiredChildIcon, overrideDescendants = false)
+                    )
+                }
+            }
+        }
+        // Verify initial state of pointer icon
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+        // Verify Child Box's icon is the desired child icon
+        verifyIconOnHover(childIconTag, desiredChildIcon)
+        // Verify Parent Box is respecting Child Box's icon
+        verifyIconOnHover(parentIconTag, desiredChildIcon)
+    }
+
+    /**
+     * Setup: The hierarchy for this test is setup as: Parent Box (custom icon =
+     * [PointerIcon.Crosshair], overrideDescendants = TRUE) ⤷ Child Box (custom icon =
+     * [PointerIcon.Text], overrideDescendants = FALSE)
+     *
+     * Expected Output: Parent Box’s [PointerIcon.Crosshair] wins for the entire Box area because
+     * it’s higher in the hierarchy than Child Box. Also the Parent Box's overrideDescendants value
+     * is TRUE, so as the topmost parent in the hierarchy with overrideDescendants = true, all its
+     * children must respect it.
+     */
+    @Test
+    fun parentChildFullOverlap_parentOverridesDescendants() {
+        rule.setContent {
+            CompositionLocalProvider(LocalPointerIconService provides iconService) {
+                Box(
+                    modifier =
+                        Modifier.requiredSize(200.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+                            .testTag(parentIconTag)
+                            .stylusHoverIcon(desiredParentIcon, overrideDescendants = true)
+                ) {
+                    Box(
+                        Modifier.requiredSize(200.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+                            .testTag(childIconTag)
+                            .stylusHoverIcon(desiredChildIcon, overrideDescendants = false)
+                    )
+                }
+            }
+        }
+        // Verify initial state of pointer icon
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+        // Verify Parent Box's icon is the desired parent icon
+        verifyIconOnHover(parentIconTag, desiredParentIcon)
+        // Verify Child Box is respecting Parent Box's icon
+        verifyIconOnHover(childIconTag, desiredParentIcon)
+    }
+
+    /**
+     * Setup: The hierarchy for this test is setup as: Parent Box (custom icon =
+     * [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Child Box (custom icon =
+     * [PointerIcon.Text], overrideDescendants = TRUE)
+     *
+     * Expected Output: Child Box’s [PointerIcon.Text] wins for the entire Box area because its
+     * lower in priority than Parent Box. If the Parent Box's overrideDescendants = false, the Child
+     * Box takes priority.
+     */
+    @Test
+    fun parentChildFullOverlap_childOverridesDescendants() {
+        rule.setContent {
+            CompositionLocalProvider(LocalPointerIconService provides iconService) {
+                Box(
+                    modifier =
+                        Modifier.requiredSize(200.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+                            .testTag(parentIconTag)
+                            .stylusHoverIcon(desiredParentIcon, overrideDescendants = false)
+                ) {
+                    Box(
+                        Modifier.requiredSize(200.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+                            .testTag(childIconTag)
+                            .stylusHoverIcon(desiredChildIcon, overrideDescendants = true)
+                    )
+                }
+            }
+        }
+        // Verify initial state of pointer icon
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+        // Verify Child Box's icon is the desired child icon
+        verifyIconOnHover(childIconTag, desiredChildIcon)
+        // Verify Parent Box is respecting Child Box's icon
+        verifyIconOnHover(parentIconTag, desiredChildIcon)
+    }
+
+    /**
+     * Setup: The hierarchy for this test is setup as: Parent Box (custom icon =
+     * [PointerIcon.Crosshair], overrideDescendants = TRUE) ⤷ Child Box (custom icon =
+     * [PointerIcon.Text], overrideDescendants = TRUE)
+     *
+     * Expected Output: Parent Box’s [PointerIcon.Crosshair] wins for the entire Box area because
+     * its overrideDescendants = true. The Parent Box takes precedence because it is the topmost
+     * parent in the hierarchy with overrideDescendants = true.
+     */
+    @Test
+    fun parentChildFullOverlap_bothOverrideDescendants() {
+        rule.setContent {
+            CompositionLocalProvider(LocalPointerIconService provides iconService) {
+                Box(
+                    modifier =
+                        Modifier.requiredSize(200.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+                            .testTag(parentIconTag)
+                            .stylusHoverIcon(desiredParentIcon, overrideDescendants = true)
+                ) {
+                    Box(
+                        Modifier.requiredSize(200.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+                            .testTag(childIconTag)
+                            .stylusHoverIcon(desiredChildIcon, overrideDescendants = true)
+                    )
+                }
+            }
+        }
+        // Verify initial state of pointer icon
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+        // Verify Parent Box's icon is the desired parent icon
+        verifyIconOnHover(parentIconTag, desiredParentIcon)
+        // Verify Child Box is respecting Parent Box's icon
+        verifyIconOnHover(childIconTag, desiredParentIcon)
+    }
+
+    /**
+     * Setup: The hierarchy for this test is setup as: Parent Box (custom icon =
+     * [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Child Box (custom icon =
+     * [PointerIcon.Text], overrideDescendants = FALSE)
+     *
+     * Expected Output: Child Box’s [PointerIcon.Hand] wins for the entire Child Box surface area
+     * because there's no parent in its hierarchy that has overrideDescendants = true. Parent Box's
+     * [PointerIcon.Crosshair] wins for all remaining surface area of its Box that doesn't overlap
+     * with Child Box.
+     */
+    @Test
+    fun parentChildPartialOverlap_noOverrideDescendants() {
+        rule.setContent {
+            CompositionLocalProvider(LocalPointerIconService provides iconService) {
+                Box(
+                    modifier =
+                        Modifier.requiredSize(200.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+                            .testTag(parentIconTag)
+                            .stylusHoverIcon(desiredParentIcon, overrideDescendants = false)
+                ) {
+                    Box(
+                        Modifier.padding(20.dp)
+                            .requiredSize(100.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+                            .testTag(childIconTag)
+                            .stylusHoverIcon(desiredChildIcon, overrideDescendants = false)
+                    )
+                }
+            }
+        }
+        // Verify initial state of pointer icon
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+        // Verify Child Box's icon is the desired child icon
+        verifyIconOnHover(childIconTag, desiredChildIcon)
+        // Verify remaining Parent Box's area is the desired parent icon
+        verifyIconOnHover(parentIconTag, desiredParentIcon)
+    }
+
+    /**
+     * Setup: The hierarchy for this test is setup as: Parent Box (custom icon =
+     * [PointerIcon.Crosshair], overrideDescendants = TRUE) ⤷ Child Box (custom icon =
+     * [PointerIcon.Hand], overrideDescendants = FALSE)
+     *
+     * Expected Output: Parent Box’s [PointerIcon.Hand] wins for the entire Box area because its
+     * overrideDescendants = true, so every child underneath it in the hierarchy must respect its
+     * pointer icon since it's the topmost parent in the hierarchy with overrideDescendants = true.
+     */
+    @Test
+    fun parentChildPartialOverlap_parentOverridesDescendants() {
+        rule.setContent {
+            CompositionLocalProvider(LocalPointerIconService provides iconService) {
+                Box(
+                    modifier =
+                        Modifier.requiredSize(200.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+                            .testTag(parentIconTag)
+                            .stylusHoverIcon(desiredParentIcon, overrideDescendants = true)
+                ) {
+                    Box(
+                        Modifier.padding(20.dp)
+                            .requiredSize(100.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+                            .testTag(childIconTag)
+                            .stylusHoverIcon(desiredChildIcon, overrideDescendants = false)
+                    )
+                }
+            }
+        }
+        // Verify initial state of pointer icon
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+        // Verify Parent Box's icon is the desired parent icon
+        verifyIconOnHover(parentIconTag, desiredParentIcon)
+        // Verify Child Box is respecting Parent Box's icon
+        verifyIconOnHover(childIconTag, desiredParentIcon)
+    }
+
+    /**
+     * Setup: The hierarchy for this test is setup as: Parent Box (custom icon =
+     * [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Child Box (custom icon =
+     * [PointerIcon.Hand], overrideDescendants = TRUE)
+     *
+     * Expected Output: Child Box’s [PointerIcon.Hand] wins for the entire Child Box surface area
+     * because it’s lower in the hierarchy than Parent Box. If Parent Box's overrideDescendants =
+     * false, the Child Box takes priority.
+     */
+    @Test
+    fun parentChildPartialOverlap_childOverridesDescendants() {
+        rule.setContent {
+            CompositionLocalProvider(LocalPointerIconService provides iconService) {
+                Box(
+                    modifier =
+                        Modifier.requiredSize(200.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+                            .testTag(parentIconTag)
+                            .stylusHoverIcon(desiredParentIcon, overrideDescendants = false)
+                ) {
+                    Box(
+                        Modifier.padding(20.dp)
+                            .requiredSize(100.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+                            .testTag(childIconTag)
+                            .stylusHoverIcon(desiredChildIcon, overrideDescendants = true)
+                    )
+                }
+            }
+        }
+        // Verify initial state of pointer icon
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+        // Verify Child Box's icon is the desired child icon
+        verifyIconOnHover(childIconTag, desiredChildIcon)
+        // Verify remaining Parent Box's area is the desired parent icon
+        verifyIconOnHover(parentIconTag, desiredParentIcon)
+    }
+
+    /**
+     * Setup: The hierarchy for this test is setup as: Parent Box (custom icon =
+     * [PointerIcon.Crosshair], overrideDescendants = TRUE) ⤷ Child Box (custom icon =
+     * [PointerIcon.Hand], overrideDescendants = TRUE)
+     *
+     * Expected Output: Parent Box’s [PointerIcon.Crosshair] wins for the entire Box area because
+     * its overrideDescendants = true. If multiple locations in the hierarchy set
+     * overrideDescendants = true, the highest parent in the hierarchy takes precedence (in this
+     * example, it was Parent Box).
+     */
+    @Test
+    fun parentChildPartialOverlap_bothOverrideDescendants() {
+        rule.setContent {
+            CompositionLocalProvider(LocalPointerIconService provides iconService) {
+                Box(
+                    modifier =
+                        Modifier.requiredSize(200.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+                            .testTag(parentIconTag)
+                            .stylusHoverIcon(desiredParentIcon, overrideDescendants = true)
+                ) {
+                    Box(
+                        Modifier.padding(20.dp)
+                            .requiredSize(100.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+                            .testTag(childIconTag)
+                            .stylusHoverIcon(desiredChildIcon, overrideDescendants = true)
+                    )
+                }
+            }
+        }
+        // Verify initial state of pointer icon
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+        // Verify Parent Box's icon is the desired parent icon
+        verifyIconOnHover(parentIconTag, desiredParentIcon)
+        // Verify Child Box is respecting Parent Box's icon
+        verifyIconOnHover(childIconTag, desiredParentIcon)
+    }
+
+    /**
+     * Setup: The hierarchy for the initial setup of this test is: Parent Box (no custom icon) ⤷
+     * Child Box (custom icon = [PointerIcon.Text], overrideDescendants = FALSE)
+     *
+     * After hovering over the center of the screen, the hierarchy under the cursor updates to:
+     * Parent Box (custom icon = [PointerIcon.Crosshair], overrideDescendants = TRUE) ⤷ Child Box
+     * (custom icon = [PointerIcon.Text], overrideDescendants = FALSE)
+     *
+     * Expected Output: Initially, the Child Box's [PointerIcon.Text] should win for its entire
+     * surface area because it has no competition in the hierarchy for any other custom icons. After
+     * the Parent Box dynamically has the stylusHoverIcon Modifier added to it, the Parent Box's
+     * [PointerIcon.Crosshair] should win for the entire surface area of the Parent Box and Child
+     * Box because the Parent Box has overrideDescendants = true.
+     *
+     * Parent Box (output icon = [PointerIcon.Crosshair]) ⤷ Child Box (output icon =
+     * [PointerIcon.Crosshair])
+     */
+    @Test
+    fun parentChildPartialOverlap_parentModifierDynamicallyAdded() {
+        val isVisible = mutableStateOf(false)
+        rule.setContent {
+            CompositionLocalProvider(LocalPointerIconService provides iconService) {
+                Box(
+                    modifier =
+                        Modifier.requiredSize(200.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+                            .testTag(parentIconTag)
+                            .then(
+                                if (isVisible.value)
+                                    Modifier.stylusHoverIcon(
+                                        desiredParentIcon,
+                                        overrideDescendants = true
+                                    )
+                                else Modifier
+                            )
+                ) {
+                    Box(
+                        Modifier.padding(20.dp)
+                            .requiredSize(150.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+                            .testTag(childIconTag)
+                            .stylusHoverIcon(desiredChildIcon, overrideDescendants = false)
+                    )
+                }
+            }
+        }
+        // Verify initial state of pointer icon
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+        // Verify Child Box is the desired child icon
+        verifyIconOnHover(childIconTag, desiredChildIcon)
+        // Verify Parent Box's icon is the desired default icon
+        verifyIconOnHover(parentIconTag, null)
+        // Dynamically add the stylusHoverIcon Modifier to the Parent Box
+        rule.runOnIdle { isVisible.value = true }
+        // Verify Parent Box's icon is the desired parent icon
+        verifyIconOnHover(parentIconTag, desiredParentIcon)
+        // Verify Child Box is respecting Parent Box's icon
+        verifyIconOnHover(childIconTag, desiredParentIcon)
+    }
+
+    /**
+     * Setup: The hierarchy for the initial setup of this test is: Parent Box (no custom icon) ⤷
+     * Child Box (custom icon = [PointerIcon.Text], overrideDescendants = FALSE)
+     *
+     * After hovering over the center of the screen, the hierarchy under the cursor updates to:
+     * Parent Box (custom icon = [PointerIcon.Crosshair], overrideDescendants = TRUE) ⤷ Child Box
+     * (custom icon = [PointerIcon.Text], overrideDescendants = FALSE)
+     *
+     * Expected Output: Initially, the Child Box's [PointerIcon.Text] should win for its entire
+     * surface area because it has no competition in the hierarchy for any other custom icons. After
+     * the Parent Box dynamically has the stylusHoverIcon Modifier added to it, the Parent Box's
+     * [PointerIcon.Crosshair] should win for the entire surface area of the Parent Box and Child
+     * Box because the Parent Box has overrideDescendants = true.
+     *
+     * Parent Box (output icon = [PointerIcon.Crosshair]) ⤷ Child Box (output icon =
+     * [PointerIcon.Crosshair])
+     */
+    @Ignore("b/299482894 - not yet implemented")
+    @Test
+    fun parentChildPartialOverlap_parentModifierDynamicallyAddedWithMoveEvents() {
+        val isVisible = mutableStateOf(false)
+        rule.setContent {
+            CompositionLocalProvider(LocalPointerIconService provides iconService) {
+                Box(
+                    modifier =
+                        Modifier.requiredSize(200.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+                            .testTag(parentIconTag)
+                            .then(
+                                if (isVisible.value)
+                                    Modifier.stylusHoverIcon(
+                                        desiredParentIcon,
+                                        overrideDescendants = true
+                                    )
+                                else Modifier
+                            )
+                ) {
+                    Box(
+                        Modifier.padding(20.dp)
+                            .requiredSize(150.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+                            .testTag(childIconTag)
+                            .stylusHoverIcon(desiredChildIcon, overrideDescendants = false)
+                    )
+                }
+            }
+        }
+        // Verify initial state of pointer icon
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+        // Hover over Child Box and verify it has the desired child icon
+        rule.onNodeWithTag(childIconTag).performStylusInput { hoverEnter(bottomRight) }
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) }
+        // Move to Parent Box and verify its icon is the desired default icon
+        rule.onNodeWithTag(parentIconTag).performStylusInput { hoverMoveTo(bottomRight) }
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+        // Move back to the Child Box
+        rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(center) }
+        // Dynamically add the stylusHoverIcon Modifier to the Parent Box
+        rule.runOnIdle { isVisible.value = true }
+        // Verify the Child Box has updated to respect the desired parent icon
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) }
+        // Move within the Child Box and verify it is still respecting the desired parent icon
+        rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(bottomRight) }
+        // Move to the Parent Box and verify it also has the desired parent icon
+        rule.onNodeWithTag(parentIconTag).performStylusInput { hoverMoveTo(bottomRight) }
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) }
+        // Exit hovering over Parent Box
+        rule.onNodeWithTag(parentIconTag).performStylusInput { hoverExit() }
+    }
+
+    /**
+     * Setup: The hierarchy for the initial setup of this test is: Parent Box (custom icon =
+     * [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Child Box (custom icon =
+     * [PointerIcon.Text], overrideDescendants = FALSE)
+     *
+     * After hovering over the center of the screen, the hierarchy under the cursor updates to:
+     * Parent Box (custom icon = [PointerIcon.Crosshair], overrideDescendants = TRUE) ⤷ Child Box
+     * (custom icon = [PointerIcon.Text], overrideDescendants = FALSE)
+     *
+     * After several assertions, it reverts back to false in the parent: Parent Box (custom icon =
+     * [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Child Box (custom icon =
+     * [PointerIcon.Text], overrideDescendants = FALSE)
+     *
+     * Expected Output: Initially, the Child Box's [PointerIcon.Text] should win for its entire
+     * surface area because the parent does not override descendants. After the Parent Box
+     * dynamically changes overrideDescendants to true, the Parent Box's [PointerIcon.Crosshair]
+     * should win for the entire surface area of the Parent Box and Child Box because the Parent Box
+     * has overrideDescendants = true.
+     *
+     * It should then revert back to Child Box's [PointerIcon.Text] after the Parent Box's
+     * overrideDescendants is set back to false.
+     */
+    @Test
+    fun parentChildPartialOverlap_parentModifierDynamicallyChangedToOverrideWithMoveEvents() {
+        var parentOverrideDescendants by mutableStateOf(false)
+        rule.setContent {
+            CompositionLocalProvider(LocalPointerIconService provides iconService) {
+                Box(
+                    modifier =
+                        Modifier.requiredSize(200.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+                            .testTag(parentIconTag)
+                            .then(
+                                Modifier.stylusHoverIcon(
+                                    desiredParentIcon,
+                                    overrideDescendants = parentOverrideDescendants
+                                )
+                            )
+                ) {
+                    Box(
+                        Modifier.padding(20.dp)
+                            .requiredSize(150.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+                            .testTag(childIconTag)
+                            .stylusHoverIcon(desiredChildIcon, overrideDescendants = false)
+                    )
+                }
+            }
+        }
+        // Verify initial state of pointer icon
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+        // Hover over Child Box and verify it has the desired child icon
+        rule.onNodeWithTag(childIconTag).performStylusInput { hoverEnter(bottomRight) }
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) }
+        // Move to Parent Box and verify its icon is the desired parent icon
+        rule.onNodeWithTag(parentIconTag).performStylusInput { hoverMoveTo(bottomRight) }
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) }
+        // Move back to the Child Box
+        rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(center) }
+
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) }
+
+        // Dynamically change the stylusHoverIcon Modifier to the Parent Box to
+        // override descendants.
+        rule.runOnIdle { parentOverrideDescendants = true }
+
+        // Verify the Child Box has updated to respect the desired parent icon
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) }
+
+        // Move within the Child Box and verify it is still respecting the desired parent icon
+        rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(bottomRight) }
+
+        // Verify the Child Box has updated to respect the desired parent icon
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) }
+
+        // Move to the Parent Box and verify it also has the desired parent icon
+        rule.onNodeWithTag(parentIconTag).performStylusInput { hoverMoveTo(bottomRight) }
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) }
+
+        // Move within the Child Box and verify it is still respecting the desired parent icon
+        rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(bottomRight) }
+
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) }
+
+        // Dynamically change the stylusHoverIcon Modifier to the Parent Box to NOT
+        // override descendants.
+        rule.runOnIdle { parentOverrideDescendants = false }
+
+        // Verify it's changed to child icon
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) }
+
+        // Move to Parent Box and verify its icon is the desired parent icon
+        rule.onNodeWithTag(parentIconTag).performStylusInput { hoverMoveTo(bottomRight) }
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) }
+        // Move back to the Child Box
+        rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(center) }
+
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) }
+
+        // Exit hovering over Parent Box
+        rule.onNodeWithTag(parentIconTag).performStylusInput { hoverExit() }
+    }
+
+    /**
+     * Setup: The hierarchy for the initial setup of this test is: Parent Box (custom icon =
+     * [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Child Box (custom icon =
+     * [PointerIcon.Text], overrideDescendants = FALSE)
+     *
+     * After hovering over the center of the screen, the hierarchy under the cursor updates to:
+     * Parent Box (custom icon = [PointerIcon.Crosshair], overrideDescendants = TRUE) ⤷ Child Box
+     * (custom icon = [PointerIcon.Text], overrideDescendants = FALSE)
+     *
+     * Expected Output: Initially, Child Box’s [PointerIcon.Hand] wins for the entire Child Box
+     * surface area because there's no parent in its hierarchy that has overrideDescendants = true.
+     * Additionally, Parent Box's [PointerIcon.Crosshair] would initially win for all remaining
+     * surface area of its Box that doesn't overlap with Child Box. Once Parent Box's
+     * overrideDescendants parameter is dynamically updated to true, the Parent Box's icon should
+     * win for its entire surface area, including within Child Box.
+     */
+    @Test
+    fun parentChildPartialOverlap_parentOverrideDescendantsDynamicallyUpdated() {
+        val parentOverrideState = mutableStateOf(false)
+        rule.setContent {
+            CompositionLocalProvider(LocalPointerIconService provides iconService) {
+                Box(
+                    modifier =
+                        Modifier.requiredSize(200.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+                            .testTag(parentIconTag)
+                            .stylusHoverIcon(
+                                desiredParentIcon,
+                                overrideDescendants = parentOverrideState.value
+                            )
+                ) {
+                    Box(
+                        Modifier.padding(20.dp)
+                            .requiredSize(150.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+                            .testTag(childIconTag)
+                            .stylusHoverIcon(desiredChildIcon, overrideDescendants = false)
+                    )
+                }
+            }
+        }
+        // Verify initial state of pointer icon
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+        // Verify Child Box's icon is the desired child icon
+        verifyIconOnHover(childIconTag, desiredChildIcon)
+        // Verify remaining Parent Box's area is the desired parent icon
+        verifyIconOnHover(parentIconTag, desiredParentIcon)
+        rule.runOnIdle { parentOverrideState.value = true }
+        // Verify Child Box's icon is the desired parent icon
+        verifyIconOnHover(childIconTag, desiredParentIcon)
+        // Verify Parent Box also has the desired parent icon
+        verifyIconOnHover(parentIconTag, desiredParentIcon)
+    }
+
+    /**
+     * Setup: The hierarchy for the initial setup of this test is: Parent Box (custom icon =
+     * [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Child Box (custom icon =
+     * [PointerIcon.Text], overrideDescendants = FALSE)
+     *
+     * After hovering over various parts of the screen and verify the results, we update the
+     * parent's overrideDescendants to true: Parent Box (custom icon = [PointerIcon.Crosshair],
+     * overrideDescendants = TRUE) ⤷ Child Box (custom icon = [PointerIcon.Text],
+     * overrideDescendants = FALSE)
+     *
+     * After several assertions, it reverts back to false in the parent: Parent Box (custom icon =
+     * [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Child Box (custom icon =
+     * [PointerIcon.Text], overrideDescendants = FALSE)
+     *
+     * Expected Output: Initially, the Child Box's [PointerIcon.Text] should win for its entire
+     * surface area because the parent does not override descendants. After the Parent Box
+     * dynamically changes overrideDescendants to true, the Parent Box's [PointerIcon.Crosshair]
+     * should win for the child's surface area within the Parent Box BUT NOT the portion of the
+     * Child Box that is outside the Parent Box.
+     *
+     * It should then revert back to Child Box's [PointerIcon.Text] (in all scenarios) after the
+     * Parent Box's overrideDescendants is set back to false.
+     */
+    @Test
+    fun parentChildPartialOverlapAndExtendsBeyondParent_dynamicOverrideDescendants() {
+        var parentOverrideDescendants by mutableStateOf(false)
+        rule.setContent {
+            CompositionLocalProvider(LocalPointerIconService provides iconService) {
+                Box(
+                    modifier =
+                        Modifier.requiredSize(300.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Green)))
+                ) {
+                    // This child extends beyond the borders of the parent (enabling this test)
+                    Box(
+                        modifier =
+                            Modifier.size(150.dp)
+                                .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+                                .testTag(parentIconTag)
+                                .then(
+                                    Modifier.stylusHoverIcon(
+                                        desiredParentIcon,
+                                        overrideDescendants = parentOverrideDescendants
+                                    )
+                                )
+                    ) {
+                        Box(
+                            Modifier.padding(20.dp)
+                                .offset(100.dp)
+                                .width(300.dp)
+                                .height(100.dp)
+                                .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+                                .testTag(childIconTag)
+                                .stylusHoverIcon(desiredChildIcon, overrideDescendants = false)
+                        )
+                    }
+                }
+            }
+        }
+        // Verify initial state of pointer icon
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+        // Hover over Child Box and verify it has the desired child icon (outside parent)
+        rule.onNodeWithTag(childIconTag).performStylusInput { hoverEnter(bottomRight) }
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) }
+
+        // Hover over Child Box and verify it has the desired child icon (inside parent)
+        rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(bottomLeft) }
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) }
+
+        // Move to Parent Box and verify its icon is the desired parent icon
+        rule.onNodeWithTag(parentIconTag).performStylusInput { hoverMoveTo(bottomRight) }
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) }
+        // Move back to the Child Box (portion inside parent)
+        rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(bottomLeft) }
+
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) }
+
+        // Dynamically change the stylusHoverIcon Modifier of the Parent Box to
+        // override descendants.
+        rule.runOnIdle { parentOverrideDescendants = true }
+
+        // Verify the Child Box has updated to respect the desired parent icon
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) }
+
+        // Hover over Child Box and verify it has the desired child icon (outside parent)
+        rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(bottomRight) }
+
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) }
+
+        // Move to the Parent Box and verify it also has the desired parent icon
+        rule.onNodeWithTag(parentIconTag).performStylusInput { hoverMoveTo(bottomRight) }
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) }
+
+        // Move within the Child Box (portion inside parent) and verify it is still
+        // respecting the desired parent icon
+        rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(bottomLeft) }
+
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) }
+
+        // Dynamically change the stylusHoverIcon Modifier of the Parent Box to NOT
+        // override descendants.
+        rule.runOnIdle { parentOverrideDescendants = false }
+
+        // Verify it's changed to child icon
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) }
+
+        // Move to Parent Box and verify its icon is the desired parent icon
+        rule.onNodeWithTag(parentIconTag).performStylusInput { hoverMoveTo(bottomRight) }
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) }
+        // Move back to the Child Box
+        rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(bottomLeft) }
+
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) }
+
+        // Exit hovering over Parent Box
+        rule.onNodeWithTag(parentIconTag).performStylusInput { hoverExit() }
+    }
+
+    /**
+     * Setup: The hierarchy for this test is setup as: Parent Box (no custom icon set) ⤷ ChildA Box
+     * (custom icon = [PointerIcon.Text], overrideDescendants = FALSE) ⤷ ChildB Box (custom icon =
+     * [PointerIcon.Hand], overrideDescendants = FALSE)
+     *
+     * Expected Output: ChildA Box’s [PointerIcon.Text] wins for the entire surface area of ChildA's
+     * Box. ChildB Box's [PointerIcon.Hand] wins for the entire surface area of ChildB's Box. No
+     * icon wins for the remainder of the surface area of Parent Box that's not covered by ChildA
+     * Box or ChildB Box. In this example, there's no competition for pointer icons because the
+     * parent has no icon set and neither ChildA or ChildB Boxes overlap.
+     *
+     * Parent Box (output icon = null) ⤷ Child Box (output icon = [PointerIcon.Text]) ⤷ Child Box
+     * (output icon = [PointerIcon.Hand])
+     */
+    @Test
+    fun NonOverlappingSiblings_noOverrideDescendants() {
+        rule.setContent {
+            CompositionLocalProvider(LocalPointerIconService provides iconService) {
+                Box(
+                    modifier =
+                        Modifier.requiredSize(200.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+                            .testTag(parentIconTag)
+                ) {
+                    Column {
+                        Box(
+                            Modifier.padding(20.dp)
+                                .requiredSize(50.dp)
+                                .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+                                .testTag(childIconTag)
+                                .stylusHoverIcon(desiredChildIcon, overrideDescendants = false)
+                        )
+                        // Referencing grandchild tag/icon for ChildB in this test
+                        Box(
+                            Modifier.padding(40.dp)
+                                .requiredSize(50.dp)
+                                .border(BorderStroke(2.dp, SolidColor(Color.Blue)))
+                                .testTag(grandchildIconTag)
+                                .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = false)
+                        )
+                    }
+                }
+            }
+        }
+        // Verify initial state of pointer icon
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+        // Verify ChildA Box's icon is the desired ChildA icon
+        verifyIconOnHover(childIconTag, desiredChildIcon)
+        // Verify ChildB Box's icon is the desired ChildB icon
+        verifyIconOnHover(grandchildIconTag, desiredGrandchildIcon)
+        // Verify remaining Parent Box's icon is the default icon
+        verifyIconOnHover(parentIconTag, null)
+    }
+
+    /**
+     * Setup: The hierarchy for this test is setup as: Parent Box (no custom icon set) ⤷ ChildA Box
+     * (custom icon = [PointerIcon.Text], overrideDescendants = TRUE) ⤷ ChildB Box (custom icon =
+     * [PointerIcon.Hand], overrideDescendants = FALSE)
+     *
+     * Expected Output: ChildA Box’s [PointerIcon.Text] wins for the entire surface area of ChildA's
+     * Box. ChildB Box's [PointerIcon.Hand] wins for the entire surface area of ChildB's Box. No
+     * icon wins for the remainder of the surface area of Parent Box that's not covered by ChildA
+     * Box or ChildB Box. In this example, it doesn't matter whether ChildA Box's
+     * overrideDescendants = true or false because there's no competition for pointer icons in this
+     * example.
+     *
+     * Parent Box (output icon = null) ⤷ Child Box (output icon = [PointerIcon.Text]) ⤷ Child Box
+     * (output icon = [PointerIcon.Hand])
+     */
+    @Test
+    fun NonOverlappingSiblings_firstChildOverridesDescendants() {
+        rule.setContent {
+            CompositionLocalProvider(LocalPointerIconService provides iconService) {
+                Box(
+                    modifier =
+                        Modifier.requiredSize(200.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+                            .testTag(parentIconTag)
+                ) {
+                    Column {
+                        Box(
+                            Modifier.padding(20.dp)
+                                .requiredSize(50.dp)
+                                .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+                                .testTag(childIconTag)
+                                .stylusHoverIcon(desiredChildIcon, overrideDescendants = true)
+                        )
+                        // Referencing grandchild tag/icon for ChildB in this test
+                        Box(
+                            Modifier.padding(40.dp)
+                                .requiredSize(50.dp)
+                                .border(BorderStroke(2.dp, SolidColor(Color.Blue)))
+                                .testTag(grandchildIconTag)
+                                .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = false)
+                        )
+                    }
+                }
+            }
+        }
+        // Verify initial state of pointer icon
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+        // Verify ChildA Box's icon is the desired ChildA icon
+        verifyIconOnHover(childIconTag, desiredChildIcon)
+        // Verify ChildB Box's icon is the desired ChildB icon
+        verifyIconOnHover(grandchildIconTag, desiredGrandchildIcon)
+        // Verify remaining Parent Box's icon is the default icon
+        verifyIconOnHover(parentIconTag, null)
+    }
+
+    /**
+     * Setup: The hierarchy for this test is setup as: Parent Box (no custom icon set) ⤷ ChildA Box
+     * (custom icon = [PointerIcon.Text], overrideDescendants = FALSE) ⤷ ChildB Box (custom icon =
+     * [PointerIcon.Hand], overrideDescendants = TRUE)
+     *
+     * Expected Output: ChildA Box’s [PointerIcon.Text] wins for the entire surface area of ChildA's
+     * Box. ChildB Box's [PointerIcon.Hand] wins for the entire surface area of ChildB's Box. No
+     * icon wins for the remainder of the surface area of Parent Box that's not covered by ChildA
+     * Box or ChildB Box. In this example, it doesn't matter whether ChildB Box's
+     * overrideDescendants = true or false because there's no competition for pointer icons in this
+     * example.
+     *
+     * Parent Box (output icon = null) ⤷ Child Box (output icon = [PointerIcon.Text]) ⤷ Child Box
+     * (output icon = [PointerIcon.Hand])
+     */
+    @Test
+    fun NonOverlappingSiblings_secondChildOverridesDescendants() {
+        rule.setContent {
+            CompositionLocalProvider(LocalPointerIconService provides iconService) {
+                Box(
+                    modifier =
+                        Modifier.requiredSize(200.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+                            .testTag(parentIconTag)
+                ) {
+                    Column {
+                        Box(
+                            Modifier.padding(20.dp)
+                                .requiredSize(50.dp)
+                                .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+                                .testTag(childIconTag)
+                                .stylusHoverIcon(desiredChildIcon, overrideDescendants = false)
+                        )
+                        // Referencing grandchild tag/icon for ChildB in this test
+                        Box(
+                            Modifier.padding(40.dp)
+                                .requiredSize(50.dp)
+                                .border(BorderStroke(2.dp, SolidColor(Color.Blue)))
+                                .testTag(grandchildIconTag)
+                                .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = true)
+                        )
+                    }
+                }
+            }
+        }
+        // Verify initial state of pointer icon
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+        // Verify ChildA Box's icon is the desired ChildA icon
+        verifyIconOnHover(childIconTag, desiredChildIcon)
+        // Verify ChildB Box's icon is the desired ChildB icon
+        verifyIconOnHover(grandchildIconTag, desiredGrandchildIcon)
+        // Verify remaining Parent Box's icon is the default icon
+        verifyIconOnHover(parentIconTag, null)
+    }
+
+    /**
+     * Setup: The hierarchy for this test is setup as: Parent Box (no custom icon set) ⤷ ChildA Box
+     * (custom icon = [PointerIcon.Text], overrideDescendants = TRUE) ⤷ ChildB Box (custom icon =
+     * [PointerIcon.Hand], overrideDescendants = TRUE)
+     *
+     * Expected Output: ChildA Box’s [PointerIcon.Text] wins for the entire surface area of ChildA's
+     * Box. ChildB Box's [PointerIcon.Hand] wins for the entire surface area of ChildB's Box. No
+     * icon wins for the remainder of the surface area of Parent Box that's not covered by ChildA
+     * Box or ChildB Box. In this example, it doesn't matter whether ChildA Box and ChildB Box's
+     * overrideDescendants = true or false because there's no competition for pointer icons in this
+     * example.
+     *
+     * Parent Box (output icon = null) ⤷ ChildA Box (output icon = [PointerIcon.Text]) ⤷ ChildB Box
+     * (output icon = [PointerIcon.Hand])
+     */
+    @Test
+    fun NonOverlappingSiblings_bothOverrideDescendants() {
+        rule.setContent {
+            CompositionLocalProvider(LocalPointerIconService provides iconService) {
+                Box(
+                    modifier =
+                        Modifier.requiredSize(200.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+                            .testTag(parentIconTag)
+                ) {
+                    Column {
+                        Box(
+                            Modifier.padding(20.dp)
+                                .requiredSize(50.dp)
+                                .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+                                .testTag(childIconTag)
+                                .stylusHoverIcon(desiredChildIcon, overrideDescendants = true)
+                        )
+                        // Referencing grandchild tag/icon for ChildB in this test
+                        Box(
+                            Modifier.padding(40.dp)
+                                .requiredSize(50.dp)
+                                .border(BorderStroke(2.dp, SolidColor(Color.Blue)))
+                                .testTag(grandchildIconTag)
+                                .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = true)
+                        )
+                    }
+                }
+            }
+        }
+        // Verify initial state of pointer icon
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+        // Verify ChildA Box's icon is the desired ChildA icon
+        verifyIconOnHover(childIconTag, desiredChildIcon)
+        // Verify ChildB Box's icon is the desired ChildB icon
+        verifyIconOnHover(grandchildIconTag, desiredGrandchildIcon)
+        // Verify remaining Parent Box's icon is the default icon
+        verifyIconOnHover(parentIconTag, null)
+    }
+
+    /**
+     * Setup: The hierarchy for this test is setup as: Parent Box (no custom icon set) ⤷ ChildA Box
+     * (custom icon = [PointerIcon.Text], overrideDescendants = FALSE) ⤷ ChildB Box (custom icon =
+     * [PointerIcon.Hand], overrideDescendants = FALSE) where ChildB Box's surface area overlaps
+     * with its sibling, ChildA, within the Parent Box
+     *
+     * Expected Output: ChildB Box's [PointerIcon.Hand] wins for the entire surface area of ChildB's
+     * Box. ChildA Box's [PointerIcon.Text] wins for the remaining surface area of ChildA Box not
+     * covered by ChildB Box. No icon wins for the remainder of the surface area of Parent Box
+     * that's not covered by ChildA Box or ChildB Box.
+     *
+     * Parent Box (output icon = null) ⤷ ChildA Box (output icon = [PointerIcon.Text]) ⤷ ChildB Box
+     * (output icon = [PointerIcon.Hand])
+     */
+    @Test
+    fun OverlappingSiblings_noOverrideDescendants() {
+        rule.setContent {
+            CompositionLocalProvider(LocalPointerIconService provides iconService) {
+                Box(
+                    modifier =
+                        Modifier.requiredSize(200.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+                            .testTag(parentIconTag)
+                ) {
+                    Box(
+                        Modifier.padding(20.dp)
+                            .requiredSize(120.dp, 60.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+                            .testTag(childIconTag)
+                            .stylusHoverIcon(desiredChildIcon, overrideDescendants = false)
+                    )
+                    // Referencing grandchild tag/icon for ChildB in this test
+                    Box(
+                        Modifier.padding(horizontal = 100.dp, vertical = 40.dp)
+                            .requiredSize(120.dp, 20.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Blue)))
+                            .testTag(grandchildIconTag)
+                            .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = false)
+                    )
+                }
+            }
+        }
+
+        verifyOverlappingSiblings()
+    }
+
+    /**
+     * Setup: The hierarchy for this test is setup as: Parent Box (no custom icon set) ⤷ ChildA Box
+     * (custom icon = [PointerIcon.Text], overrideDescendants = TRUE) ⤷ ChildB Box (custom icon =
+     * [PointerIcon.Hand], overrideDescendants = FALSE) where ChildB Box's surface area overlaps
+     * with its sibling, ChildA, within the Parent Box
+     *
+     * Expected Output: ChildB Box's [PointerIcon.Hand] wins for the entire surface area of ChildB's
+     * Box. ChildA Box's [PointerIcon.Text] wins for the remaining surface area of ChildA Box not
+     * covered by ChildB Box. No icon wins for the remainder of the surface area of Parent Box
+     * that's not covered by ChildA Box or ChildB Box. The overrideDescendants param only affects
+     * that element's children. So in this example, it doesn't matter whether ChildA Box's
+     * overrideDescendants = true because ChildB is its sibling and is therefore unaffected by this
+     * param.
+     *
+     * Parent Box (output icon = null) ⤷ ChildA Box (output icon = [PointerIcon.Text]) ⤷ ChildB Box
+     * (output icon = [PointerIcon.Hand])
+     */
+    @Test
+    fun OverlappingSiblings_childAOverridesDescendants() {
+        rule.setContent {
+            CompositionLocalProvider(LocalPointerIconService provides iconService) {
+                Box(
+                    modifier =
+                        Modifier.requiredSize(200.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+                            .testTag(parentIconTag)
+                ) {
+                    Box(
+                        Modifier.padding(20.dp)
+                            .requiredSize(120.dp, 60.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+                            .testTag(childIconTag)
+                            .stylusHoverIcon(desiredChildIcon, overrideDescendants = true)
+                    )
+                    // Referencing grandchild tag/icon for ChildB in this test
+                    Box(
+                        Modifier.padding(horizontal = 100.dp, vertical = 40.dp)
+                            .requiredSize(120.dp, 20.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Blue)))
+                            .testTag(grandchildIconTag)
+                            .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = false)
+                    )
+                }
+            }
+        }
+
+        verifyOverlappingSiblings()
+    }
+
+    /**
+     * Setup: The hierarchy for this test is setup as: Parent Box (no custom icon set) ⤷ ChildA Box
+     * (custom icon = [PointerIcon.Text], overrideDescendants = FALSE) ⤷ ChildB Box (custom icon =
+     * [PointerIcon.Hand], overrideDescendants = TRUE) where ChildB Box's surface area overlaps with
+     * its sibling, ChildA, within the Parent Box
+     *
+     * Expected Output: ChildB Box's [PointerIcon.Hand] wins for the entire surface area of ChildB's
+     * Box. ChildA Box's [PointerIcon.Text] wins for the remaining surface area of ChildA Box not
+     * covered by ChildB Box. No icon wins for the remainder of the surface area of Parent Box
+     * that's not covered by ChildA Box or ChildB Box. The overrideDescendants param only affects
+     * that element's children. So in this example, it doesn't matter whether ChildB Box's
+     * overrideDescendants = true because ChildA is its sibling and is therefore unaffected by this
+     * param.
+     *
+     * Parent Box (output icon = null) ⤷ ChildA Box (output icon = [PointerIcon.Text]) ⤷ ChildB Box
+     * (output icon = [PointerIcon.Hand])
+     */
+    @Test
+    fun OverlappingSiblings_childBOverridesDescendants() {
+        rule.setContent {
+            CompositionLocalProvider(LocalPointerIconService provides iconService) {
+                Box(
+                    modifier =
+                        Modifier.requiredSize(200.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+                            .testTag(parentIconTag)
+                ) {
+                    Box(
+                        Modifier.padding(20.dp)
+                            .requiredSize(120.dp, 60.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+                            .testTag(childIconTag)
+                            .stylusHoverIcon(desiredChildIcon, overrideDescendants = false)
+                    )
+                    // Referencing grandchild tag/icon for ChildB in this test
+                    Box(
+                        Modifier.padding(horizontal = 100.dp, vertical = 40.dp)
+                            .requiredSize(120.dp, 20.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Blue)))
+                            .testTag(grandchildIconTag)
+                            .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = true)
+                    )
+                }
+            }
+        }
+
+        verifyOverlappingSiblings()
+    }
+
+    /**
+     * Setup: The hierarchy for this test is setup as: Parent Box (no custom icon set) ⤷ ChildA Box
+     * (custom icon = [PointerIcon.Text], overrideDescendants = TRUE) ⤷ ChildB Box (custom icon =
+     * [PointerIcon.Hand], overrideDescendants = TRUE) where ChildB Box's surface area overlaps with
+     * its sibling, ChildA, within the Parent Box
+     *
+     * Expected Output: ChildB Box's [PointerIcon.Hand] wins for the entire surface area of ChildB's
+     * Box. ChildA Box's [PointerIcon.Text] wins for the remaining surface area of ChildA Box not
+     * covered by ChildB Box. No icon wins for the remainder of the surface area of Parent Box
+     * that's not covered by ChildA Box or ChildB Box. The overrideDescendants param only affects
+     * that element's children. So in this example, it doesn't matter whether ChildA Box or ChildB
+     * Box's overrideDescendants = true because ChildA and ChildB Boxes are siblings and are
+     * unaffected by each other's overrideDescendants param.
+     *
+     * Parent Box (output icon = null) ⤷ ChildA Box (output icon = [PointerIcon.Text]) ⤷ ChildB Box
+     * (output icon = [PointerIcon.Hand])
+     */
+    @Test
+    fun OverlappingSiblings_bothOverrideDescendants() {
+        rule.setContent {
+            CompositionLocalProvider(LocalPointerIconService provides iconService) {
+                Box(
+                    modifier =
+                        Modifier.requiredSize(200.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+                            .testTag(parentIconTag)
+                ) {
+                    Box(
+                        Modifier.padding(20.dp)
+                            .requiredSize(120.dp, 60.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+                            .testTag(childIconTag)
+                            .stylusHoverIcon(desiredChildIcon, overrideDescendants = true)
+                    )
+                    // Referencing grandchild tag/icon for ChildB in this test
+                    Box(
+                        Modifier.padding(horizontal = 100.dp, vertical = 40.dp)
+                            .requiredSize(120.dp, 20.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Blue)))
+                            .testTag(grandchildIconTag)
+                            .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = true)
+                    )
+                }
+            }
+        }
+
+        verifyOverlappingSiblings()
+    }
+
+    /**
+     * Setup: The hierarchy for this test is setup as: Parent Box (custom icon =
+     * [PointerIcon.Crosshair], overrideDescendants = TRUE) ⤷ ChildA Box (custom icon =
+     * [PointerIcon.Text], overrideDescendants = TRUE) ⤷ ChildB Box (custom icon =
+     * [PointerIcon.Hand], overrideDescendants = TRUE) where ChildB Box's surface area overlaps with
+     * its sibling, ChildA, within the Parent Box
+     *
+     * Expected Output: Parent Box's [PointerIcon.Crosshair] wins for the entire surface area of its
+     * box, including the surface area within ChildA Box and ChildB Box. Parent Box has
+     * overrideDescendants = true, which takes priority over any custom icon set by its children.
+     *
+     * Parent Box (output icon = [PointerIcon.Crosshair]) ⤷ ChildA Box (output icon =
+     * [PointerIcon.Crosshair]) ⤷ ChildB Box (output icon = [PointerIcon.Crosshair])
+     */
+    @Test
+    fun OverlappingSiblings_parentOverridesDescendants() {
+        rule.setContent {
+            CompositionLocalProvider(LocalPointerIconService provides iconService) {
+                Box(
+                    modifier =
+                        Modifier.requiredSize(200.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+                            .testTag(parentIconTag)
+                            .stylusHoverIcon(desiredParentIcon, overrideDescendants = true)
+                ) {
+                    Box(
+                        Modifier.padding(20.dp)
+                            .requiredSize(120.dp, 60.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+                            .testTag(childIconTag)
+                            .stylusHoverIcon(desiredChildIcon, overrideDescendants = true)
+                    )
+                    // Referencing grandchild tag/icon for ChildB in this test
+                    Box(
+                        Modifier.padding(horizontal = 100.dp, vertical = 40.dp)
+                            .requiredSize(120.dp, 20.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Blue)))
+                            .testTag(grandchildIconTag)
+                            .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = true)
+                    )
+                }
+            }
+        }
+
+        // Verify initial state of pointer icon
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+        // Hover over ChildB (bottom right corner) and verify desired Parent icon
+        rule.onNodeWithTag(grandchildIconTag).performStylusInput { hoverEnter(bottomRight) }
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) }
+        // Then hover to parent (bottom right corner) and icon hasn't changed
+        rule.onNodeWithTag(parentIconTag).performStylusInput { hoverMoveTo(bottomRight) }
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) }
+        // Then hover to ChildA (bottom left corner) and verify icon hasn't changed
+        rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(bottomLeft) }
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) }
+        // Exit hovering
+        rule.onNodeWithTag(parentIconTag).performStylusInput { hoverExit() }
+    }
+
+    /**
+     * Setup: The hierarchy for this test is setup as: Parent Box (no custom icon set) ⤷ Child Box
+     * (no custom icon set) ⤷ Grandchild Box (custom icon = [PointerIcon.Hand], overrideDescendants
+     * = FALSE)
+     *
+     * Expected Output: Grandchild Box’s [PointerIcon.Hand] wins for the entire surface area of
+     * Grandchild's Box. No icon wins for the remainder of the surface area of Parent Box that isn't
+     * covered by Grandchild Box.
+     *
+     * Parent Box (output icon = null) ⤷ Child Box (output icon = null) ⤷ Grandchild Box (output
+     * icon = [PointerIcon.Hand])
+     */
+    @Test
+    fun multiLayeredNesting_grandchildCustomIconNoOverride() {
+        rule.setContent {
+            CompositionLocalProvider(LocalPointerIconService provides iconService) {
+                Box(
+                    modifier =
+                        Modifier.requiredSize(200.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+                            .testTag(parentIconTag)
+                ) {
+                    Box(
+                        Modifier.padding(20.dp)
+                            .requiredSize(150.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+                            .testTag(childIconTag)
+                    ) {
+                        Box(
+                            Modifier.padding(40.dp)
+                                .requiredSize(100.dp)
+                                .border(BorderStroke(2.dp, SolidColor(Color.Blue)))
+                                .testTag(grandchildIconTag)
+                                .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = false)
+                        )
+                    }
+                }
+            }
+        }
+        // Verify initial state of pointer icon
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+        // Verify Grandchild Box's icon is the desired grandchild icon
+        verifyIconOnHover(grandchildIconTag, desiredGrandchildIcon)
+        // Verify remaining Child Box's area is the default arrow icon
+        verifyIconOnHover(childIconTag, null)
+        // Verify remaining Parent Box's area is the default arrow icon
+        verifyIconOnHover(parentIconTag, null)
+    }
+
+    /**
+     * Setup: The hierarchy for this test is setup as: Parent Box (no custom icon set) ⤷ Child Box
+     * (no custom icon set) ⤷ Grandchild Box (custom icon = [PointerIcon.Hand], overrideDescendants
+     * = TRUE)
+     *
+     * Expected Output: Grandchild Box’s [PointerIcon.Hand] wins for the entire surface area of
+     * Grandchild's Box. No icon wins for the remainder of the surface area of Parent Box that isn't
+     * covered by Grandchild Box.
+     *
+     * Parent Box (output icon = null) ⤷ Child Box (output icon = null) ⤷ Grandchild Box (output
+     * icon = [PointerIcon.Hand])
+     */
+    @Test
+    fun multiLayeredNesting_grandchildCustomIconHasOverride() {
+        rule.setContent {
+            CompositionLocalProvider(LocalPointerIconService provides iconService) {
+                Box(
+                    modifier =
+                        Modifier.requiredSize(200.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+                            .testTag(parentIconTag)
+                ) {
+                    Box(
+                        Modifier.padding(20.dp)
+                            .requiredSize(150.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+                            .testTag(childIconTag)
+                    ) {
+                        Box(
+                            Modifier.padding(40.dp)
+                                .requiredSize(100.dp)
+                                .border(BorderStroke(2.dp, SolidColor(Color.Blue)))
+                                .testTag(grandchildIconTag)
+                                .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = true)
+                        )
+                    }
+                }
+            }
+        }
+        // Verify initial state of pointer icon
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+        // Verify Grandchild Box's icon is the desired grandchild icon
+        verifyIconOnHover(grandchildIconTag, desiredGrandchildIcon)
+        // Verify remaining Child Box's area is the default arrow icon
+        verifyIconOnHover(childIconTag, null)
+        // Verify remaining Parent Box's area is the default arrow icon
+        verifyIconOnHover(parentIconTag, null)
+    }
+
+    /**
+     * Setup: The hierarchy for this test is setup as: Parent Box (no custom icon set) ⤷ Child Box
+     * (custom icon = [PointerIcon.Text], overrideDescendants = FALSE) ⤷ Grandchild Box (custom icon
+     * = [PointerIcon.Hand], overrideDescendants = FALSE)
+     *
+     * Expected Output: Grandchild Box's [PointerIcon.Hand] wins for the entire surface area of the
+     * Grandchild Box. Child Box’s [PointerIcon.Text] wins for remaining surface area of its Box not
+     * covered by the Grandchild Box. No icon wins for the remainder of the surface area of Parent
+     * Box that isn't covered by Child Box.
+     *
+     * Parent Box (output icon = null) ⤷ Child Box (output icon = [PointerIcon.Text]) ⤷ Grandchild
+     * Box (output icon = [PointerIcon.Hand])
+     */
+    @Test
+    fun multiLayeredNesting_childAndGrandchildCustomIconsNoOverrides() {
+        rule.setContent {
+            CompositionLocalProvider(LocalPointerIconService provides iconService) {
+                Box(
+                    modifier =
+                        Modifier.requiredSize(200.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+                            .testTag(parentIconTag)
+                ) {
+                    Box(
+                        Modifier.padding(20.dp)
+                            .requiredSize(150.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+                            .testTag(childIconTag)
+                            .stylusHoverIcon(desiredChildIcon, overrideDescendants = false)
+                    ) {
+                        Box(
+                            Modifier.padding(40.dp)
+                                .requiredSize(100.dp)
+                                .border(BorderStroke(2.dp, SolidColor(Color.Blue)))
+                                .testTag(grandchildIconTag)
+                                .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = false)
+                        )
+                    }
+                }
+            }
+        }
+        // Verify initial state of pointer icon
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+        // Verify Grandchild Box's icon is the desired grandchild icon
+        verifyIconOnHover(grandchildIconTag, desiredGrandchildIcon)
+        // Verify remaining Child Box's icon is the desired child icon
+        verifyIconOnHover(childIconTag, desiredChildIcon)
+        // Verify remaining Parent Box's area is the default arrow icon
+        verifyIconOnHover(parentIconTag, null)
+    }
+
+    /**
+     * Setup: The hierarchy for this test is setup as: Parent Box (no custom icon set) ⤷ Child Box
+     * (custom icon = [PointerIcon.Text], overrideDescendants = FALSE) ⤷ Grandchild Box (custom icon
+     * = [PointerIcon.Hand], overrideDescendants = TRUE)
+     *
+     * Expected Output: Grandchild Box's [PointerIcon.Hand] wins for the entire surface area of the
+     * Grandchild Box. Child Box’s [PointerIcon.Text] wins for the remainder of the Child Box's
+     * surface area that's not covered by the Grandchild box. No icon wins for the remainder of the
+     * surface area of Parent Box that isn't covered by Child Box.
+     *
+     * Parent Box (output icon = null) ⤷ Child Box (output icon = [PointerIcon.Text]) ⤷ Grandchild
+     * Box (output icon = [PointerIcon.Hand])
+     */
+    @Test
+    fun multiLayeredNesting_childCustomIconGrandchildHasOverride() {
+        rule.setContent {
+            CompositionLocalProvider(LocalPointerIconService provides iconService) {
+                Box(
+                    modifier =
+                        Modifier.requiredSize(200.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+                            .testTag(parentIconTag)
+                ) {
+                    Box(
+                        Modifier.padding(20.dp)
+                            .requiredSize(150.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+                            .testTag(childIconTag)
+                            .stylusHoverIcon(desiredChildIcon, overrideDescendants = false)
+                    ) {
+                        Box(
+                            Modifier.padding(40.dp)
+                                .requiredSize(100.dp)
+                                .border(BorderStroke(2.dp, SolidColor(Color.Blue)))
+                                .testTag(grandchildIconTag)
+                                .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = true)
+                        )
+                    }
+                }
+            }
+        }
+        // Verify initial state of pointer icon
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+        // Verify Grandchild Box's icon is the desired grandchild icon
+        verifyIconOnHover(grandchildIconTag, desiredGrandchildIcon)
+        // Verify remaining Child Box's icon is the desired child icon
+        verifyIconOnHover(childIconTag, desiredChildIcon)
+        // Verify remaining Parent Box's area is the default arrow icon
+        verifyIconOnHover(parentIconTag, null)
+    }
+
+    /**
+     * Setup: The hierarchy for this test is setup as: Parent Box (no custom icon set) ⤷ Child Box
+     * (custom icon = [PointerIcon.Text], overrideDescendants = TRUE) ⤷ Grandchild Box (custom icon
+     * = [PointerIcon.Hand], overrideDescendants = FALSE)
+     *
+     * Expected Output: Child Box’s [PointerIcon.Text] wins for the entire surface area of its Box
+     * (including all of the Grandchild Box since it is contained within Child Box's surface area).
+     * No icon wins for the remainder of the surface area of Parent Box that isn't covered by Child
+     * Box.
+     *
+     * Parent Box (output icon = null) ⤷ Child Box (output icon = [PointerIcon.Text]) ⤷ Grandchild
+     * Box (output icon = [PointerIcon.Text])
+     */
+    @Test
+    fun multiLayeredNesting_grandchildCustomIconChildHasOverride() {
+        rule.setContent {
+            CompositionLocalProvider(LocalPointerIconService provides iconService) {
+                Box(
+                    modifier =
+                        Modifier.requiredSize(200.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+                            .testTag(parentIconTag)
+                ) {
+                    Box(
+                        Modifier.padding(20.dp)
+                            .requiredSize(150.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+                            .testTag(childIconTag)
+                            .stylusHoverIcon(desiredChildIcon, overrideDescendants = true)
+                    ) {
+                        Box(
+                            Modifier.padding(40.dp)
+                                .requiredSize(100.dp)
+                                .border(BorderStroke(2.dp, SolidColor(Color.Blue)))
+                                .testTag(grandchildIconTag)
+                                .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = false)
+                        )
+                    }
+                }
+            }
+        }
+        // Verify initial state of pointer icon
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+        // Verify Child Box's icon is the desired child icon
+        verifyIconOnHover(childIconTag, desiredChildIcon)
+        // Verify Grandchild Box is respecting Child Box's icon
+        verifyIconOnHover(grandchildIconTag, desiredChildIcon)
+        // Verify remaining Parent Box's area is the default arrow icon
+        verifyIconOnHover(parentIconTag, null)
+    }
+
+    /**
+     * Setup: The hierarchy for this test is setup as: Parent Box (no custom icon set) ⤷ Child Box
+     * (custom icon = [PointerIcon.Text], overrideDescendants = TRUE) ⤷ Grandchild Box (custom icon
+     * = [PointerIcon.Hand], overrideDescendants = TRUE)
+     *
+     * Expected Output: Child Box’s [PointerIcon.Text] wins for the entire surface area of its Box
+     * (including all of the Grandchild Box since it is contained within Child Box's surface area).
+     * No icon wins for the remainder of the surface area of Parent Box that isn't covered by Child
+     * Box.
+     *
+     * Parent Box (output icon = null) ⤷ Child Box (output icon = [PointerIcon.Text]) ⤷ Grandchild
+     * Box (output icon = [PointerIcon.Text])
+     */
+    @Test
+    fun multiLayeredNesting_childAndGrandchildOverrideDescendants() {
+        rule.setContent {
+            CompositionLocalProvider(LocalPointerIconService provides iconService) {
+                Box(
+                    modifier =
+                        Modifier.requiredSize(200.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+                            .testTag(parentIconTag)
+                ) {
+                    Box(
+                        Modifier.padding(20.dp)
+                            .requiredSize(150.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+                            .testTag(childIconTag)
+                            .stylusHoverIcon(desiredChildIcon, overrideDescendants = true)
+                    ) {
+                        Box(
+                            Modifier.padding(40.dp)
+                                .requiredSize(100.dp)
+                                .border(BorderStroke(2.dp, SolidColor(Color.Blue)))
+                                .testTag(grandchildIconTag)
+                                .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = true)
+                        )
+                    }
+                }
+            }
+        }
+        // Verify initial state of pointer icon
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+        // Verify Child Box's icon is the desired child icon
+        verifyIconOnHover(childIconTag, desiredChildIcon)
+        // Verify Grandchild Box is respecting Child Box's icon
+        verifyIconOnHover(grandchildIconTag, desiredChildIcon)
+        // Verify remaining Parent Box's area is the default arrow icon
+        verifyIconOnHover(parentIconTag, null)
+    }
+
+    /**
+     * Setup: The hierarchy for this test is setup as: Parent Box (custom icon =
+     * [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Child Box (no icon set) ⤷ Grandchild
+     * Box (custom icon = [PointerIcon.Hand], overrideDescendants = FALSE)
+     *
+     * Expected Output: Grandchild Box's [PointerIcon.Hand] wins for the entire surface area of the
+     * Grandchild Box. Parent Box’s [PointerIcon.Crosshair] wins for the remaining surface area of
+     * the Pare Box that's not covered by the Grandchild Box.
+     *
+     * Parent Box (output icon = [PointerIcon.Crosshair]) ⤷ Child Box (output icon =
+     * [PointerIcon.Crosshair]) ⤷ Grandchild Box (output icon = [PointerIcon.Hand])
+     */
+    @Test
+    fun multiLayeredNesting_parentAndGrandchildCustomIconNoOverrides() {
+        rule.setContent {
+            CompositionLocalProvider(LocalPointerIconService provides iconService) {
+                Box(
+                    modifier =
+                        Modifier.requiredSize(200.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+                            .testTag(parentIconTag)
+                            .stylusHoverIcon(desiredParentIcon, overrideDescendants = false)
+                ) {
+                    Box(
+                        Modifier.padding(20.dp)
+                            .requiredSize(150.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+                            .testTag(childIconTag)
+                    ) {
+                        Box(
+                            Modifier.padding(40.dp)
+                                .requiredSize(100.dp)
+                                .border(BorderStroke(2.dp, SolidColor(Color.Blue)))
+                                .testTag(grandchildIconTag)
+                                .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = false)
+                        )
+                    }
+                }
+            }
+        }
+        // Verify initial state of pointer icon
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+        // Verify Grandchild Box's icon is the desired grandchild icon
+        verifyIconOnHover(grandchildIconTag, desiredGrandchildIcon)
+        // Verify remaining Child Box is respecting Parent Box's icon
+        verifyIconOnHover(childIconTag, desiredParentIcon)
+        // Verify remaining Parent Box's icon is the desired parent icon
+        verifyIconOnHover(parentIconTag, desiredParentIcon)
+    }
+
+    /**
+     * Setup: The hierarchy for this test is setup as: Parent Box (custom icon =
+     * [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Child Box (no icon set) ⤷ Grandchild
+     * Box (custom icon = [PointerIcon.Hand], overrideDescendants = TRUE)
+     *
+     * Expected Output: Grandchild Box's [PointerIcon.Hand] wins for the entire surface area of its
+     * Box. Parent Box’s [PointerIcon.Crosshair] wins for the remaining surface area of the Pare Box
+     * that's not covered by the Grandchild Box.
+     *
+     * Parent Box (output icon = [PointerIcon.Crosshair]) ⤷ Child Box (output icon =
+     * [PointerIcon.Crosshair]) ⤷ Grandchild Box (output icon = [PointerIcon.Hand])
+     */
+    @Test
+    fun multiLayeredNesting_parentCustomIconGrandchildOverrides() {
+        rule.setContent {
+            CompositionLocalProvider(LocalPointerIconService provides iconService) {
+                Box(
+                    modifier =
+                        Modifier.requiredSize(200.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+                            .testTag(parentIconTag)
+                            .stylusHoverIcon(desiredParentIcon, overrideDescendants = false)
+                ) {
+                    Box(
+                        Modifier.padding(20.dp)
+                            .requiredSize(150.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+                            .testTag(childIconTag)
+                    ) {
+                        Box(
+                            Modifier.padding(40.dp)
+                                .requiredSize(100.dp)
+                                .border(BorderStroke(2.dp, SolidColor(Color.Blue)))
+                                .testTag(grandchildIconTag)
+                                .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = true)
+                        )
+                    }
+                }
+            }
+        }
+        // Verify initial state of pointer icon
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+        // Verify Grandchild Box's icon is the desired grandchild icon
+        verifyIconOnHover(grandchildIconTag, desiredGrandchildIcon)
+        // Verify remaining Child Box is respecting Parent Box's icon
+        verifyIconOnHover(childIconTag, desiredParentIcon)
+        // Verify remaining Parent Box's icon is the desired parent icon
+        verifyIconOnHover(parentIconTag, desiredParentIcon)
+    }
+
+    /**
+     * Setup: The hierarchy for this test is setup as: Parent Box (custom icon =
+     * [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Child Box (custom icon =
+     * [PointerIcon.Text], overrideDescendants = FALSE) ⤷ Grandchild Box (custom icon =
+     * [PointerIcon.Hand], overrideDescendants = FALSE)
+     *
+     * Expected Output: Grandchild Box's [PointerIcon.Hand] wins for the entire surface area of the
+     * Grandchild Box. Child Box's [PointerIcon.Text] wins for the remaining surface area of the
+     * Child Box not covered by the Grandchild Box. Parent Box’s [PointerIcon.Crosshair] wins for
+     * the remaining surface area not covered by the Child Box.
+     *
+     * Parent Box (output icon = [PointerIcon.Crosshair]) ⤷ Child Box (output icon =
+     * [PointerIcon.Text]) ⤷ Grandchild Box (output icon = [PointerIcon.Hand])
+     */
+    @Test
+    fun multiLayeredNesting_allCustomIconsNoOverrides() {
+        rule.setContent {
+            CompositionLocalProvider(LocalPointerIconService provides iconService) {
+                Box(
+                    modifier =
+                        Modifier.requiredSize(200.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+                            .testTag(parentIconTag)
+                            .stylusHoverIcon(desiredParentIcon, overrideDescendants = false)
+                ) {
+                    Box(
+                        Modifier.padding(20.dp)
+                            .requiredSize(150.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+                            .testTag(childIconTag)
+                            .stylusHoverIcon(desiredChildIcon, overrideDescendants = false)
+                    ) {
+                        Box(
+                            Modifier.padding(40.dp)
+                                .requiredSize(100.dp)
+                                .border(BorderStroke(2.dp, SolidColor(Color.Blue)))
+                                .testTag(grandchildIconTag)
+                                .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = false)
+                        )
+                    }
+                }
+            }
+        }
+        // Verify initial state of pointer icon
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+        // Verify Grandchild Box's icon is the desired grandchild icon
+        verifyIconOnHover(grandchildIconTag, desiredGrandchildIcon)
+        // Verify remaining Child Box's icon is the desired child icon
+        verifyIconOnHover(childIconTag, desiredChildIcon)
+        // Verify remaining Parent Box's icon is the desired parent icon
+        verifyIconOnHover(parentIconTag, desiredParentIcon)
+    }
+
+    /**
+     * Setup: The hierarchy for this test is setup as: Parent Box (custom icon =
+     * [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Child Box (custom icon =
+     * [PointerIcon.Text], overrideDescendants = FALSE) ⤷ Grandchild Box (custom icon =
+     * [PointerIcon.Hand], overrideDescendants = TRUE)
+     *
+     * Expected Output: Grandchild Box's [PointerIcon.Hand] wins for the entire surface area of the
+     * Grandchild Box. Child Box's [PointerIcon.Text] wins for the remaining surface area of the
+     * Child Box not covered by the Grandchild Box. Parent Box’s [PointerIcon.Crosshair] wins for
+     * the remaining surface area not covered by the Child Box.
+     *
+     * Parent Box (output icon = [PointerIcon.Crosshair]) ⤷ Child Box (output icon =
+     * [PointerIcon.Text]) ⤷ Grandchild Box (output icon = [PointerIcon.Hand])
+     */
+    @Test
+    fun multiLayeredNesting_allCustomIconsGrandchildOverrides() {
+        rule.setContent {
+            CompositionLocalProvider(LocalPointerIconService provides iconService) {
+                Box(
+                    modifier =
+                        Modifier.requiredSize(200.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+                            .testTag(parentIconTag)
+                            .stylusHoverIcon(desiredParentIcon, overrideDescendants = false)
+                ) {
+                    Box(
+                        Modifier.padding(20.dp)
+                            .requiredSize(150.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+                            .testTag(childIconTag)
+                            .stylusHoverIcon(desiredChildIcon, overrideDescendants = false)
+                    ) {
+                        Box(
+                            Modifier.padding(40.dp)
+                                .requiredSize(100.dp)
+                                .border(BorderStroke(2.dp, SolidColor(Color.Blue)))
+                                .testTag(grandchildIconTag)
+                                .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = true)
+                        )
+                    }
+                }
+            }
+        }
+        // Verify initial state of pointer icon
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+        // Verify Grandchild Box's icon is the desired grandchild icon
+        verifyIconOnHover(grandchildIconTag, desiredGrandchildIcon)
+        // Verify remaining Child Box's icon is the desired child icon
+        verifyIconOnHover(childIconTag, desiredChildIcon)
+        // Verify remaining Parent Box's icon is the desired parent icon
+        verifyIconOnHover(parentIconTag, desiredParentIcon)
+    }
+
+    /**
+     * Setup: The hierarchy for this test is setup as: Parent Box (custom icon =
+     * [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Child Box (custom icon =
+     * [PointerIcon.Text], overrideDescendants = TRUE) ⤷ Grandchild Box (custom icon =
+     * [PointerIcon.Hand], overrideDescendants = FALSE)
+     *
+     * Expected Output: Child Box's [PointerIcon.Hand] wins for the entire surface area of its Box.
+     * Parent Box’s [PointerIcon.Crosshair] wins for the remaining surface area of its Box.
+     *
+     * Parent Box (output icon = [PointerIcon.Crosshair]) ⤷ Child Box (output icon =
+     * [PointerIcon.Text]) ⤷ Grandchild Box (output icon = [PointerIcon.Text])
+     */
+    @Test
+    fun multiLayeredNesting_allCustomIconsChildOverrides() {
+        rule.setContent {
+            CompositionLocalProvider(LocalPointerIconService provides iconService) {
+                Box(
+                    modifier =
+                        Modifier.requiredSize(200.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+                            .testTag(parentIconTag)
+                            .stylusHoverIcon(desiredParentIcon, overrideDescendants = false)
+                ) {
+                    Box(
+                        Modifier.padding(20.dp)
+                            .requiredSize(150.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+                            .testTag(childIconTag)
+                            .stylusHoverIcon(desiredChildIcon, overrideDescendants = true)
+                    ) {
+                        Box(
+                            Modifier.padding(40.dp)
+                                .requiredSize(100.dp)
+                                .border(BorderStroke(2.dp, SolidColor(Color.Blue)))
+                                .testTag(grandchildIconTag)
+                                .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = false)
+                        )
+                    }
+                }
+            }
+        }
+        // Verify initial state of pointer icon
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+        // Verify Child Box's icon is the desired child icon
+        verifyIconOnHover(childIconTag, desiredChildIcon)
+        // Verify Grandchild Box is respecting Child Box's icon
+        verifyIconOnHover(grandchildIconTag, desiredChildIcon)
+        // Verify remaining Parent Box's icon is the desired parent icon
+        verifyIconOnHover(parentIconTag, desiredParentIcon)
+    }
+
+    /**
+     * Setup: The hierarchy for this test is setup as: Parent Box (custom icon =
+     * [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Child Box (custom icon =
+     * [PointerIcon.Text], overrideDescendants = TRUE) ⤷ Grandchild Box (custom icon =
+     * [PointerIcon.Hand], overrideDescendants = TRUE)
+     *
+     * Expected Output: Child Box's [PointerIcon.Hand] wins for the entire surface area of its Box.
+     * Parent Box’s [PointerIcon.Crosshair] wins for the remaining surface area of its Box. The
+     * addition of Grandchild Box’s overrideDescendants = true in this test doesn’t impact the
+     * outcome; this is because Child Box is Grandchild Box's parent in the hierarchy and it already
+     * has overrideDescendants = true, which takes priority over anything Grandchild Box sets.
+     *
+     * Parent Box (output icon = [PointerIcon.Crosshair]) ⤷ Child Box (output icon =
+     * [PointerIcon.Text]) ⤷ Grandchild Box (output icon = [PointerIcon.Text])
+     */
+    @Test
+    fun multiLayeredNesting_allCustomIconsChildAndGrandchildOverrides() {
+        rule.setContent {
+            CompositionLocalProvider(LocalPointerIconService provides iconService) {
+                Box(
+                    modifier =
+                        Modifier.requiredSize(200.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+                            .testTag(parentIconTag)
+                            .stylusHoverIcon(desiredParentIcon, overrideDescendants = false)
+                ) {
+                    Box(
+                        Modifier.padding(20.dp)
+                            .requiredSize(150.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+                            .testTag(childIconTag)
+                            .stylusHoverIcon(desiredChildIcon, overrideDescendants = true)
+                    ) {
+                        Box(
+                            Modifier.padding(40.dp)
+                                .requiredSize(100.dp)
+                                .border(BorderStroke(2.dp, SolidColor(Color.Blue)))
+                                .testTag(grandchildIconTag)
+                                .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = true)
+                        )
+                    }
+                }
+            }
+        }
+        // Verify initial state of pointer icon
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+        // Verify Child Box's icon is the desired child icon
+        verifyIconOnHover(childIconTag, desiredChildIcon)
+        // Verify Grandchild Box is respecting Child Box's icon
+        verifyIconOnHover(grandchildIconTag, desiredChildIcon)
+        // Verify remaining Parent Box's icon is the desired parent icon
+        verifyIconOnHover(parentIconTag, desiredParentIcon)
+    }
+
+    /**
+     * Setup: The hierarchy for this test is setup as: Parent Box (custom icon =
+     * [PointerIcon.Crosshair], overrideDescendants = TRUE) ⤷ Child Box (no icon set) ⤷ Grandchild
+     * Box (custom icon = [PointerIcon.Hand], overrideDescendants = FALSE)
+     *
+     * Expected Output: Parent Box’s [PointerIcon.Crosshair] wins for the entire surface area of its
+     * Box. Even though the Grandchild Box’s icon was set, the Parent Box will always take priority
+     * because it's the highestmost level in the hierarchy where overrideDescendants = true.
+     *
+     * Parent Box (output icon = [PointerIcon.Crosshair]) ⤷ Child Box (output icon =
+     * [PointerIcon.Crosshair]) ⤷ Grandchild Box (output icon = [PointerIcon.Crosshair])
+     */
+    @Test
+    fun multiLayeredNesting_parentGrandChildCustomIconsParentOverrides() {
+        rule.setContent {
+            CompositionLocalProvider(LocalPointerIconService provides iconService) {
+                Box(
+                    modifier =
+                        Modifier.requiredSize(200.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+                            .testTag(parentIconTag)
+                            .stylusHoverIcon(desiredParentIcon, overrideDescendants = true)
+                ) {
+                    Box(
+                        Modifier.padding(20.dp)
+                            .requiredSize(150.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+                            .testTag(childIconTag)
+                    ) {
+                        Box(
+                            Modifier.padding(40.dp)
+                                .requiredSize(100.dp)
+                                .border(BorderStroke(2.dp, SolidColor(Color.Blue)))
+                                .testTag(grandchildIconTag)
+                                .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = false)
+                        )
+                    }
+                }
+            }
+        }
+        // Verify initial state of pointer icon
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+        // Verify Parent Box's icon is the desired parent icon
+        verifyIconOnHover(parentIconTag, desiredParentIcon)
+        // Verify Child Box is respecting Parent Box's icon
+        verifyIconOnHover(childIconTag, desiredParentIcon)
+        // Verify Grandchild Box is respecting Parent Box's icon
+        verifyIconOnHover(grandchildIconTag, desiredParentIcon)
+    }
+
+    /**
+     * Setup: The hierarchy for this test is setup as: Parent Box (custom icon =
+     * [PointerIcon.Crosshair], overrideDescendants = TRUE) ⤷ Child Box (no icon set) ⤷ Grandchild
+     * Box (custom icon = [PointerIcon.Hand], overrideDescendants = TRUE)
+     *
+     * Expected Output: Parent Box’s [PointerIcon.Crosshair] wins for the entire surface area of its
+     * Box. Even though the Grandchild Box’s icon was set, the Parent Box will always take priority
+     * because it's the highestmost level in the hierarchy where overrideDescendants = true.
+     *
+     * Parent Box (output icon = [PointerIcon.Crosshair]) ⤷ Child Box (output icon =
+     * [PointerIcon.Crosshair]) ⤷ Grandchild Box (output icon = [PointerIcon.Crosshair])
+     */
+    @Test
+    fun multiLayeredNesting_parentGrandChildCustomIconsBothOverride() {
+        rule.setContent {
+            CompositionLocalProvider(LocalPointerIconService provides iconService) {
+                Box(
+                    modifier =
+                        Modifier.requiredSize(200.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+                            .testTag(parentIconTag)
+                            .stylusHoverIcon(desiredParentIcon, overrideDescendants = true)
+                ) {
+                    Box(
+                        Modifier.padding(20.dp)
+                            .requiredSize(150.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+                            .testTag(childIconTag)
+                            .stylusHoverIcon(desiredChildIcon, overrideDescendants = false)
+                    ) {
+                        Box(
+                            Modifier.padding(40.dp)
+                                .requiredSize(100.dp)
+                                .border(BorderStroke(2.dp, SolidColor(Color.Blue)))
+                                .testTag(grandchildIconTag)
+                                .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = true)
+                        )
+                    }
+                }
+            }
+        }
+        // Verify initial state of pointer icon
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+        // Verify Parent Box's icon is the desired parent icon
+        verifyIconOnHover(parentIconTag, desiredParentIcon)
+        // Verify Child Box is respecting Parent Box's icon
+        verifyIconOnHover(childIconTag, desiredParentIcon)
+        // Verify Grandchild Box is respecting Parent Box's icon
+        verifyIconOnHover(grandchildIconTag, desiredParentIcon)
+    }
+
+    /**
+     * Setup: The hierarchy for this test is setup as: Parent Box (custom icon =
+     * [PointerIcon.Crosshair], overrideDescendants = TRUE) ⤷ Child Box (custom icon =
+     * [PointerIcon.Text], overrideDescendants = FALSE) ⤷ Grandchild Box (custom icon =
+     * [PointerIcon.Hand], overrideDescendants = FALSE)
+     *
+     * Expected Output: Parent Box’s [PointerIcon.Crosshair] wins for the entire surface area of its
+     * Box. Even though the Child and Grandchild Box’s icons were set, the Parent Box will always
+     * take priority because it's the highestmost level in the hierarchy where overrideDescendants =
+     * true.
+     *
+     * Parent Box (output icon = [PointerIcon.Crosshair]) ⤷ Child Box (output icon =
+     * [PointerIcon.Crosshair]) ⤷ Grandchild Box (output icon = [PointerIcon.Crosshair])
+     */
+    @Test
+    fun multiLayeredNesting_allCustomIconsParentOverrides() {
+        rule.setContent {
+            CompositionLocalProvider(LocalPointerIconService provides iconService) {
+                Box(
+                    modifier =
+                        Modifier.requiredSize(200.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+                            .testTag(parentIconTag)
+                            .stylusHoverIcon(desiredParentIcon, overrideDescendants = true)
+                ) {
+                    Box(
+                        Modifier.padding(20.dp)
+                            .requiredSize(150.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+                            .testTag(childIconTag)
+                            .stylusHoverIcon(desiredChildIcon, overrideDescendants = false)
+                    ) {
+                        Box(
+                            Modifier.padding(40.dp)
+                                .requiredSize(100.dp)
+                                .border(BorderStroke(2.dp, SolidColor(Color.Blue)))
+                                .testTag(grandchildIconTag)
+                                .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = false)
+                        )
+                    }
+                }
+            }
+        }
+        // Verify initial state of pointer icon
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+        // Verify Parent Box's icon is the desired parent icon
+        verifyIconOnHover(parentIconTag, desiredParentIcon)
+        // Verify Child Box is respecting Parent Box's icon
+        verifyIconOnHover(childIconTag, desiredParentIcon)
+        // Verify Grandchild Box is respecting Parent Box's icon
+        verifyIconOnHover(grandchildIconTag, desiredParentIcon)
+    }
+
+    /**
+     * Setup: The hierarchy for this test is setup as: Parent Box (custom icon =
+     * [PointerIcon.Crosshair], overrideDescendants = TRUE) ⤷ Child Box (custom icon =
+     * [PointerIcon.Text], overrideDescendants = FALSE) ⤷ Grandchild Box (custom icon =
+     * [PointerIcon.Hand], overrideDescendants = TRUE)
+     *
+     * Expected Output: Parent Box’s [PointerIcon.Crosshair] wins for the entire surface area of its
+     * Box. Even though the Child and Grandchild Box’s icons were set, the Parent Box will always
+     * take priority because it's the highestmost level in the hierarchy where overrideDescendants =
+     * true.
+     *
+     * Parent Box (output icon = [PointerIcon.Crosshair]) ⤷ Child Box (output icon =
+     * [PointerIcon.Crosshair]) ⤷ Grandchild Box (output icon = [PointerIcon.Crosshair])
+     */
+    @Test
+    fun multiLayeredNesting_allCustomIconsParentAndGrandchildOverride() {
+        rule.setContent {
+            CompositionLocalProvider(LocalPointerIconService provides iconService) {
+                Box(
+                    modifier =
+                        Modifier.requiredSize(200.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+                            .testTag(parentIconTag)
+                            .stylusHoverIcon(desiredParentIcon, overrideDescendants = true)
+                ) {
+                    Box(
+                        Modifier.padding(20.dp)
+                            .requiredSize(150.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+                            .testTag(childIconTag)
+                            .stylusHoverIcon(desiredChildIcon, overrideDescendants = false)
+                    ) {
+                        Box(
+                            Modifier.padding(40.dp)
+                                .requiredSize(100.dp)
+                                .border(BorderStroke(2.dp, SolidColor(Color.Blue)))
+                                .testTag(grandchildIconTag)
+                                .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = true)
+                        )
+                    }
+                }
+            }
+        }
+        // Verify initial state of pointer icon
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+        // Verify Parent Box's icon is the desired parent icon
+        verifyIconOnHover(parentIconTag, desiredParentIcon)
+        // Verify Child Box is respecting Parent Box's icon
+        verifyIconOnHover(childIconTag, desiredParentIcon)
+        // Verify Grandchild Box is respecting Parent Box's icon
+        verifyIconOnHover(grandchildIconTag, desiredParentIcon)
+    }
+
+    /**
+     * Setup: The hierarchy for this test is setup as: Parent Box (custom icon =
+     * [PointerIcon.Crosshair], overrideDescendants = TRUE) ⤷ Child Box (custom icon =
+     * [PointerIcon.Text], overrideDescendants = TRUE) ⤷ Grandchild Box (custom icon =
+     * [PointerIcon.Hand], overrideDescendants = FALSE)
+     *
+     * Expected Output: Parent Box’s [PointerIcon.Crosshair] wins for the entire surface area of its
+     * Box. Even though the Child and Grandchild Box’s icons were set, the Parent Box will always
+     * take priority because it's the highestmost level in the hierarchy where overrideDescendants =
+     * true.
+     *
+     * Parent Box (output icon = [PointerIcon.Crosshair]) ⤷ Child Box (output icon =
+     * [PointerIcon.Crosshair]) ⤷ Grandchild Box (output icon = [PointerIcon.Crosshair])
+     */
+    @Test
+    fun multiLayeredNesting_allCustomIconsParentAndChildOverride() {
+        rule.setContent {
+            CompositionLocalProvider(LocalPointerIconService provides iconService) {
+                Box(
+                    modifier =
+                        Modifier.requiredSize(200.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+                            .testTag(parentIconTag)
+                            .stylusHoverIcon(desiredParentIcon, overrideDescendants = true)
+                ) {
+                    Box(
+                        Modifier.padding(20.dp)
+                            .requiredSize(150.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+                            .testTag(childIconTag)
+                            .stylusHoverIcon(desiredChildIcon, overrideDescendants = true)
+                    ) {
+                        Box(
+                            Modifier.padding(40.dp)
+                                .requiredSize(100.dp)
+                                .border(BorderStroke(2.dp, SolidColor(Color.Blue)))
+                                .testTag(grandchildIconTag)
+                                .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = false)
+                        )
+                    }
+                }
+            }
+        }
+        // Verify initial state of pointer icon
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+        // Verify Parent Box's icon is the desired parent icon
+        verifyIconOnHover(parentIconTag, desiredParentIcon)
+        // Verify Child Box is respecting Parent Box's icon
+        verifyIconOnHover(childIconTag, desiredParentIcon)
+        // Verify Grandchild Box is respecting Parent Box's icon
+        verifyIconOnHover(grandchildIconTag, desiredParentIcon)
+    }
+
+    /**
+     * Setup: The hierarchy for this test is setup as: Parent Box (custom icon =
+     * [PointerIcon.Crosshair], overrideDescendants = TRUE) ⤷ Child Box (custom icon =
+     * [PointerIcon.Text], overrideDescendants = TRUE) ⤷ Grandchild Box (custom icon =
+     * [PointerIcon.Hand], overrideDescendants = TRUE)
+     *
+     * Expected Output: Parent Box’s [PointerIcon.Crosshair] wins for the entire surface area of its
+     * Box. Even though the Child and Grandchild Box’s icons were set, the Parent Box will always
+     * take priority because it's the highestmost level in the hierarchy where overrideDescendants =
+     * true.
+     *
+     * Parent Box (output icon = [PointerIcon.Crosshair]) ⤷ Child Box (output icon =
+     * [PointerIcon.Crosshair]) ⤷ Grandchild Box (output icon = [PointerIcon.Crosshair])
+     */
+    @Test
+    fun multiLayeredNesting_allIconsOverride() {
+        rule.setContent {
+            CompositionLocalProvider(LocalPointerIconService provides iconService) {
+                Box(
+                    modifier =
+                        Modifier.requiredSize(200.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+                            .testTag(parentIconTag)
+                            .stylusHoverIcon(desiredParentIcon, overrideDescendants = true)
+                ) {
+                    Box(
+                        Modifier.padding(20.dp)
+                            .requiredSize(150.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+                            .testTag(childIconTag)
+                            .stylusHoverIcon(desiredChildIcon, overrideDescendants = true)
+                    ) {
+                        Box(
+                            Modifier.padding(40.dp)
+                                .requiredSize(100.dp)
+                                .border(BorderStroke(2.dp, SolidColor(Color.Blue)))
+                                .testTag(grandchildIconTag)
+                                .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = true)
+                        )
+                    }
+                }
+            }
+        }
+        // Verify initial state of pointer icon
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+        // Verify Parent Box's icon is the desired parent icon
+        verifyIconOnHover(parentIconTag, desiredParentIcon)
+        // Verify Child Box is respecting Parent Box's icon
+        verifyIconOnHover(childIconTag, desiredParentIcon)
+        // Verify Grandchild Box is respecting Parent Box's icon
+        verifyIconOnHover(grandchildIconTag, desiredParentIcon)
+    }
+
+    /**
+     * This test takes an existing Box with a custom icon and changes the custom icon to a different
+     * custom icon while the cursor is hovered over the box.
+     */
+    @Test
+    fun dynamicallyUpdatedIcon() {
+        val icon = mutableStateOf(desiredChildIcon)
+
+        rule.setContent {
+            CompositionLocalProvider(LocalPointerIconService provides iconService) {
+                Box(
+                    modifier =
+                        Modifier.requiredSize(200.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+                            .testTag(parentIconTag)
+                            .stylusHoverIcon(desiredParentIcon, overrideDescendants = false)
+                ) {
+                    Box(
+                        Modifier.padding(20.dp)
+                            .requiredSize(100.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+                            .testTag(childIconTag)
+                            .stylusHoverIcon(icon.value, overrideDescendants = false)
+                    )
+                }
+            }
+        }
+
+        // Verify initial state of pointer icon
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+        // Hover over Child Box
+        rule.onNodeWithTag(childIconTag).performStylusInput { hoverEnter(bottomRight) }
+        // Verify Child Box has the desired child icon and dynamically update the icon assigned to
+        // the Child Box while hovering over Child Box
+        rule.runOnIdle {
+            assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon)
+            icon.value = desiredGrandchildIcon
+        }
+        // Verify the icon has been updated to the desired grandchild icon
+        rule.runOnIdle {
+            assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredGrandchildIcon)
+        }
+        // Move cursor within Child Box and verify it still has the updated icon
+        rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(centerRight) }
+        rule.runOnIdle {
+            assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredGrandchildIcon)
+        }
+        // Exit hovering over Child Box
+        rule.onNodeWithTag(childIconTag).performStylusInput { hoverExit() }
+    }
+
+    /**
+     * Setup: The hierarchy for the initial setup of this test is: Parent Box (custom icon =
+     * [PointerIcon.Crosshair], overrideDescendants = FALSE)
+     *
+     * After hovering over the center of the screen, the hierarchy under the cursor updates to:
+     * Parent Box (custom icon = [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Child Box
+     * (custom icon = [PointerIcon.Text], overrideDescendants = FALSE)
+     *
+     * Expected Output: Initially, the Parent Box's [PointerIcon.Crosshair] should win for its
+     * entire surface area because it has no competition in the hierarchy for any other custom
+     * icons. After the Child Box is dynamically added under the cursor, the Child Box's
+     * [PointerIcon.Text] should win for the entire surface area of the Child Box. This also
+     * requires updating the user facing cursor icon to reflect the Child Box that was added under
+     * the cursor.
+     *
+     * Parent Box (output icon = [PointerIcon.Crosshair]) ⤷ Child Box (output icon =
+     * [PointerIcon.Text])
+     */
+    @Test
+    fun dynamicallyAddAndRemoveChild_noOverrideDescendants() {
+        val isChildVisible = mutableStateOf(false)
+
+        rule.setContent {
+            CompositionLocalProvider(LocalPointerIconService provides iconService) {
+                Box(
+                    modifier =
+                        Modifier.requiredSize(200.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+                            .testTag(parentIconTag)
+                            .stylusHoverIcon(desiredParentIcon, overrideDescendants = false),
+                    contentAlignment = Alignment.Center
+                ) {
+                    if (isChildVisible.value) {
+                        Box(
+                            modifier =
+                                Modifier.padding(20.dp)
+                                    .requiredSize(100.dp)
+                                    .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+                                    .testTag(childIconTag)
+                                    .stylusHoverIcon(desiredChildIcon, overrideDescendants = false)
+                        )
+                    }
+                }
+            }
+        }
+
+        // Verify initial state of pointer icon
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+        // Hover over center of Parent Box
+        rule.onNodeWithTag(parentIconTag).performStylusInput { hoverEnter(center) }
+        // Verify Parent Box has the desired parent icon and dynamically add the Child Box under the
+        // cursor
+        rule.runOnIdle {
+            assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon)
+            isChildVisible.value = true
+        }
+        // Verify the icon has been updated to the desired child icon
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) }
+        // Move cursor within Child Box and verify it still has the updated icon
+        rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(centerRight) }
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) }
+        // Move cursor outside Child Box and verify the icon is updated to the desired parent icon
+        rule.onNodeWithTag(parentIconTag).performStylusInput { hoverMoveTo(bottomCenter) }
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) }
+        // Move cursor back to the center of the Child Box
+        rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(center) }
+        // Dynamically remove the Child Box
+        rule.runOnIdle { isChildVisible.value = false }
+        // Verify the icon has been updated to the desired parent icon
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) }
+        // Exit hovering over Parent Box
+        rule.onNodeWithTag(parentIconTag).performStylusInput { hoverExit() }
+    }
+
+    /**
+     * Setup: The hierarchy for the initial setup of this test is: Parent Box (custom icon =
+     * [PointerIcon.Crosshair], overrideDescendants = TRUE)
+     *
+     * After hovering over the center of the screen, the hierarchy under the cursor updates to:
+     * Parent Box (custom icon = [PointerIcon.Crosshair], overrideDescendants = TRUE) ⤷ Child Box
+     * (custom icon = [PointerIcon.Text], overrideDescendants = FALSE)
+     *
+     * Expected Output: The Parent Box's [PointerIcon.Crosshair] should win for its entire surface
+     * area regardless of whether the Child Box is visible or not. This is because the Parent Box's
+     * overrideDescendants = true, so its children should always respect Parent Box's custom icon.
+     *
+     * Parent Box (output icon = [PointerIcon.Crosshair]) ⤷ Child Box (output icon =
+     * [PointerIcon.Crosshair])
+     */
+    @Test
+    fun dynamicallyAddAndRemoveChild_parentOverridesDescendants() {
+        val isChildVisible = mutableStateOf(false)
+
+        rule.setContent {
+            CompositionLocalProvider(LocalPointerIconService provides iconService) {
+                Box(
+                    modifier =
+                        Modifier.requiredSize(200.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+                            .testTag(parentIconTag)
+                            .stylusHoverIcon(desiredParentIcon, overrideDescendants = true),
+                    contentAlignment = Alignment.Center
+                ) {
+                    if (isChildVisible.value) {
+                        Box(
+                            modifier =
+                                Modifier.padding(20.dp)
+                                    .requiredSize(100.dp)
+                                    .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+                                    .testTag(childIconTag)
+                                    .stylusHoverIcon(desiredChildIcon, overrideDescendants = false)
+                        )
+                    }
+                }
+            }
+        }
+
+        // Verify initial state of pointer icon
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+        // Hover over center of Parent Box
+        rule.onNodeWithTag(parentIconTag).performStylusInput { hoverEnter(center) }
+        // Verify Parent Box has the desired parent icon and dynamically add the Child Box under the
+        // cursor
+        rule.runOnIdle {
+            assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon)
+            isChildVisible.value = true
+        }
+        // Verify the icon stays as the parent icon
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) }
+        // Move cursor within Child Box and verify it still is the parent icon
+        rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(centerRight) }
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) }
+        // Move cursor outside Child Box and verify the icon is updated to the desired parent icon
+        rule.onNodeWithTag(parentIconTag).performStylusInput { hoverMoveTo(bottomCenter) }
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) }
+        // Move cursor back to the center of the Child Box
+        rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(center) }
+        // Dynamically remove the Child Box
+        rule.runOnIdle { isChildVisible.value = false }
+        // Verify the icon still the desired parent icon
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) }
+        // Exit hovering over Parent Box
+        rule.onNodeWithTag(parentIconTag).performStylusInput { hoverExit() }
+    }
+
+    /**
+     * Setup: The hierarchy for the initial setup of this test is: Parent Box (custom icon =
+     * [PointerIcon.Crosshair], overrideDescendants = FALSE)
+     *
+     * After hovering over the center of the screen, the hierarchy under the cursor updates to:
+     * Parent Box (custom icon = [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Child Box
+     * (custom icon = [PointerIcon.Text], overrideDescendants = FALSE) ⤷ Grandchild Box (custom icon
+     * = [PointerIcon.Hand], overrideDescendants = FALSE)
+     *
+     * Expected Output: Initially, the Parent Box's [PointerIcon.Crosshair] should win for its
+     * entire surface area because it has no competition in the hierarchy for any other custom
+     * icons. After the Child Box and the Grandchild Box are dynamically added under the cursor, the
+     * Grandchild Box's [PointerIcon.Hand] should win for the entire surface area of the Grandchild
+     * Box. The Child Box's [PointerIcon.Text] should win for the remaining surface area of the
+     * Child Box not covered by the Grandchild Box. This also requires updating the user facing
+     * cursor icon to reflect the Child Box and Grandchild Box that were added under the cursor.
+     *
+     * Parent Box (output icon = [PointerIcon.Crosshair]) ⤷ Child Box (output icon =
+     * [PointerIcon.Text]) ⤷ Grandchild Box (output icon = [PointerIcon.Hand])
+     */
+    @Test
+    fun dynamicallyAddAndRemoveChildAndGrandchild_noOverrideDescendants() {
+        val areDescendantsVisible = mutableStateOf(false)
+
+        rule.setContent {
+            CompositionLocalProvider(LocalPointerIconService provides iconService) {
+                Box(
+                    modifier =
+                        Modifier.requiredSize(200.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+                            .testTag(parentIconTag)
+                            .stylusHoverIcon(desiredParentIcon, overrideDescendants = false),
+                    contentAlignment = Alignment.Center
+                ) {
+                    if (areDescendantsVisible.value) {
+                        Box(
+                            modifier =
+                                Modifier.padding(20.dp)
+                                    .requiredSize(150.dp)
+                                    .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+                                    .testTag(childIconTag)
+                                    .stylusHoverIcon(desiredChildIcon, overrideDescendants = false),
+                            contentAlignment = Alignment.Center
+                        ) {
+                            Box(
+                                modifier =
+                                    Modifier.padding(40.dp)
+                                        .requiredSize(100.dp)
+                                        .border(BorderStroke(2.dp, SolidColor(Color.Blue)))
+                                        .testTag(grandchildIconTag)
+                                        .stylusHoverIcon(
+                                            desiredGrandchildIcon,
+                                            overrideDescendants = false
+                                        )
+                            )
+                        }
+                    }
+                }
+            }
+        }
+
+        // Verify initial state of pointer icon
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+        // Hover over center of Parent Box
+        rule.onNodeWithTag(parentIconTag).performStylusInput { hoverEnter(center) }
+        // Verify Parent Box has the desired parent icon and dynamically add the Child Box and
+        // Grandchild Box under the cursor
+        rule.runOnIdle {
+            assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon)
+            areDescendantsVisible.value = true
+        }
+        // Verify the icon has been updated to the desired grandchild icon
+        rule.runOnIdle {
+            assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredGrandchildIcon)
+        }
+        // Move cursor within Grandchild Box and verify it still has the grandchild icon
+        rule.onNodeWithTag(grandchildIconTag).performStylusInput { hoverMoveTo(centerRight) }
+        rule.runOnIdle {
+            assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredGrandchildIcon)
+        }
+        // Move cursor outside Grandchild Box within Child Box and verify it has the child icon
+        rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(bottomRight) }
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) }
+        // Move cursor outside Child Box and verify the icon is updated to the desired parent icon
+        rule.onNodeWithTag(parentIconTag).performStylusInput { hoverMoveTo(bottomCenter) }
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) }
+        // Move cursor back to the center of the Grandchild Box
+        rule.onNodeWithTag(grandchildIconTag).performStylusInput { hoverMoveTo(center) }
+        // Dynamically remove the Child Box and Grandchild Box
+        rule.runOnIdle { areDescendantsVisible.value = false }
+        // Verify the icon has been updated to the desired parent icon
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) }
+        // Exit hovering over Parent Box
+        rule.onNodeWithTag(parentIconTag).performStylusInput { hoverExit() }
+    }
+
+    /**
+     * Setup: The hierarchy for the initial setup of this test is: Parent Box (custom icon =
+     * [PointerIcon.Crosshair], overrideDescendants = FALSE)
+     *
+     * After hovering over the center of the screen, the hierarchy under the cursor updates to:
+     * Parent Box (custom icon = [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Child Box
+     * (custom icon = [PointerIcon.Text], overrideDescendants = FALSE) ⤷ Grandchild Box (custom icon
+     * = [PointerIcon.Hand], overrideDescendants = TRUE)
+     *
+     * Expected Output: Initially, the Parent Box's [PointerIcon.Crosshair] should win for its
+     * entire surface area because it has no competition in the hierarchy for any other custom
+     * icons. After the Child Box and the Grandchild Box are dynamically added under the cursor, the
+     * Grandchild Box's [PointerIcon.Hand] should win for the entire surface area of the Grandchild
+     * Box. Because the Grandchild Box is the lowest level in the hierarchy, the outcome doesn't
+     * change whether it has overrideDescendants = true or not. The Child Box's [PointerIcon.Text]
+     * should win for the remaining surface area of the Child Box not covered by the Grandchild Box.
+     * This also requires updating the user facing cursor icon to reflect the Child Box and
+     * Grandchild Box that were added under the cursor.
+     *
+     * Parent Box (output icon = [PointerIcon.Crosshair]) ⤷ Child Box (output icon =
+     * [PointerIcon.Text]) ⤷ Grandchild Box (output icon = [PointerIcon.Hand])
+     */
+    @Test
+    fun dynamicallyAddAndRemoveChildAndGrandchild_grandchildOverridesDescendants() {
+        val areDescendantsVisible = mutableStateOf(false)
+
+        rule.setContent {
+            CompositionLocalProvider(LocalPointerIconService provides iconService) {
+                Box(
+                    modifier =
+                        Modifier.requiredSize(200.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+                            .testTag(parentIconTag)
+                            .stylusHoverIcon(desiredParentIcon, overrideDescendants = false),
+                    contentAlignment = Alignment.Center
+                ) {
+                    if (areDescendantsVisible.value) {
+                        Box(
+                            modifier =
+                                Modifier.padding(20.dp)
+                                    .requiredSize(150.dp)
+                                    .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+                                    .testTag(childIconTag)
+                                    .stylusHoverIcon(desiredChildIcon, overrideDescendants = false),
+                            contentAlignment = Alignment.Center
+                        ) {
+                            Box(
+                                modifier =
+                                    Modifier.padding(40.dp)
+                                        .requiredSize(100.dp)
+                                        .border(BorderStroke(2.dp, SolidColor(Color.Blue)))
+                                        .testTag(grandchildIconTag)
+                                        .stylusHoverIcon(
+                                            desiredGrandchildIcon,
+                                            overrideDescendants = true
+                                        )
+                            )
+                        }
+                    }
+                }
+            }
+        }
+
+        // Verify initial state of pointer icon
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+        // Hover over center of Parent Box
+        rule.onNodeWithTag(parentIconTag).performStylusInput { hoverEnter(center) }
+        // Verify Parent Box has the desired parent icon and dynamically add the Child Box and
+        // Grandchild Box under the cursor
+        rule.runOnIdle {
+            assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon)
+            areDescendantsVisible.value = true
+        }
+        // Verify the icon has been updated to the desired grandchild icon
+        rule.runOnIdle {
+            assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredGrandchildIcon)
+        }
+        // Move cursor within Grandchild Box and verify it still has the grandchild icon
+        rule.onNodeWithTag(grandchildIconTag).performStylusInput { hoverMoveTo(centerRight) }
+        rule.runOnIdle {
+            assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredGrandchildIcon)
+        }
+        // Move cursor outside Grandchild Box within Child Box and verify it has the child icon
+        rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(bottomRight) }
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) }
+        // Move cursor outside Child Box and verify the icon is updated to the desired parent icon
+        rule.onNodeWithTag(parentIconTag).performStylusInput { hoverMoveTo(bottomCenter) }
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) }
+        // Move cursor back to the center of the Grandchild Box
+        rule.onNodeWithTag(grandchildIconTag).performStylusInput { hoverMoveTo(center) }
+        // Dynamically remove the Child Box and Grandchild Box
+        rule.runOnIdle { areDescendantsVisible.value = false }
+        // Verify the icon has been updated to the desired parent icon
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) }
+        // Exit hovering over Parent Box
+        rule.onNodeWithTag(parentIconTag).performStylusInput { hoverExit() }
+    }
+
+    /**
+     * Setup: The hierarchy for the initial setup of this test is: Parent Box (custom icon =
+     * [PointerIcon.Crosshair], overrideDescendants = FALSE)
+     *
+     * After hovering over the center of the screen, the hierarchy under the cursor updates to:
+     * Parent Box (custom icon = [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Child Box
+     * (custom icon = [PointerIcon.Text], overrideDescendants = TRUE) ⤷ Grandchild Box (custom icon
+     * = [PointerIcon.Hand], overrideDescendants = FALSE)
+     *
+     * Expected Output: Initially, the Parent Box's [PointerIcon.Crosshair] should win for its
+     * entire surface area because it has no competition in the hierarchy for any other custom
+     * icons. After the Child Box and the Grandchild Box are dynamically added under the cursor, the
+     * Child Box's [PointerIcon.Text] should win for the entire surface area of the Child Box. This
+     * includes the Grandchild Box's [PointerIcon.Text] should win for the remaining surface area of
+     * the Child Box not covered by the Grandchild Box. This also requires updating the user facing
+     * cursor icon to reflect the Child Box and Grandchild Box that were added under the cursor.
+     *
+     * Parent Box (output icon = [PointerIcon.Crosshair]) ⤷ Child Box (output icon =
+     * [PointerIcon.Text]) ⤷ Grandchild Box (output icon = [PointerIcon.Hand])
+     */
+    @Test
+    fun dynamicallyAddAndRemoveChildAndGrandchild_childOverridesDescendants() {
+        val areDescendantsVisible = mutableStateOf(false)
+
+        rule.setContent {
+            CompositionLocalProvider(LocalPointerIconService provides iconService) {
+                Box(
+                    modifier =
+                        Modifier.requiredSize(200.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+                            .testTag(parentIconTag)
+                            .stylusHoverIcon(desiredParentIcon, overrideDescendants = false),
+                    contentAlignment = Alignment.Center
+                ) {
+                    if (areDescendantsVisible.value) {
+                        Box(
+                            modifier =
+                                Modifier.padding(20.dp)
+                                    .requiredSize(150.dp)
+                                    .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+                                    .testTag(childIconTag)
+                                    .stylusHoverIcon(desiredChildIcon, overrideDescendants = true),
+                            contentAlignment = Alignment.Center
+                        ) {
+                            Box(
+                                modifier =
+                                    Modifier.padding(40.dp)
+                                        .requiredSize(100.dp)
+                                        .border(BorderStroke(2.dp, SolidColor(Color.Blue)))
+                                        .testTag(grandchildIconTag)
+                                        .stylusHoverIcon(
+                                            desiredGrandchildIcon,
+                                            overrideDescendants = false
+                                        )
+                            )
+                        }
+                    }
+                }
+            }
+        }
+
+        // Verify initial state of pointer icon
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+        // Hover over center of Parent Box
+        rule.onNodeWithTag(parentIconTag).performStylusInput { hoverEnter(center) }
+        // Verify Parent Box has the desired parent icon, then dynamically add the Child Box and
+        // Grandchild Box under the cursor
+        rule.runOnIdle {
+            assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon)
+            areDescendantsVisible.value = true
+        }
+        // Verify the icon has been updated to the desired child icon
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) }
+        // Move cursor within Grandchild Box and verify it still has the child icon
+        rule.onNodeWithTag(grandchildIconTag).performStylusInput { hoverMoveTo(centerRight) }
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) }
+        // Move cursor outside Grandchild Box within Child Box to verify it still has the child icon
+        rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(bottomRight) }
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) }
+        // Move cursor outside Child Box and verify the icon is updated to the desired parent icon
+        rule.onNodeWithTag(parentIconTag).performStylusInput { hoverMoveTo(bottomCenter) }
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) }
+        // Move cursor back to the center of the Grandchild Box
+        rule.onNodeWithTag(grandchildIconTag).performStylusInput { hoverMoveTo(center) }
+        // Dynamically remove the Child Box and Grandchild Box
+        rule.runOnIdle { areDescendantsVisible.value = false }
+        // Verify the icon has been updated to the desired parent icon
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) }
+        // Exit hovering over Parent Box
+        rule.onNodeWithTag(parentIconTag).performStylusInput { hoverExit() }
+    }
+
+    /**
+     * Setup: The hierarchy for the initial setup of this test is: Child Box (custom icon =
+     * [PointerIcon.Text], overrideDescendants = FALSE)
+     *
+     * After hovering over the center of the screen, the hierarchy under the cursor updates to:
+     * Parent Box (custom icon = [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Child Box
+     * (custom icon = [PointerIcon.Text], overrideDescendants = FALSE)
+     *
+     * Expected Output: The Child Box's [PointerIcon.Text] should win for its entire surface area
+     * regardless of whether there's a Parent Box present or not. This is because the Parent Box has
+     * overrideDescendants = false and should therefore not have its custom icon take priority over
+     * the Child Box's custom icon. The Parent Box's [PointerIcon.Crosshair] should win for its
+     * remaining surface area not covered by the Child Box.
+     *
+     * Parent Box (output icon = [PointerIcon.Crosshair]) ⤷ Child Box (output icon =
+     * [PointerIcon.Text])
+     */
+    @Test
+    fun dynamicallyAddAndRemoveParent_noOverrideDescendants() {
+        val isParentVisible = mutableStateOf(false)
+        val child = movableContentOf {
+            Box(
+                modifier =
+                    Modifier.requiredSize(150.dp)
+                        .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+                        .testTag(childIconTag)
+                        .stylusHoverIcon(desiredChildIcon, overrideDescendants = false)
+            )
+        }
+
+        rule.setContent {
+            CompositionLocalProvider(LocalPointerIconService provides iconService) {
+                if (isParentVisible.value) {
+                    Box(
+                        modifier =
+                            Modifier.requiredSize(200.dp)
+                                .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+                                .testTag(parentIconTag)
+                                .stylusHoverIcon(desiredParentIcon, overrideDescendants = false),
+                        contentAlignment = Alignment.Center
+                    ) {
+                        child()
+                    }
+                } else {
+                    child()
+                }
+            }
+        }
+
+        // Verify initial state of pointer icon
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+        // Hover over center of Child Box
+        rule.onNodeWithTag(childIconTag).performStylusInput { hoverEnter(center) }
+        // Verify Child Box has the desired child icon and dynamically add the Parent Box under the
+        // cursor
+        rule.runOnIdle {
+            assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon)
+            isParentVisible.value = true
+        }
+        // Verify the icon stays as the desired child icon
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) }
+        // Move cursor within Child Box and verify it still has the child icon
+        rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(centerRight) }
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) }
+        // Move cursor outside Child Box and verify the icon is updated to the desired parent icon
+        rule.onNodeWithTag(parentIconTag).performStylusInput { hoverMoveTo(bottomCenter) }
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) }
+        // Move cursor back to the center of the Child Box
+        rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(center) }
+        // Dynamically remove the Parent Box
+        rule.runOnIdle { isParentVisible.value = false }
+        // Verify the icon stays as the desired child icon
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) }
+        // Exit hovering over Child Box
+        rule.onNodeWithTag(childIconTag).performStylusInput { hoverExit() }
+    }
+
+    /**
+     * Setup: The hierarchy for the initial setup of this test is: Child Box (custom icon =
+     * [PointerIcon.Text], overrideDescendants = FALSE)
+     *
+     * After hovering over the center of the screen, the hierarchy under the cursor updates to:
+     * Parent Box (custom icon = [PointerIcon.Crosshair], overrideDescendants = TRUE) ⤷ Child Box
+     * (custom icon = [PointerIcon.Text], overrideDescendants = FALSE)
+     *
+     * Expected Output: The Child Box's [PointerIcon.Text] should win for its entire surface area
+     * when the Parent Box isn't present. Once the Parent Box becomes visible, the Parent Box's
+     * [PointerIcon.Crosshair] should win for its entire surface area. This is because the Parent
+     * Box's overrideDescendants = true, so its children should always respect Parent Box's custom
+     * icon. This also requires updating the user facing cursor icon to reflect the Parent Box that
+     * was added under the cursor.
+     *
+     * Parent Box (output icon = [PointerIcon.Crosshair]) ⤷ Child Box (output icon =
+     * [PointerIcon.Crosshair])
+     */
+    @Test
+    fun dynamicallyAddAndRemoveParent_parentOverridesDescendants() {
+        val isParentVisible = mutableStateOf(false)
+        val child = movableContentOf {
+            Box(
+                modifier =
+                    Modifier.requiredSize(150.dp)
+                        .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+                        .testTag(childIconTag)
+                        .stylusHoverIcon(desiredChildIcon, overrideDescendants = false)
+            )
+        }
+
+        rule.setContent {
+            CompositionLocalProvider(LocalPointerIconService provides iconService) {
+                if (isParentVisible.value) {
+                    Box(
+                        modifier =
+                            Modifier.requiredSize(200.dp)
+                                .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+                                .testTag(parentIconTag)
+                                .stylusHoverIcon(desiredParentIcon, overrideDescendants = true),
+                        contentAlignment = Alignment.Center
+                    ) {
+                        child()
+                    }
+                } else {
+                    child()
+                }
+            }
+        }
+
+        // Verify initial state of pointer icon
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+        // Hover over center of Child Box
+        rule.onNodeWithTag(childIconTag).performStylusInput { hoverEnter(center) }
+        // Verify Child Box has the desired child icon and dynamically add the Parent Box under the
+        // cursor
+        rule.runOnIdle {
+            assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon)
+            isParentVisible.value = true
+        }
+        // Verify the icon has been updated to the desired parent icon
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) }
+        // Move cursor within Child Box and verify it still has the parent icon
+        rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(centerRight) }
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) }
+        // Move cursor outside Child Box and verify the icon is still the parent icon
+        rule.onNodeWithTag(parentIconTag).performStylusInput { hoverMoveTo(bottomCenter) }
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) }
+        // Move cursor back to the center of the Child Box
+        rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(center) }
+        // Dynamically remove the Parent Box
+        rule.runOnIdle { isParentVisible.value = false }
+        // Verify the icon has been updated to the desired child icon
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) }
+        // Exit hovering over Child Box
+        rule.onNodeWithTag(childIconTag).performStylusInput { hoverExit() }
+    }
+
+    /**
+     * Setup: The hierarchy for the initial setup of this test is: Parent Box (custom icon =
+     * [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Grandchild Box (custom icon =
+     * [PointerIcon.Hand], overrideDescendants = FALSE)
+     *
+     * After hovering over the center of the screen, the hierarchy under the cursor updates to:
+     * Parent Box (custom icon = [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Child Box
+     * (custom icon = [PointerIcon.Text], overrideDescendants = FALSE) ⤷ Grandchild Box (custom icon
+     * = [PointerIcon.Hand], overrideDescendants = FALSE)
+     *
+     * Expected Output: The Grandchild Box's [PointerIcon.Hand] should win for its entire surface
+     * area regardless of whether there's a Child Box or Parent Box present. This is because the
+     * Parent Box and Child Box have overrideDescendants = false and should therefore not have their
+     * custom icons take priority over the Grandchild Box's custom icon. The Child Box should win
+     * for its remaining surface area not covered by the Grandchild Box. The Parent Box's
+     * [PointerIcon.Crosshair] should win for its remaining surface area not covered by the Child
+     * Box.
+     *
+     * Parent Box (output icon = [PointerIcon.Crosshair]) ⤷ Child Box (output icon =
+     * [PointerIcon.Text]) ⤷ Grandchild Box (output icon = [PointerIcon.Hand])
+     */
+    @Test
+    fun dynamicallyAddAndRemoveNestedChild_noOverrideDescendants() {
+        val isChildVisible = mutableStateOf(false)
+        val grandchild = movableContentOf {
+            Box(
+                modifier =
+                    Modifier.requiredSize(100.dp)
+                        .border(BorderStroke(2.dp, SolidColor(Color.Blue)))
+                        .testTag(grandchildIconTag)
+                        .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = false)
+            )
+        }
+
+        rule.setContent {
+            CompositionLocalProvider(LocalPointerIconService provides iconService) {
+                Box(
+                    modifier =
+                        Modifier.requiredSize(200.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+                            .testTag(parentIconTag)
+                            .stylusHoverIcon(desiredParentIcon, overrideDescendants = false),
+                    contentAlignment = Alignment.Center
+                ) {
+                    if (isChildVisible.value) {
+                        Box(
+                            modifier =
+                                Modifier.requiredSize(150.dp)
+                                    .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+                                    .testTag(childIconTag)
+                                    .stylusHoverIcon(desiredChildIcon, overrideDescendants = false),
+                            contentAlignment = Alignment.Center
+                        ) {
+                            grandchild()
+                        }
+                    } else {
+                        grandchild()
+                    }
+                }
+            }
+        }
+
+        // Verify initial state of pointer icon
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+        // Hover over center of Grandchild Box
+        rule.onNodeWithTag(grandchildIconTag).performStylusInput { hoverEnter(center) }
+        // Verify Grandchild Box has the desired grandchild icon and dynamically add the Child Box
+        // under the cursor
+        rule.runOnIdle {
+            assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredGrandchildIcon)
+            isChildVisible.value = true
+        }
+        // Verify the icon stays as the desired grandchild icon
+        rule.runOnIdle {
+            assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredGrandchildIcon)
+        }
+        // Move cursor within Grandchild Box and verify it still has the grandchild icon
+        rule.onNodeWithTag(grandchildIconTag).performStylusInput { hoverMoveTo(centerRight) }
+        rule.runOnIdle {
+            assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredGrandchildIcon)
+        }
+        // Move cursor outside Grandchild Box within Child Box to verify icon is now the child icon
+        rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(bottomRight) }
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) }
+        // Move cursor outside Child Box and verify the icon is updated to the desired parent icon
+        rule.onNodeWithTag(parentIconTag).performStylusInput { hoverMoveTo(bottomRight) }
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) }
+        // Move cursor back to the center of the Grandchild Box
+        rule.onNodeWithTag(grandchildIconTag).performStylusInput { hoverMoveTo(center) }
+        // Dynamically remove the Child Box
+        rule.runOnIdle { isChildVisible.value = false }
+        // Verify the icon has been updated to the desired grandchild icon
+        rule.runOnIdle {
+            assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredGrandchildIcon)
+        }
+        // Exit hovering over Parent Box
+        rule.onNodeWithTag(parentIconTag).performStylusInput { hoverExit() }
+    }
+
+    /**
+     * Setup: The hierarchy for the initial setup of this test is: Parent Box (custom icon =
+     * [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Grandchild Box (custom icon =
+     * [PointerIcon.Hand], overrideDescendants = FALSE)
+     *
+     * After hovering over the center of the screen, the hierarchy under the cursor updates to:
+     * Parent Box (custom icon = [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Child Box
+     * (custom icon = [PointerIcon.Text], overrideDescendants = TRUE) ⤷ Grandchild Box (custom icon
+     * = [PointerIcon.Hand], overrideDescendants = FALSE)
+     *
+     * Expected Output: The Grandchild Box's [PointerIcon.Hand] should win for its entire surface
+     * area regardless of whether there's a Child Box or Parent Box present. This is because the
+     * Parent Box and Child Box have overrideDescendants = false and should therefore not have thei
+     * custom icona take priority over the Grandchild Box's custom icon. The Child Box should win
+     * for its remaining surface area not covered by the Grandchild Box. The Parent Box's
+     * [PointerIcon.Crosshair] should win for its remaining surface area not covered by the Child
+     * Box. Initially, the Parent Box's [PointerIcon.Crosshair] should win for its entire surface
+     * area because it has no competition in the hierarchy for any other custom icons. After the
+     * Child Box and the Grandchild Box are dynamically added under the cursor, the Child Box's
+     * [PointerIcon.Text] should win for the entire surface area of the Child Box. This includes the
+     * Grandchild Box's [PointerIcon.Text] should win for the remaining surface area of the Child
+     * Box not covered by the Grandchild Box. This also requires updating the user facing cursor
+     * icon to reflect the Child Box and Grandchild Box that were added under the cursor.
+     *
+     * Parent Box (output icon = [PointerIcon.Crosshair]) ⤷ Child Box (output icon =
+     * [PointerIcon.Text]) ⤷ Grandchild Box (output icon = [PointerIcon.Hand])
+     */
+    @Test
+    fun dynamicallyAddAndRemoveNestedChild_ChildOverridesDescendants() {
+        val isChildVisible = mutableStateOf(false)
+        val grandchild = movableContentOf {
+            Box(
+                modifier =
+                    Modifier.requiredSize(100.dp)
+                        .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+                        .testTag(grandchildIconTag)
+                        .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = false)
+            )
+        }
+
+        rule.setContent {
+            CompositionLocalProvider(LocalPointerIconService provides iconService) {
+                Box(
+                    modifier =
+                        Modifier.requiredSize(200.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+                            .testTag(parentIconTag)
+                            .stylusHoverIcon(desiredParentIcon, overrideDescendants = false),
+                    contentAlignment = Alignment.Center
+                ) {
+                    if (isChildVisible.value) {
+                        Box(
+                            modifier =
+                                Modifier.requiredSize(150.dp)
+                                    .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+                                    .testTag(childIconTag)
+                                    .stylusHoverIcon(desiredChildIcon, overrideDescendants = true),
+                            contentAlignment = Alignment.Center
+                        ) {
+                            grandchild()
+                        }
+                    } else {
+                        grandchild()
+                    }
+                }
+            }
+        }
+
+        // Verify initial state of pointer icon
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+        // Hover over center of Grandchild Box
+        rule.onNodeWithTag(grandchildIconTag).performStylusInput { hoverEnter(center) }
+        // Verify Grandchild Box has the desired grandchild icon and dynamically add the Child Box
+        // under the cursor
+        rule.runOnIdle {
+            assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredGrandchildIcon)
+            isChildVisible.value = true
+        }
+        // Verify the icon has been updated to the desired child icon
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) }
+        // Move cursor within Grandchild Box and verify it still has the child icon
+        rule.onNodeWithTag(grandchildIconTag).performStylusInput { hoverMoveTo(centerRight) }
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) }
+        // Move cursor outside Grandchild Box within Child Box to verify it still has the child icon
+        rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(bottomRight) }
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) }
+        // Move cursor outside Child Box and verify the icon is updated to the desired parent icon
+        rule.onNodeWithTag(parentIconTag).performStylusInput { hoverMoveTo(bottomCenter) }
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) }
+        // Move cursor back to center of the Grandchild Box
+        rule.onNodeWithTag(grandchildIconTag).performStylusInput { hoverMoveTo(center) }
+        // Dynamically remove the Child Box
+        rule.runOnIdle { isChildVisible.value = false }
+        // Verify the icon has been updated to the desired grandchild icon
+        rule.runOnIdle {
+            assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredGrandchildIcon)
+        }
+        // Exit hovering over Parent Box
+        rule.onNodeWithTag(parentIconTag).performStylusInput { hoverExit() }
+    }
+
+    /**
+     * Setup: The hierarchy for the initial setup of this test is: Grandparent Box (custom icon =
+     * [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Parent Box (custom icon =
+     * [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Grandchild Box (custom icon =
+     * [PointerIcon.Hand], overrideDescendants = FALSE)
+     *
+     * After hovering over the corner of the Grandparent Box that doesn't overlap with any
+     * descendant, the hierarchy of the screen updates to: Grandparent Box (custom icon =
+     * [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Parent Box (custom icon =
+     * [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Child Box (custom icon =
+     * [PointerIcon.Text], overrideDescendants = FALSE) ⤷ Grandchild Box (custom icon =
+     * [PointerIcon.Hand], overrideDescendants = FALSE)
+     *
+     * Expected Output: The Grandchild Box's [PointerIcon.Hand] should win for its entire surface
+     * area regardless of whether there's a Child, Parent, or Grandparent Box present. This is
+     * because the Grandparent, Parent, and Child Boxes have overrideDescendants = false and should
+     * therefore not have their custom icons take priority over the Grandchild Box's custom icon.
+     * The Child Box should win for its remaining surface area not covered by the Grandchild Box.
+     * The Parent Box's [PointerIcon.Crosshair] should win for its remaining surface area not
+     * covered by the Child Box. And the Grandparent Box should win for its remaining surface area
+     * not covered by the Parent Box.
+     *
+     * Grandparent Box (output icon = [PointerIcon.Crosshair]) ⤷ Parent Box (output icon =
+     * [PointerIcon.Crosshair]) ⤷ Child Box (output icon = [PointerIcon.Text]) ⤷ Grandchild Box
+     * (output icon = [PointerIcon.Hand])
+     */
+    @Test
+    fun dynamicallyAddAndRemoveNestedChild_notHoveredOverChild() {
+        val grandparentIconTag = "myGrandparentIcon"
+        val desiredGrandparentIcon = desiredParentIcon
+        val isChildVisible = mutableStateOf(false)
+        val grandchild = movableContentOf {
+            Box(
+                modifier =
+                    Modifier.requiredSize(100.dp)
+                        .border(BorderStroke(2.dp, SolidColor(Color.Blue)))
+                        .testTag(grandchildIconTag)
+                        .stylusHoverIcon(desiredGrandchildIcon, overrideDescendants = false)
+            )
+        }
+
+        rule.setContent {
+            CompositionLocalProvider(LocalPointerIconService provides iconService) {
+                Box(
+                    modifier =
+                        Modifier.requiredSize(200.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+                            .testTag(grandparentIconTag)
+                            .stylusHoverIcon(desiredGrandparentIcon, overrideDescendants = false),
+                    contentAlignment = Alignment.Center
+                ) {
+                    Box(
+                        modifier =
+                            Modifier.requiredSize(175.dp)
+                                .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+                                .testTag(parentIconTag)
+                                .stylusHoverIcon(desiredParentIcon, overrideDescendants = false),
+                        contentAlignment = Alignment.Center
+                    ) {
+                        if (isChildVisible.value) {
+                            Box(
+                                modifier =
+                                    Modifier.requiredSize(150.dp)
+                                        .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+                                        .testTag(childIconTag)
+                                        .stylusHoverIcon(
+                                            desiredChildIcon,
+                                            overrideDescendants = false
+                                        ),
+                                contentAlignment = Alignment.Center
+                            ) {
+                                grandchild()
+                            }
+                        } else {
+                            grandchild()
+                        }
+                    }
+                }
+            }
+        }
+
+        // Verify initial state of pointer icon
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+        // Hover over center of Grandchild Box
+        rule.onNodeWithTag(grandchildIconTag).performStylusInput { hoverEnter(center) }
+        // Verify Grandchild Box has the desired grandchild icon
+        rule.runOnIdle {
+            assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredGrandchildIcon)
+        }
+        // Move to corner of Grandparent Box where no descendants are under the cursor
+        rule.onNodeWithTag(grandparentIconTag).performStylusInput { hoverMoveTo(bottomRight) }
+        // Verify the icon is the desired grandparent icon and dynamically add the Child Box
+        rule.runOnIdle {
+            assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredGrandparentIcon)
+            isChildVisible.value = true
+        }
+        // Verify the icon stays as the desired grandparent icon
+        rule.runOnIdle {
+            assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredGrandparentIcon)
+        }
+        // Move cursor within Grandparent Box and verify it still has the grandparent icon
+        rule.onNodeWithTag(grandparentIconTag).performStylusInput { hoverMoveTo(centerRight) }
+        rule.runOnIdle {
+            assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredGrandparentIcon)
+        }
+        // Move cursor outside Grandparent Box to Parent Box to verify icon is now the parent icon
+        rule.onNodeWithTag(parentIconTag).performStylusInput { hoverMoveTo(bottomRight) }
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) }
+        // Move cursor back to corner of Grandparent Box where no descendants are under the cursor
+        rule.onNodeWithTag(grandparentIconTag).performStylusInput { hoverMoveTo(bottomRight) }
+        // Dynamically remove the Child Box
+        rule.runOnIdle { isChildVisible.value = false }
+        // Verify the icon stays as the grandparent icon
+        rule.runOnIdle {
+            assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredGrandparentIcon)
+        }
+        // Exit hovering over Parent Box
+        rule.onNodeWithTag(parentIconTag).performStylusInput { hoverExit() }
+    }
+
+    /**
+     * Setup: The hierarchy for the initial setup of this test is: Parent Box (custom icon =
+     * [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Child Box (custom icon =
+     * [PointerIcon.Text], overrideDescendants = FALSE)
+     *
+     * After hovering over the corner of the Parent Box that doesn't overlap with any descendant,
+     * the hierarchy of the screen updates to: Parent Box (custom icon = [PointerIcon.Crosshair],
+     * overrideDescendants = FALSE) ⤷ Child Box (custom icon = [PointerIcon.Text],
+     * overrideDescendants = FALSE) ⤷ Grandchild Box (custom icon = [PointerIcon.Hand],
+     * overrideDescendants = FALSE)
+     *
+     * Expected Output: The Grandchild Box's [PointerIcon.Hand] should win for its entire surface
+     * area regardless of whether there's a Child or Parent Box present. This is because the Parent
+     * and Child Boxes have overrideDescendants = false and should therefore not have their custom
+     * icons take priority over the Grandchild Box's custom icon. The Child Box should win for its
+     * remaining surface area not covered by the Grandchild Box. The Parent Box's
+     * [PointerIcon.Crosshair] should win for its remaining surface area not covered by the Child
+     * Box.
+     *
+     * Parent Box (output icon = [PointerIcon.Crosshair]) ⤷ Child Box (output icon =
+     * [PointerIcon.Text]) ⤷ Grandchild Box (output icon = [PointerIcon.Hand])
+     */
+    @Test
+    fun dynamicallyAddAndRemoveGrandchild_notHoveredOverGrandchild() {
+        val isGrandchildVisible = mutableStateOf(false)
+
+        rule.setContent {
+            CompositionLocalProvider(LocalPointerIconService provides iconService) {
+                Box(
+                    modifier =
+                        Modifier.requiredSize(200.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+                            .testTag(parentIconTag)
+                            .stylusHoverIcon(desiredParentIcon, overrideDescendants = false),
+                    contentAlignment = Alignment.Center
+                ) {
+                    Box(
+                        modifier =
+                            Modifier.requiredSize(150.dp)
+                                .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+                                .testTag(childIconTag)
+                                .stylusHoverIcon(desiredChildIcon, overrideDescendants = false),
+                        contentAlignment = Alignment.Center
+                    ) {
+                        if (isGrandchildVisible.value) {
+                            Box(
+                                modifier =
+                                    Modifier.requiredSize(100.dp)
+                                        .border(BorderStroke(2.dp, SolidColor(Color.Blue)))
+                                        .testTag(grandchildIconTag)
+                                        .stylusHoverIcon(
+                                            desiredGrandchildIcon,
+                                            overrideDescendants = false
+                                        )
+                            )
+                        }
+                    }
+                }
+            }
+        }
+
+        // Verify initial state of pointer icon
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+        // Hover over center of Child Box
+        rule.onNodeWithTag(childIconTag).performStylusInput { hoverEnter(center) }
+        // Verify Child Box has the desired child icon
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) }
+        // Move to corner of Parent Box where no descendants are under the cursor
+        rule.onNodeWithTag(parentIconTag).performStylusInput { hoverMoveTo(bottomRight) }
+        // Verify the icon is the desired parent icon and dynamically add the Grandchild Box
+        rule.runOnIdle {
+            assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon)
+            isGrandchildVisible.value = true
+        }
+        // Verify the icon stays as the desired parent icon
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) }
+        // Move cursor within Parent Box and verify it still has the grandparent icon
+        rule.onNodeWithTag(parentIconTag).performStylusInput { hoverMoveTo(centerRight) }
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) }
+        // Move cursor outside Parent Box to Child Box to verify icon is now the child icon
+        rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(bottomRight) }
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) }
+        // Move cursor back to corner of Parent Box where no descendants are under the cursor
+        rule.onNodeWithTag(parentIconTag).performStylusInput { hoverMoveTo(bottomRight) }
+        // Dynamically remove the Grandchild Box
+        rule.runOnIdle { isGrandchildVisible.value = false }
+        // Verify the icon stays as the parent icon
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) }
+        // Exit hovering over Parent Box
+        rule.onNodeWithTag(parentIconTag).performStylusInput { hoverExit() }
+    }
+
+    /**
+     * Setup: The hierarchy for the initial setup of this test is: Parent Box (custom icon =
+     * [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ ChildA Box (custom icon =
+     * [PointerIcon.Text], overrideDescendants = FALSE)
+     *
+     * After hovering over the area where ChildB will be, the hierarchy of the screen updates to:
+     * Parent Box (no custom icon set) ⤷ ChildA Box (custom icon = [PointerIcon.Text],
+     * overrideDescendants = FALSE) ⤷ ChildB Box (custom icon = [PointerIcon.Hand],
+     * overrideDescendants = FALSE)
+     *
+     * Expected Output: Regardless of the presence of ChildB Box, ChildA Box's [PointerIcon.Text]
+     * should win for its entire surface area. Once ChildB Box appears, ChildB Box's
+     * [PointerIcon.Hand] should win for its entire surface area. Initially, Parent Box's
+     * [PointerIcon.Crosshair] should win for its entire surface area not covered by ChildA Box.
+     * Once ChildA Box appears, Parent Box should win for its entire surface not covered by either
+     * ChildA or ChildB Boxes.
+     *
+     * Parent Box (output icon = [PointerIcon.Crosshair]) ⤷ Child Box (output icon =
+     * [PointerIcon.Text]) ⤷ Child Box (output icon = [PointerIcon.Hand])
+     */
+    @Ignore("b/271277248 - Remove Ignore annotation once input event bug is fixed")
+    @Test
+    fun dynamicallyAddAndRemoveSibling_hoveredOverAppearingSibling() {
+        val isChildBVisible = mutableStateOf(false)
+
+        rule.setContent {
+            CompositionLocalProvider(LocalPointerIconService provides iconService) {
+                Box(
+                    modifier =
+                        Modifier.requiredSize(150.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+                            .testTag(parentIconTag)
+                            .stylusHoverIcon(desiredParentIcon, overrideDescendants = false)
+                ) {
+                    Column {
+                        Box(
+                            Modifier.requiredSize(50.dp)
+                                .border(BorderStroke(2.dp, SolidColor(Color.Blue)))
+                                .testTag(childIconTag)
+                                .stylusHoverIcon(desiredChildIcon, overrideDescendants = false)
+                        )
+                        if (isChildBVisible.value) {
+                            // Referencing grandchild tag/icon for ChildB in this test
+                            Box(
+                                Modifier.requiredSize(50.dp)
+                                    .offset(y = 100.dp)
+                                    .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+                                    .testTag(grandchildIconTag)
+                                    .stylusHoverIcon(
+                                        desiredGrandchildIcon,
+                                        overrideDescendants = false
+                                    )
+                            )
+                        }
+                    }
+                }
+            }
+        }
+        // Verify initial state of pointer icon
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+        // Hover over corner of Parent Box
+        rule.onNodeWithTag(parentIconTag).performStylusInput { hoverEnter(bottomRight) }
+        // Verify Parent Box has the desired parent icon
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) }
+        // Move to center of ChildA Box
+        rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(center) }
+        // Verify ChildA Box has the desired child icon
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) }
+        // Move to left corner of Parent Box where ChildB will be added
+        rule.onNodeWithTag(parentIconTag).performStylusInput { hoverMoveTo(bottomLeft) }
+        // Dynamically add the ChildB Box
+        rule.runOnIdle {
+            assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon)
+            isChildBVisible.value = true
+        }
+        // Verify the icon is updated to the desired ChildB icon
+        rule.runOnIdle {
+            assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredGrandchildIcon)
+        }
+        // Move to corner of ChildB Box
+        rule.onNodeWithTag(grandchildIconTag).performStylusInput { hoverMoveTo(bottomRight) }
+        // Verify ChildB Box has the desired grandchild icon
+        rule.runOnIdle {
+            assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredGrandchildIcon)
+        }
+        // Move cursor back to the center of ChildA Box
+        rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(center) }
+        // Verify that icon is updated to the desired ChildA icon
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) }
+        // Move cursor back to the location of ChildB
+        rule.onNodeWithTag(grandchildIconTag).performStylusInput { hoverMoveTo(center) }
+        // Dynamically remove the ChildB Box
+        rule.runOnIdle {
+            assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredGrandchildIcon)
+            isChildBVisible.value = false
+        }
+        // Verify the icon updates to the parent icon
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) }
+        // Exit hovering over ChildA Box
+        rule.onNodeWithTag(parentIconTag).performStylusInput { hoverExit() }
+    }
+
+    /**
+     * Setup: The hierarchy for the initial setup of this test is: Parent Box (custom icon =
+     * [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ ChildA Box (custom icon =
+     * [PointerIcon.Text], overrideDescendants = FALSE)
+     *
+     * After hovering over ChildA, the hierarchy of the screen updates to: Parent Box (no custom
+     * icon set) ⤷ ChildA Box (custom icon = [PointerIcon.Text], overrideDescendants = FALSE) ⤷
+     * ChildB Box (custom icon = [PointerIcon.Hand], overrideDescendants = FALSE)
+     *
+     * Expected Output: Regardless of the presence of ChildB Box, ChildA Box's [PointerIcon.Text]
+     * should win for its entire surface area. Once ChildB Box appears, ChildB Box's
+     * [PointerIcon.Hand] should win for its entire surface area. Initially, Parent Box's
+     * [PointerIcon.Crosshair] should win for its entire surface area not covered by ChildA Box.
+     * Once ChildA Box appears, Parent Box should win for its entire surface not covered by either
+     * ChildA or ChildB Boxes.
+     *
+     * Parent Box (output icon = [PointerIcon.Crosshair]) ⤷ Child Box (output icon =
+     * [PointerIcon.Text]) ⤷ Child Box (output icon = [PointerIcon.Hand])
+     */
+    @Ignore("b/271277248 - Remove Ignore annotation once input event bug is fixed")
+    @Test
+    fun dynamicallyAddAndRemoveSibling_notHoveredOverAppearingSibling() {
+        val isChildBVisible = mutableStateOf(false)
+
+        rule.setContent {
+            CompositionLocalProvider(LocalPointerIconService provides iconService) {
+                Box(
+                    modifier =
+                        Modifier.requiredSize(200.dp)
+                            .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+                            .testTag(parentIconTag)
+                            .stylusHoverIcon(desiredParentIcon, overrideDescendants = false)
+                ) {
+                    Column {
+                        Box(
+                            Modifier.requiredSize(50.dp)
+                                .border(BorderStroke(2.dp, SolidColor(Color.Blue)))
+                                .testTag(childIconTag)
+                                .stylusHoverIcon(desiredChildIcon, overrideDescendants = false)
+                        )
+                        if (isChildBVisible.value) {
+                            // Referencing grandchild tag/icon for ChildB in this test
+                            Box(
+                                Modifier.requiredSize(50.dp)
+                                    .offset(y = 100.dp)
+                                    .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+                                    .testTag(grandchildIconTag)
+                                    .stylusHoverIcon(
+                                        desiredGrandchildIcon,
+                                        overrideDescendants = false
+                                    )
+                            )
+                        }
+                    }
+                }
+            }
+        }
+        // Verify initial state of pointer icon
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+        // Hover over corner of Parent Box
+        rule.onNodeWithTag(parentIconTag).performStylusInput { hoverEnter(bottomRight) }
+        // Verify Parent Box has the desired parent icon
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) }
+        // Move to center of ChildA Box
+        rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(center) }
+        // Verify ChildA Box has the desired child icon and dynamically add the ChildB Box
+        rule.runOnIdle {
+            assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon)
+            isChildBVisible.value = true
+        }
+        // Verify the icon stays as the desired child icon since the cursor hasn't moved
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) }
+        // Move to corner of ChildB Box
+        rule.onNodeWithTag(grandchildIconTag).performStylusInput { hoverMoveTo(bottomRight) }
+        // Verify ChildB Box has the desired grandchild icon
+        rule.runOnIdle {
+            assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredGrandchildIcon)
+        }
+        // Move cursor back to the center of ChildA Box
+        rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(center) }
+        // Dynamically remove the ChildB Box
+        rule.runOnIdle {
+            assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon)
+            isChildBVisible.value = false
+        }
+        // Verify the icon stays as the desired child icon since the cursor hasn't moved
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) }
+        // Exit hovering over ChildA Box
+        rule.onNodeWithTag(childIconTag).performStylusInput { hoverExit() }
+    }
+
+    /**
+     * Setup: The hierarchy for this test is setup as: Default Box (no custom icon set) ⤷ Parent Box
+     * (custom icon = [PointerIcon.Crosshair], overrideDescendants = FALSE) ⤷ Child Box (custom icon
+     * = [PointerIcon.Text], overrideDescendants = FALSE) ⤷ Grandchild Box (custom icon =
+     * [PointerIcon.Hand], overrideDescendants = FALSE)
+     *
+     * Expected Output: Grandchild Box's [PointerIcon.Hand] wins for the entire surface area of the
+     * Grandchild Box. Child Box's [PointerIcon.Text] wins for the remaining surface area of the
+     * Child Box not covered by the Grandchild Box. Parent Box’s [PointerIcon.Crosshair] wins for
+     * the remaining surface area not covered by the Child Box. No icon wins for the remaining
+     * surface area of
+     *
+     * Default Box (output icon = null) ⤷ Parent Box (output icon = [PointerIcon.Crosshair]) ⤷ Child
+     * Box (output icon = [PointerIcon.Text]) ⤷ Grandchild Box (output icon = [PointerIcon.Hand])
+     */
+    @Test
+    fun childNotFullyContainedInParent_noOverrideDescendants() {
+        val defaultIconTag = "myDefaultWrapper"
+        rule.setContent {
+            CompositionLocalProvider(LocalPointerIconService provides iconService) {
+                Box(
+                    modifier =
+                        Modifier.fillMaxSize()
+                            .border(BorderStroke(2.dp, SolidColor(Color.Yellow)))
+                            .testTag(defaultIconTag)
+                ) {
+                    Box(
+                        modifier =
+                            Modifier.requiredSize(width = 200.dp, height = 150.dp)
+                                .border(BorderStroke(2.dp, SolidColor(Color.Red)))
+                                .testTag(parentIconTag)
+                                .stylusHoverIcon(desiredParentIcon, overrideDescendants = false)
+                    ) {
+                        Box(
+                            Modifier.requiredSize(width = 150.dp, height = 125.dp)
+                                .border(BorderStroke(2.dp, SolidColor(Color.Black)))
+                                .testTag(childIconTag)
+                                .stylusHoverIcon(desiredChildIcon, overrideDescendants = false)
+                        ) {
+                            Box(
+                                Modifier.requiredSize(width = 300.dp, height = 100.dp)
+                                    .offset(x = 100.dp)
+                                    .border(BorderStroke(2.dp, SolidColor(Color.Blue)))
+                                    .testTag(grandchildIconTag)
+                                    .stylusHoverIcon(
+                                        desiredGrandchildIcon,
+                                        overrideDescendants = false
+                                    )
+                            )
+                        }
+                    }
+                }
+            }
+        }
+
+        // Verify initial state of pointer icon
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+        // Hover over the default wrapping box and verify the cursor is still the default icon
+        rule.onNodeWithTag(defaultIconTag).performStylusInput { hoverEnter(center) }
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+        // Move cursor to the corner of the Grandchild Box and verify it has the desired grandchild
+        // icon
+        rule.onNodeWithTag(grandchildIconTag).performStylusInput { hoverMoveTo(bottomRight) }
+        rule.runOnIdle {
+            assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredGrandchildIcon)
+        }
+        // Move cursor to the center right of the Child Box and verify it still has the desired
+        // grandchild icon
+        rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(centerRight) }
+        rule.runOnIdle {
+            assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredGrandchildIcon)
+        }
+        // Move cursor to the corner of the Child Box and verify it has updated to the desired child
+        // icon
+        rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(bottomRight) }
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) }
+        // Move cursor to the center right of the Parent Box and verify it has the desired
+        // grandchild icon
+        rule.onNodeWithTag(parentIconTag).performStylusInput { hoverMoveTo(centerRight) }
+        rule.runOnIdle {
+            assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredGrandchildIcon)
+        }
+        // Move cursor to the corner of the Parent Box and verify it has the desired parent icon
+        rule.onNodeWithTag(parentIconTag).performStylusInput { hoverMoveTo(bottomRight) }
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredParentIcon) }
+    }
+
+    @Test
+    fun resetPointerIconWhenChildRemoved_parentDoesSetIcon_iconIsHand() {
+        val defaultIconTag = "myDefaultWrapper"
+        var show by mutableStateOf(true)
+        rule.setContent {
+            CompositionLocalProvider(LocalPointerIconService provides iconService) {
+                Box(
+                    modifier =
+                        Modifier.fillMaxSize()
+                            .stylusHoverIcon(PointerIcon.Hand)
+                            .testTag(defaultIconTag)
+                ) {
+                    if (show) {
+                        Box(
+                            modifier = Modifier.stylusHoverIcon(PointerIcon.Text).size(10.dp, 10.dp)
+                        )
+                    }
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            // No stylus movement yet, should be default
+            assertThat(iconService.getStylusHoverIcon()).isNull()
+        }
+
+        rule.onNodeWithTag(defaultIconTag).performStylusInput {
+            hoverMoveTo(Offset(x = 5f, y = 5f))
+        }
+
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(PointerIcon.Text) }
+
+        show = false
+
+        rule.onNodeWithTag(defaultIconTag).performStylusInput {
+            hoverMoveTo(Offset(x = 6f, y = 6f))
+        }
+
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(PointerIcon.Hand) }
+    }
+
+    @Test
+    fun resetPointerIconWhenChildRemoved_parentDoesNotSetIcon_iconIsDefault() {
+        val defaultIconTag = "myDefaultWrapper"
+        var show by mutableStateOf(true)
+        rule.setContent {
+            CompositionLocalProvider(LocalPointerIconService provides iconService) {
+                Box(modifier = Modifier.fillMaxSize().testTag(defaultIconTag)) {
+                    if (show) {
+                        Box(
+                            modifier = Modifier.stylusHoverIcon(PointerIcon.Text).size(10.dp, 10.dp)
+                        )
+                    }
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            // No stylus movement yet, should be default
+            assertThat(iconService.getStylusHoverIcon()).isNull()
+        }
+
+        rule.onNodeWithTag(defaultIconTag).performStylusInput {
+            hoverMoveTo(Offset(x = 5f, y = 5f))
+        }
+
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(PointerIcon.Text) }
+
+        show = false
+
+        rule.onNodeWithTag(defaultIconTag).performStylusInput {
+            hoverMoveTo(Offset(x = 6f, y = 6f))
+        }
+
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+    }
+
+    private fun verifyIconOnHover(tag: String, expectedIcon: PointerIcon?) {
+        // Hover over element with specified tag
+        rule.onNodeWithTag(tag).performStylusInput { hoverEnter(bottomRight) }
+        // Verify the current icon is the expected icon
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(expectedIcon) }
+        // Exit hovering over element
+        rule.onNodeWithTag(tag).performStylusInput { hoverExit() }
+    }
+
+    private fun verifyOverlappingSiblings() {
+        // Verify initial state of pointer icon
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+        // Hover over ChildB (bottom right corner) and verify desired ChildB icon
+        rule.onNodeWithTag(grandchildIconTag).performStylusInput { hoverEnter(bottomRight) }
+        rule.runOnIdle {
+            assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredGrandchildIcon)
+        }
+
+        // Then hover to parent (bottom right corner) and verify default arrow icon
+        rule.onNodeWithTag(parentIconTag).performStylusInput { hoverMoveTo(bottomRight) }
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+
+        // Then hover back over ChildB in area that overlaps with sibling (bottom left corner) and
+        // verify desired ChildB icon
+        rule.onNodeWithTag(grandchildIconTag).performStylusInput { hoverMoveTo(bottomLeft) }
+        rule.runOnIdle {
+            assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredGrandchildIcon)
+        }
+
+        // Then hover to ChildA (bottom left corner) and verify desired ChildA icon (hand)
+        rule.onNodeWithTag(childIconTag).performStylusInput { hoverMoveTo(bottomLeft) }
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isEqualTo(desiredChildIcon) }
+
+        // Then hover over parent (bottom left corner) and verify default arrow icon
+        rule.onNodeWithTag(parentIconTag).performStylusInput { hoverMoveTo(bottomLeft) }
+        rule.runOnIdle { assertThat(iconService.getStylusHoverIcon()).isNull() }
+
+        // Exit hovering
+        rule.onNodeWithTag(parentIconTag).performStylusInput { hoverExit() }
+    }
+}
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 4dd57c0..db16500 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
@@ -34,6 +34,7 @@
 import android.util.LongSparseArray
 import android.util.SparseArray
 import android.view.FocusFinder
+import android.view.InputDevice
 import android.view.KeyEvent as AndroidKeyEvent
 import android.view.MotionEvent
 import android.view.MotionEvent.ACTION_CANCEL
@@ -2444,23 +2445,55 @@
         return null
     }
 
+    @RequiresApi(N)
+    override fun onResolvePointerIcon(
+        event: MotionEvent,
+        pointerIndex: Int
+    ): android.view.PointerIcon {
+        val toolType = event.getToolType(pointerIndex)
+        if (
+            !event.isFromSource(InputDevice.SOURCE_MOUSE) &&
+                event.isFromSource(InputDevice.SOURCE_STYLUS) &&
+                (toolType == MotionEvent.TOOL_TYPE_STYLUS ||
+                    toolType == MotionEvent.TOOL_TYPE_ERASER)
+        ) {
+            val icon = pointerIconService.getStylusHoverIcon()
+            if (icon != null) {
+                return AndroidComposeViewVerificationHelperMethodsN.toAndroidPointerIcon(
+                    context,
+                    icon
+                )
+            }
+        }
+        return super.onResolvePointerIcon(event, pointerIndex)
+    }
+
     override val pointerIconService: PointerIconService =
         object : PointerIconService {
-            private var currentIcon: PointerIcon = PointerIcon.Default
+            private var currentMouseCursorIcon: PointerIcon = PointerIcon.Default
+            private var currentStylusHoverIcon: PointerIcon? = null
 
             override fun getIcon(): PointerIcon {
-                return currentIcon
+                return currentMouseCursorIcon
             }
 
             override fun setIcon(value: PointerIcon?) {
-                currentIcon = value ?: PointerIcon.Default
+                currentMouseCursorIcon = value ?: PointerIcon.Default
                 if (SDK_INT >= N) {
                     AndroidComposeViewVerificationHelperMethodsN.setPointerIcon(
                         this@AndroidComposeView,
-                        currentIcon
+                        currentMouseCursorIcon
                     )
                 }
             }
+
+            override fun getStylusHoverIcon(): PointerIcon? {
+                return currentStylusHoverIcon
+            }
+
+            override fun setStylusHoverIcon(value: PointerIcon?) {
+                currentStylusHoverIcon = value
+            }
         }
 
     /**
@@ -2601,20 +2634,22 @@
 
 @RequiresApi(N)
 private object AndroidComposeViewVerificationHelperMethodsN {
+    @RequiresApi(N)
+    fun toAndroidPointerIcon(context: Context, icon: PointerIcon?): android.view.PointerIcon =
+        when (icon) {
+            is AndroidPointerIcon -> icon.pointerIcon
+            is AndroidPointerIconType -> android.view.PointerIcon.getSystemIcon(context, icon.type)
+            else ->
+                android.view.PointerIcon.getSystemIcon(
+                    context,
+                    android.view.PointerIcon.TYPE_DEFAULT
+                )
+        }
+
     @DoNotInline
     @RequiresApi(N)
     fun setPointerIcon(view: View, icon: PointerIcon?) {
-        val iconToSet =
-            when (icon) {
-                is AndroidPointerIcon -> icon.pointerIcon
-                is AndroidPointerIconType ->
-                    android.view.PointerIcon.getSystemIcon(view.context, icon.type)
-                else ->
-                    android.view.PointerIcon.getSystemIcon(
-                        view.context,
-                        android.view.PointerIcon.TYPE_DEFAULT
-                    )
-            }
+        val iconToSet = toAndroidPointerIcon(view.context, icon)
 
         if (view.pointerIcon != iconToSet) {
             view.pointerIcon = iconToSet
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/input/pointer/PointerIcon.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/input/pointer/PointerIcon.kt
index be06d69..258d4c9 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/input/pointer/PointerIcon.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/input/pointer/PointerIcon.kt
@@ -20,18 +20,24 @@
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.input.pointer.PointerEventPass.Main
 import androidx.compose.ui.node.CompositionLocalConsumerModifierNode
+import androidx.compose.ui.node.DpTouchBoundsExpansion
 import androidx.compose.ui.node.ModifierNodeElement
 import androidx.compose.ui.node.PointerInputModifierNode
+import androidx.compose.ui.node.TouchBoundsExpansion
 import androidx.compose.ui.node.TraversableNode
 import androidx.compose.ui.node.TraversableNode.Companion.TraverseDescendantsAction
 import androidx.compose.ui.node.currentValueOf
+import androidx.compose.ui.node.requireDensity
 import androidx.compose.ui.node.traverseAncestors
 import androidx.compose.ui.node.traverseDescendants
 import androidx.compose.ui.platform.InspectorInfo
 import androidx.compose.ui.platform.LocalPointerIconService
 import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.util.fastAny
 
-/** Represents a pointer icon to use in [Modifier.pointerHoverIcon] */
+/**
+ * Represents a pointer icon to use in [Modifier.pointerHoverIcon] or [Modifier.stylusHoverIcon].
+ */
 @Stable
 interface PointerIcon {
 
@@ -64,6 +70,10 @@
     fun getIcon(): PointerIcon
 
     fun setIcon(value: PointerIcon?)
+
+    fun getStylusHoverIcon(): PointerIcon?
+
+    fun setStylusHoverIcon(value: PointerIcon?)
 }
 
 /**
@@ -72,10 +82,10 @@
  * icon using this modifier.
  *
  * @sample androidx.compose.ui.samples.PointerIconSample
- * @param icon The icon to set
- * @param overrideDescendants when false (by default) descendants are able to set their own pointer
+ * @param icon the icon to set
+ * @param overrideDescendants when false (by default), descendants are able to set their own pointer
  *   icon. If true, no descendants under this parent are eligible to change the icon (it will be set
- *   to the this [the parent's] icon).
+ *   to the this (the parent's) icon).
  */
 @Stable
 fun Modifier.pointerHoverIcon(icon: PointerIcon, overrideDescendants: Boolean = false) =
@@ -112,15 +122,93 @@
 internal class PointerHoverIconModifierNode(
     icon: PointerIcon,
     overrideDescendants: Boolean = false
+) : HoverIconModifierNode(icon, overrideDescendants) {
+    /* Traversal key used with the [TraversableNode] interface to enable all the traversing
+     * functions (ancestor, child, subtree, and subtreeIf).
+     */
+    override val traverseKey = "androidx.compose.ui.input.pointer.PointerHoverIcon"
+
+    override fun isRelevantPointerType(pointerType: PointerType) =
+        pointerType != PointerType.Stylus && pointerType != PointerType.Eraser
+
+    override fun displayIcon(icon: PointerIcon?) {
+        pointerIconService?.setIcon(icon)
+    }
+}
+
+/**
+ * Modifier that lets a developer define a pointer icon to display when a stylus is hovered over the
+ * element. When [overrideDescendants] is set to true, descendants cannot override the pointer icon
+ * using this modifier.
+ *
+ * @param icon the icon to set
+ * @param overrideDescendants when false (by default), descendants are able to set their own pointer
+ *   icon. If true, no descendants under this parent are eligible to change the icon (it will be set
+ *   to the this (the parent's) icon).
+ * @param touchBoundsExpansion amount by which the element's bounds is expanded
+ * @sample androidx.compose.ui.samples.StylusHoverIconSample
+ */
+fun Modifier.stylusHoverIcon(
+    icon: PointerIcon,
+    overrideDescendants: Boolean = false,
+    touchBoundsExpansion: DpTouchBoundsExpansion? = null
+) =
+    this then
+        StylusHoverIconModifierElement(
+            icon = icon,
+            overrideDescendants = overrideDescendants,
+            touchBoundsExpansion = touchBoundsExpansion
+        )
+
+internal data class StylusHoverIconModifierElement(
+    val icon: PointerIcon,
+    val overrideDescendants: Boolean = false,
+    val touchBoundsExpansion: DpTouchBoundsExpansion? = null
+) : ModifierNodeElement<StylusHoverIconModifierNode>() {
+    override fun create() =
+        StylusHoverIconModifierNode(icon, overrideDescendants, touchBoundsExpansion)
+
+    override fun update(node: StylusHoverIconModifierNode) {
+        node.icon = icon
+        node.overrideDescendants = overrideDescendants
+        node.dpTouchBoundsExpansion = touchBoundsExpansion
+    }
+
+    override fun InspectorInfo.inspectableProperties() {
+        name = "stylusHoverIcon"
+        properties["icon"] = icon
+        properties["overrideDescendants"] = overrideDescendants
+        properties["touchBoundsExpansion"] = touchBoundsExpansion
+    }
+}
+
+internal class StylusHoverIconModifierNode(
+    icon: PointerIcon,
+    overrideDescendants: Boolean = false,
+    touchBoundsExpansion: DpTouchBoundsExpansion? = null
+) : HoverIconModifierNode(icon, overrideDescendants, touchBoundsExpansion) {
+    /* Traversal key used with the [TraversableNode] interface to enable all the traversing
+     * functions (ancestor, child, subtree, and subtreeIf).
+     */
+    override val traverseKey = "androidx.compose.ui.input.pointer.StylusHoverIcon"
+
+    override fun isRelevantPointerType(pointerType: PointerType) =
+        pointerType == PointerType.Stylus || pointerType == PointerType.Eraser
+
+    override fun displayIcon(icon: PointerIcon?) {
+        pointerIconService?.setStylusHoverIcon(icon)
+    }
+}
+
+internal abstract class HoverIconModifierNode(
+    icon: PointerIcon,
+    overrideDescendants: Boolean = false,
+    var dpTouchBoundsExpansion: DpTouchBoundsExpansion? = null
 ) :
     Modifier.Node(),
     TraversableNode,
     PointerInputModifierNode,
     CompositionLocalConsumerModifierNode {
-    /* Traversal key used with the [TraversableNode] interface to enable all the traversing
-     * functions (ancestor, child, subtree, and subtreeIf).
-     */
-    override val traverseKey = "androidx.compose.ui.input.pointer.PointerHoverIcon"
 
     var icon = icon
         set(value) {
@@ -151,7 +239,7 @@
         }
 
     // Service used to actually update the icon with the system when needed.
-    private val pointerIconService: PointerIconService?
+    protected val pointerIconService: PointerIconService?
         get() = currentValueOf(LocalPointerIconService)
 
     private var cursorInBoundsOfNode = false
@@ -162,7 +250,7 @@
         pass: PointerEventPass,
         bounds: IntSize
     ) {
-        if (pass == Main) {
+        if (pass == Main && pointerEvent.changes.fastAny { isRelevantPointerType(it.type) }) {
             // Cursor within the surface area of this node's bounds
             if (pointerEvent.type == PointerEventType.Enter) {
                 onEnter()
@@ -199,15 +287,20 @@
         super.onDetach()
     }
 
+    override val touchBoundsExpansion: TouchBoundsExpansion
+        get() =
+            dpTouchBoundsExpansion?.roundToTouchBoundsExpansion(requireDensity())
+                ?: TouchBoundsExpansion.None
+
+    abstract fun isRelevantPointerType(pointerType: PointerType): Boolean
+
+    abstract fun displayIcon(icon: PointerIcon?)
+
     private fun displayIcon() {
         // If there are any ancestor that override this node, we must use that icon. Otherwise, we
         // use the current node's icon
         val iconToUse = findOverridingAncestorNode()?.icon ?: icon
-        pointerIconService?.setIcon(iconToUse)
-    }
-
-    private fun displayDefaultIcon() {
-        pointerIconService?.setIcon(null)
+        displayIcon(iconToUse)
     }
 
     private fun displayIconIfDescendantsDoNotHavePriority() {
@@ -239,22 +332,22 @@
      *
      * Note: Multiple descendant nodes may have `cursorInBoundsOfNode` set to true (for when the
      * cursor enters their bounds). The lowest one is the one that is the correct node for the
-     * mouse (see example for explanation).
+     * pointer (see example for explanation).
      *
      * Example: Parent node contains a child node within its visual border (both are pointer icon
      * nodes).
-     * - Mouse moves over the PARENT node triggers the pointer input handler ENTER event which sets
-     * `cursorInBoundsOfNode` = `true`.
-     * - Mouse moves over CHILD node triggers the pointer input handler ENTER event which sets
+     * - Pointer moves over the PARENT node triggers the pointer input handler ENTER event which
+     * sets `cursorInBoundsOfNode` = `true`.
+     * - Pointer moves over CHILD node triggers the pointer input handler ENTER event which sets
      * `cursorInBoundsOfNode` = `true`.
      *
      * They are both true now because the pointer input event's exit is not triggered (which would
-     * set cursorInBoundsOfNode` = `false`) unless the mouse moves outside the parent node. Because
-     * the child node is contained visually within the parent node, it is not triggered. That is why
-     * we need to get the lowest node with `cursorInBoundsOfNode` set to true.
+     * set cursorInBoundsOfNode` = `false`) unless the pointer moves outside the parent node.
+     * Because the child node is contained visually within the parent node, it is not triggered.
+     * That is why we need to get the lowest node with `cursorInBoundsOfNode` set to true.
      */
-    private fun findDescendantNodeWithCursorInBounds(): PointerHoverIconModifierNode? {
-        var descendantNodeWithCursorInBounds: PointerHoverIconModifierNode? = null
+    private fun findDescendantNodeWithCursorInBounds(): HoverIconModifierNode? {
+        var descendantNodeWithCursorInBounds: HoverIconModifierNode? = null
 
         traverseDescendants {
             var actionForSubtreeOfCurrentNode = TraverseDescendantsAction.ContinueTraversal
@@ -277,54 +370,52 @@
     private fun displayIconFromCurrentNodeOrDescendantsWithCursorInBounds() {
         if (!cursorInBoundsOfNode) return
 
-        var pointerHoverIconModifierNode: PointerHoverIconModifierNode = this
+        var hoverIconModifierNode: HoverIconModifierNode = this
 
         if (!overrideDescendants) {
-            findDescendantNodeWithCursorInBounds()?.let { pointerHoverIconModifierNode = it }
+            findDescendantNodeWithCursorInBounds()?.let { hoverIconModifierNode = it }
         }
 
-        pointerHoverIconModifierNode.displayIcon()
+        hoverIconModifierNode.displayIcon()
     }
 
-    private fun findOverridingAncestorNode(): PointerHoverIconModifierNode? {
-        var pointerHoverIconModifierNode: PointerHoverIconModifierNode? = null
+    private fun findOverridingAncestorNode(): HoverIconModifierNode? {
+        var hoverIconModifierNode: HoverIconModifierNode? = null
 
         traverseAncestors {
             if (it.overrideDescendants && it.cursorInBoundsOfNode) {
-                pointerHoverIconModifierNode = it
+                hoverIconModifierNode = it
             }
             // continue traversal
             true
         }
 
-        return pointerHoverIconModifierNode
+        return hoverIconModifierNode
     }
 
     /*
-     * Sets the icon to either the ancestor where the mouse is in its bounds (or to its
+     * Sets the icon to either the ancestor where the pointer is in its bounds (or to its
      * ancestors if one overrides it) or to a default icon.
      */
     private fun displayIconFromAncestorNodeWithCursorInBoundsOrDefaultIcon() {
-        var pointerHoverIconModifierNode: PointerHoverIconModifierNode? = null
+        var hoverIconModifierNode: HoverIconModifierNode? = null
 
         traverseAncestors {
-            if (pointerHoverIconModifierNode == null && it.cursorInBoundsOfNode) {
-                pointerHoverIconModifierNode = it
+            if (hoverIconModifierNode == null && it.cursorInBoundsOfNode) {
+                hoverIconModifierNode = it
 
                 // We should only assign a node that override its descendants if there was a node
-                // below it where the mouse was in bounds meaning the pointerHoverIconModifierNode
-                // will not be null.
+                // below it where the pointer was in bounds meaning the hoverIconModifierNode will
+                // not be null.
             } else if (
-                pointerHoverIconModifierNode != null &&
-                    it.overrideDescendants &&
-                    it.cursorInBoundsOfNode
+                hoverIconModifierNode != null && it.overrideDescendants && it.cursorInBoundsOfNode
             ) {
-                pointerHoverIconModifierNode = it
+                hoverIconModifierNode = it
             }
 
             // continue traversal
             true
         }
-        pointerHoverIconModifierNode?.displayIcon() ?: displayDefaultIcon()
+        hoverIconModifierNode?.displayIcon() ?: displayIcon(null)
     }
 }
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/PointerInputModifierNode.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/PointerInputModifierNode.kt
index 1e3ba09..ed62306 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/PointerInputModifierNode.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/PointerInputModifierNode.kt
@@ -21,13 +21,8 @@
 import androidx.compose.ui.input.pointer.PointerInputChange
 import androidx.compose.ui.input.pointer.SuspendingPointerInputModifierNode
 import androidx.compose.ui.input.pointer.SuspendingPointerInputModifierNodeImpl
-import androidx.compose.ui.internal.requirePrecondition
 import androidx.compose.ui.layout.LayoutCoordinates
-import androidx.compose.ui.node.TouchBoundsExpansion.Companion.MAX_VALUE
-import androidx.compose.ui.node.TouchBoundsExpansion.Companion.pack
 import androidx.compose.ui.unit.IntSize
-import androidx.compose.ui.unit.LayoutDirection
-import kotlin.jvm.JvmInline
 
 /**
  * A [androidx.compose.ui.Modifier.Node] that receives [PointerInputChange]s, interprets them, and
@@ -133,148 +128,6 @@
         get() = TouchBoundsExpansion.None
 }
 
-/**
- * Describes the expansion of a [PointerInputModifierNode]'s touch bounds along each edges. See
- * [TouchBoundsExpansion] factories and [Absolute] for convenient ways to build
- * [TouchBoundsExpansion].
- *
- * @see PointerInputModifierNode.touchBoundsExpansion
- */
-@JvmInline
-value class TouchBoundsExpansion internal constructor(private val packedValue: Long) {
-    companion object {
-        /**
-         * Creates a [TouchBoundsExpansion] that's unaware of [LayoutDirection]. The `left`, `top`,
-         * `right` and `bottom` represent the amount of pixels that the touch bounds is expanded
-         * along the corresponding edge. Each value must be in the range of 0 to 32767 (inclusive).
-         */
-        fun Absolute(
-            left: Int = 0,
-            top: Int = 0,
-            right: Int = 0,
-            bottom: Int = 0
-        ): TouchBoundsExpansion {
-            requirePrecondition(left in 0..MAX_VALUE) {
-                "Start must be in the range of 0 .. $MAX_VALUE"
-            }
-            requirePrecondition(top in 0..MAX_VALUE) {
-                "Top must be in the range of 0 .. $MAX_VALUE"
-            }
-            requirePrecondition(right in 0..MAX_VALUE) {
-                "End must be in the range of 0 .. $MAX_VALUE"
-            }
-            requirePrecondition(bottom in 0..MAX_VALUE) {
-                "Bottom must be in the range of 0 .. $MAX_VALUE"
-            }
-            return TouchBoundsExpansion(pack(left, top, right, bottom, false))
-        }
-
-        /** Constant that represents no touch bounds expansion. */
-        val None = TouchBoundsExpansion(0)
-
-        internal fun pack(
-            start: Int,
-            top: Int,
-            end: Int,
-            bottom: Int,
-            isLayoutDirectionAware: Boolean
-        ): Long {
-            return trimAndShift(start, 0) or
-                trimAndShift(top, 1) or
-                trimAndShift(end, 2) or
-                trimAndShift(bottom, 3) or
-                if (isLayoutDirectionAware) IS_LAYOUT_DIRECTION_AWARE else 0L
-        }
-
-        private const val MASK = 0x7FFF
-
-        private const val SHIFT = 15
-
-        internal const val MAX_VALUE = MASK
-
-        private const val IS_LAYOUT_DIRECTION_AWARE = 1L shl 63
-
-        // We stored all
-        private fun unpack(packedValue: Long, position: Int): Int =
-            (packedValue shr (position * SHIFT)).toInt() and MASK
-
-        private fun trimAndShift(int: Int, position: Int): Long =
-            (int and MASK).toLong() shl (position * SHIFT)
-    }
-
-    /**
-     * The amount of pixels the touch bounds should be expanded along the start edge. When
-     * [isLayoutDirectionAware] is `true`, it's applied to the left edge when [LayoutDirection] is
-     * [LayoutDirection.Ltr] and vice versa. When [isLayoutDirectionAware] is `false`, it's always
-     * applied to the left edge.
-     */
-    val start: Int
-        get() = unpack(packedValue, 0)
-
-    /** The amount of pixels the touch bounds should be expanded along the top edge. */
-    val top: Int
-        get() = unpack(packedValue, 1)
-
-    /**
-     * The amount of pixels the touch bounds should be expanded along the end edge. When
-     * [isLayoutDirectionAware] is `true`, it's applied to the left edge when [LayoutDirection] is
-     * [LayoutDirection.Ltr] and vice versa. When [isLayoutDirectionAware] is `false`, it's always
-     * applied to the left edge.
-     */
-    val end: Int
-        get() = unpack(packedValue, 2)
-
-    /** The amount of pixels the touch bounds should be expanded along the bottom edge. */
-    val bottom: Int
-        get() = unpack(packedValue, 3)
-
-    /**
-     * Whether this [TouchBoundsExpansion] is aware of [LayoutDirection] or not. See [start] and
-     * [end] for more details.
-     */
-    val isLayoutDirectionAware: Boolean
-        get() = (packedValue and IS_LAYOUT_DIRECTION_AWARE) != 0L
-
-    /** Returns the amount of pixels the touch bounds is expanded towards left. */
-    internal fun computeLeft(layoutDirection: LayoutDirection): Int {
-        return if (!isLayoutDirectionAware || layoutDirection == LayoutDirection.Ltr) {
-            start
-        } else {
-            end
-        }
-    }
-
-    /** Returns the amount of pixels the touch bounds is expanded towards right. */
-    internal fun computeRight(layoutDirection: LayoutDirection): Int {
-        return if (!isLayoutDirectionAware || layoutDirection == LayoutDirection.Ltr) {
-            end
-        } else {
-            start
-        }
-    }
-}
-
-/**
- * Creates a [TouchBoundsExpansion] that's aware of [LayoutDirection]. See
- * [TouchBoundsExpansion.start] and [TouchBoundsExpansion.end] for more details about
- * [LayoutDirection].
- *
- * The `start`, `top`, `end` and `bottom` represent the amount of pixels that the touch bounds is
- * expanded along the corresponding edge. Each value must be in the range of 0 to 32767 (inclusive).
- */
-fun TouchBoundsExpansion(
-    start: Int = 0,
-    top: Int = 0,
-    end: Int = 0,
-    bottom: Int = 0
-): TouchBoundsExpansion {
-    requirePrecondition(start in 0..MAX_VALUE) { "Start must be in the range of 0 .. $MAX_VALUE" }
-    requirePrecondition(top in 0..MAX_VALUE) { "Top must be in the range of 0 .. $MAX_VALUE" }
-    requirePrecondition(end in 0..MAX_VALUE) { "End must be in the range of 0 .. $MAX_VALUE" }
-    requirePrecondition(bottom in 0..MAX_VALUE) { "Bottom must be in the range of 0 .. $MAX_VALUE" }
-    return TouchBoundsExpansion(packedValue = pack(start, top, end, bottom, true))
-}
-
 internal val PointerInputModifierNode.isAttached: Boolean
     get() = node.isAttached
 
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/TouchBoundsExpansion.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/TouchBoundsExpansion.kt
new file mode 100644
index 0000000..14ba4ab
--- /dev/null
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/TouchBoundsExpansion.kt
@@ -0,0 +1,247 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR 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.ui.internal.requirePrecondition
+import androidx.compose.ui.node.DpTouchBoundsExpansion.Companion.Absolute
+import androidx.compose.ui.node.TouchBoundsExpansion.Companion.Absolute
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.dp
+import kotlin.jvm.JvmInline
+
+/**
+ * Describes the expansion of a [PointerInputModifierNode]'s touch bounds along each edges. See
+ * [TouchBoundsExpansion] factories and [Absolute] for convenient ways to build
+ * [TouchBoundsExpansion].
+ *
+ * @see PointerInputModifierNode.touchBoundsExpansion
+ */
+@JvmInline
+value class TouchBoundsExpansion internal constructor(private val packedValue: Long) {
+    companion object {
+        /**
+         * Creates a [TouchBoundsExpansion] that's unaware of [LayoutDirection]. The `left`, `top`,
+         * `right` and `bottom` represent the amount of pixels that the touch bounds is expanded
+         * along the corresponding edge. Each value must be in the range of 0 to 32767 (inclusive).
+         */
+        fun Absolute(
+            left: Int = 0,
+            top: Int = 0,
+            right: Int = 0,
+            bottom: Int = 0
+        ): TouchBoundsExpansion {
+            requirePrecondition(left in 0..MAX_VALUE) {
+                "Start must be in the range of 0 .. $MAX_VALUE"
+            }
+            requirePrecondition(top in 0..MAX_VALUE) {
+                "Top must be in the range of 0 .. $MAX_VALUE"
+            }
+            requirePrecondition(right in 0..MAX_VALUE) {
+                "End must be in the range of 0 .. $MAX_VALUE"
+            }
+            requirePrecondition(bottom in 0..MAX_VALUE) {
+                "Bottom must be in the range of 0 .. $MAX_VALUE"
+            }
+            return TouchBoundsExpansion(pack(left, top, right, bottom, false))
+        }
+
+        /** Constant that represents no touch bounds expansion. */
+        val None = TouchBoundsExpansion(0)
+
+        internal fun pack(
+            start: Int,
+            top: Int,
+            end: Int,
+            bottom: Int,
+            isLayoutDirectionAware: Boolean
+        ): Long {
+            return trimAndShift(start, 0) or
+                trimAndShift(top, 1) or
+                trimAndShift(end, 2) or
+                trimAndShift(bottom, 3) or
+                if (isLayoutDirectionAware) IS_LAYOUT_DIRECTION_AWARE else 0L
+        }
+
+        private const val MASK = 0x7FFF
+
+        private const val SHIFT = 15
+
+        internal const val MAX_VALUE = MASK
+
+        private const val IS_LAYOUT_DIRECTION_AWARE = 1L shl 63
+
+        // We stored all
+        private fun unpack(packedValue: Long, position: Int): Int =
+            (packedValue shr (position * SHIFT)).toInt() and MASK
+
+        private fun trimAndShift(int: Int, position: Int): Long =
+            (int and MASK).toLong() shl (position * SHIFT)
+    }
+
+    /**
+     * The amount of pixels the touch bounds should be expanded along the start edge. When
+     * [isLayoutDirectionAware] is `true`, it's applied to the left edge when [LayoutDirection] is
+     * [LayoutDirection.Ltr] and vice versa. When [isLayoutDirectionAware] is `false`, it's always
+     * applied to the left edge.
+     */
+    val start: Int
+        get() = unpack(packedValue, 0)
+
+    /** The amount of pixels the touch bounds should be expanded along the top edge. */
+    val top: Int
+        get() = unpack(packedValue, 1)
+
+    /**
+     * The amount of pixels the touch bounds should be expanded along the end edge. When
+     * [isLayoutDirectionAware] is `true`, it's applied to the left edge when [LayoutDirection] is
+     * [LayoutDirection.Ltr] and vice versa. When [isLayoutDirectionAware] is `false`, it's always
+     * applied to the left edge.
+     */
+    val end: Int
+        get() = unpack(packedValue, 2)
+
+    /** The amount of pixels the touch bounds should be expanded along the bottom edge. */
+    val bottom: Int
+        get() = unpack(packedValue, 3)
+
+    /**
+     * Whether this [TouchBoundsExpansion] is aware of [LayoutDirection] or not. See [start] and
+     * [end] for more details.
+     */
+    val isLayoutDirectionAware: Boolean
+        get() = (packedValue and IS_LAYOUT_DIRECTION_AWARE) != 0L
+
+    /** Returns the amount of pixels the touch bounds is expanded towards left. */
+    internal fun computeLeft(layoutDirection: LayoutDirection): Int {
+        return if (!isLayoutDirectionAware || layoutDirection == LayoutDirection.Ltr) {
+            start
+        } else {
+            end
+        }
+    }
+
+    /** Returns the amount of pixels the touch bounds is expanded towards right. */
+    internal fun computeRight(layoutDirection: LayoutDirection): Int {
+        return if (!isLayoutDirectionAware || layoutDirection == LayoutDirection.Ltr) {
+            end
+        } else {
+            start
+        }
+    }
+}
+
+/**
+ * Describes the expansion of a [PointerInputModifierNode]'s touch bounds along each edges using
+ * [Dp] for units. See [DpTouchBoundsExpansion] factories and [Absolute] for convenient ways to
+ * build [DpTouchBoundsExpansion].
+ *
+ * @see PointerInputModifierNode.touchBoundsExpansion
+ */
+data class DpTouchBoundsExpansion(
+    val start: Dp,
+    val top: Dp,
+    val end: Dp,
+    val bottom: Dp,
+    val isLayoutDirectionAware: Boolean
+) {
+    init {
+        requirePrecondition(start.value >= 0) { "Left must be non-negative" }
+        requirePrecondition(top.value >= 0) { "Top must be non-negative" }
+        requirePrecondition(end.value >= 0) { "Right must be non-negative" }
+        requirePrecondition(bottom.value >= 0) { "Bottom must be non-negative" }
+    }
+
+    fun roundToTouchBoundsExpansion(density: Density) =
+        with(density) {
+            TouchBoundsExpansion(
+                packedValue =
+                    TouchBoundsExpansion.pack(
+                        start.roundToPx(),
+                        top.roundToPx(),
+                        end.roundToPx(),
+                        bottom.roundToPx(),
+                        isLayoutDirectionAware
+                    )
+            )
+        }
+
+    companion object {
+        /**
+         * Creates a [DpTouchBoundsExpansion] that's unaware of [LayoutDirection]. The `left`,
+         * `top`, `right` and `bottom` represent the distance that the touch bounds is expanded
+         * along the corresponding edge.
+         */
+        fun Absolute(
+            left: Dp = 0.dp,
+            top: Dp = 0.dp,
+            right: Dp = 0.dp,
+            bottom: Dp = 0.dp
+        ): DpTouchBoundsExpansion {
+            return DpTouchBoundsExpansion(left, top, right, bottom, false)
+        }
+    }
+}
+
+/**
+ * Creates a [TouchBoundsExpansion] that's aware of [LayoutDirection]. See
+ * [TouchBoundsExpansion.start] and [TouchBoundsExpansion.end] for more details about
+ * [LayoutDirection].
+ *
+ * The `start`, `top`, `end` and `bottom` represent the amount of pixels that the touch bounds is
+ * expanded along the corresponding edge. Each value must be in the range of 0 to 32767 (inclusive).
+ */
+fun TouchBoundsExpansion(
+    start: Int = 0,
+    top: Int = 0,
+    end: Int = 0,
+    bottom: Int = 0
+): TouchBoundsExpansion {
+    requirePrecondition(start in 0..TouchBoundsExpansion.MAX_VALUE) {
+        "Start must be in the range of 0 .. ${TouchBoundsExpansion.MAX_VALUE}"
+    }
+    requirePrecondition(top in 0..TouchBoundsExpansion.MAX_VALUE) {
+        "Top must be in the range of 0 .. ${TouchBoundsExpansion.MAX_VALUE}"
+    }
+    requirePrecondition(end in 0..TouchBoundsExpansion.MAX_VALUE) {
+        "End must be in the range of 0 .. ${TouchBoundsExpansion.MAX_VALUE}"
+    }
+    requirePrecondition(bottom in 0..TouchBoundsExpansion.MAX_VALUE) {
+        "Bottom must be in the range of 0 .. ${TouchBoundsExpansion.MAX_VALUE}"
+    }
+    return TouchBoundsExpansion(
+        packedValue = TouchBoundsExpansion.pack(start, top, end, bottom, true)
+    )
+}
+
+/**
+ * Creates a [DpTouchBoundsExpansion] that's aware of [LayoutDirection]. See
+ * [DpTouchBoundsExpansion.start] and [DpTouchBoundsExpansion.end] for more details about
+ * [LayoutDirection].
+ *
+ * The `start`, `top`, `end` and `bottom` represent the distance that the touch bounds is expanded
+ * along the corresponding edge.
+ */
+fun DpTouchBoundsExpansion(
+    start: Dp = 0.dp,
+    top: Dp = 0.dp,
+    end: Dp = 0.dp,
+    bottom: Dp = 0.dp
+): DpTouchBoundsExpansion {
+    return DpTouchBoundsExpansion(start, top, end, bottom, true)
+}
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 4bbacd0..84ae0fc 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!!
+        layoutNode.semanticsConfiguration ?: SemanticsConfiguration()
     )
 
 internal fun SemanticsNode(
diff --git a/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/CallSessionLegacyTest.kt b/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/CallSessionLegacyTest.kt
index b1020d5..7705abd 100644
--- a/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/CallSessionLegacyTest.kt
+++ b/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/CallSessionLegacyTest.kt
@@ -19,6 +19,8 @@
 import android.os.Build.VERSION_CODES
 import android.os.ParcelUuid
 import android.telecom.CallAudioState
+import android.telecom.CallAudioState.ROUTE_EARPIECE
+import android.telecom.CallAudioState.ROUTE_WIRED_HEADSET
 import android.telecom.CallEndpoint
 import androidx.annotation.RequiresApi
 import androidx.core.telecom.CallEndpointCompat
@@ -54,6 +56,30 @@
     }
 
     /**
+     * Test the setter for available endpoints removes the earpiece endpoint if the wired headset
+     * endpoint is available
+     */
+    @SmallTest
+    @Test
+    fun testRemovalOfEarpieceEndpointIfWiredEndpointIsPresent() {
+        setUpBackwardsCompatTest()
+        runBlocking {
+            val callSession =
+                initCallSessionLegacy(
+                    coroutineContext,
+                    null,
+                )
+            val supportedRouteMask = ROUTE_EARPIECE or ROUTE_WIRED_HEADSET
+            callSession.setAvailableCallEndpoints(
+                CallAudioState(false, ROUTE_WIRED_HEADSET, supportedRouteMask)
+            )
+            val res = callSession.getAvailableCallEndpointsForSession()
+            assertEquals(1, res.size)
+            assertEquals(res[0].type, CallEndpointCompat.TYPE_WIRED_HEADSET)
+        }
+    }
+
+    /**
      * Verify the [CallEndpoint]s echoed from the platform are re-mapped to the existing
      * [CallEndpointCompat]s the user received with
      * [androidx.core.telecom.CallsManager#getAvailableStartingCallEndpoints()]
@@ -109,7 +135,7 @@
 
             val supportedRouteMask =
                 CallAudioState.ROUTE_BLUETOOTH or
-                    CallAudioState.ROUTE_WIRED_HEADSET or
+                    ROUTE_WIRED_HEADSET or
                     CallAudioState.ROUTE_SPEAKER
 
             val cas = CallAudioState(false, CallAudioState.ROUTE_BLUETOOTH, supportedRouteMask)
diff --git a/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/CallSessionTest.kt b/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/CallSessionTest.kt
index 37a61c5..9146ca1 100644
--- a/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/CallSessionTest.kt
+++ b/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/CallSessionTest.kt
@@ -23,6 +23,7 @@
 import androidx.core.telecom.CallEndpointCompat
 import androidx.core.telecom.internal.CallChannels
 import androidx.core.telecom.internal.CallSession
+import androidx.core.telecom.internal.utils.EndpointUtils
 import androidx.core.telecom.test.utils.BaseTelecomTest
 import androidx.core.telecom.test.utils.TestUtils
 import androidx.core.telecom.util.ExperimentalAppActions
@@ -55,6 +56,24 @@
     private val mEarAndSpeakerEndpoints = listOf(mEarpieceEndpoint, mSpeakerEndpoint)
     private val mEarAndSpeakerAndBtEndpoints =
         listOf(mEarpieceEndpoint, mSpeakerEndpoint, mBluetoothEndpoint)
+    private val mWiredAndEarpieceEndpoints = listOf(mEarpieceEndpoint, mWiredEndpoint)
+
+    /**
+     * Test the helper method that removes the earpiece call endpoint if the wired headset endpoint
+     * is present
+     */
+    @SdkSuppress(minSdkVersion = VERSION_CODES.UPSIDE_DOWN_CAKE)
+    @SmallTest
+    @Test
+    fun testRemovalOfEarpieceEndpointIfWiredEndpointIsPresent() {
+        setUpV2Test()
+        val res =
+            EndpointUtils.maybeRemoveEarpieceIfWiredEndpointPresent(
+                mWiredAndEarpieceEndpoints.toMutableList()
+            )
+        assertEquals(1, res.size)
+        assertEquals(res[0].type, CallEndpointCompat.TYPE_WIRED_HEADSET)
+    }
 
     /**
      * verify maybeDelaySwitchToSpeaker does NOT switch to speakerphone if the bluetooth device
diff --git a/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/utils/BaseTelecomTest.kt b/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/utils/BaseTelecomTest.kt
index dd71456..751bed8 100644
--- a/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/utils/BaseTelecomTest.kt
+++ b/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/utils/BaseTelecomTest.kt
@@ -32,6 +32,7 @@
 import androidx.core.telecom.CallEndpointCompat.Companion.TYPE_BLUETOOTH
 import androidx.core.telecom.CallEndpointCompat.Companion.TYPE_EARPIECE
 import androidx.core.telecom.CallEndpointCompat.Companion.TYPE_SPEAKER
+import androidx.core.telecom.CallEndpointCompat.Companion.TYPE_WIRED_HEADSET
 import androidx.core.telecom.CallsManager
 import androidx.core.telecom.internal.CallEndpointUuidTracker
 import androidx.core.telecom.internal.JetpackConnectionService
@@ -75,6 +76,7 @@
     val mEarpieceEndpoint = CallEndpointCompat("EARPIECE", TYPE_EARPIECE, mBaseSessionId)
     val mSpeakerEndpoint = CallEndpointCompat("SPEAKER", TYPE_SPEAKER, mBaseSessionId)
     val mBluetoothEndpoint = CallEndpointCompat("BLUETOOTH", TYPE_BLUETOOTH, mBaseSessionId)
+    val mWiredEndpoint = CallEndpointCompat("WIRED", TYPE_WIRED_HEADSET, mBaseSessionId)
 
     @Before
     fun setUpBase() {
diff --git a/core/core-telecom/src/main/java/androidx/core/telecom/internal/CallSession.kt b/core/core-telecom/src/main/java/androidx/core/telecom/internal/CallSession.kt
index 1487911..5e3b4f2 100644
--- a/core/core-telecom/src/main/java/androidx/core/telecom/internal/CallSession.kt
+++ b/core/core-telecom/src/main/java/androidx/core/telecom/internal/CallSession.kt
@@ -35,6 +35,7 @@
 import androidx.core.telecom.internal.utils.EndpointUtils.Companion.isBluetoothAvailable
 import androidx.core.telecom.internal.utils.EndpointUtils.Companion.isEarpieceEndpoint
 import androidx.core.telecom.internal.utils.EndpointUtils.Companion.isWiredHeadsetOrBtEndpoint
+import androidx.core.telecom.internal.utils.EndpointUtils.Companion.maybeRemoveEarpieceIfWiredEndpointPresent
 import java.util.function.Consumer
 import kotlin.coroutines.CoroutineContext
 import kotlinx.coroutines.CompletableDeferred
@@ -66,7 +67,7 @@
     private var mPlatformInterface: CallControl? = null
     // cache the latest current and available endpoints
     private var mCurrentCallEndpoint: CallEndpointCompat? = null
-    private var mAvailableEndpoints: List<CallEndpointCompat> = ArrayList()
+    private var mAvailableEndpoints: MutableList<CallEndpointCompat> = mutableListOf()
     private var mLastClientRequestedEndpoint: CallEndpointCompat? = null
     // use CompletableDeferred objects to signal when all the endpoint values have initially
     // been received from the platform.
@@ -112,7 +113,7 @@
 
     @VisibleForTesting
     fun setAvailableCallEndpoints(endpoints: List<CallEndpointCompat>) {
-        mAvailableEndpoints = endpoints
+        mAvailableEndpoints = endpoints.toMutableList()
     }
 
     /**
@@ -165,7 +166,9 @@
     override fun onAvailableCallEndpointsChanged(endpoints: List<CallEndpoint>) {
         // due to the [CallsManager#getAvailableStartingCallEndpoints] API, endpoints the client
         // has can be different from the ones coming from the platform. Hence, a remapping is needed
-        mAvailableEndpoints = endpoints.map { toRemappedCallEndpointCompat(it) }.sorted()
+        mAvailableEndpoints =
+            endpoints.map { toRemappedCallEndpointCompat(it) }.sorted().toMutableList()
+        maybeRemoveEarpieceIfWiredEndpointPresent(mAvailableEndpoints)
         // send the current call endpoints out to the client
         callChannels.availableEndpointChannel.trySend(mAvailableEndpoints).getOrThrow()
         Log.i(TAG, "onAvailableCallEndpointsChanged: endpoints=[$endpoints]")
diff --git a/core/core-telecom/src/main/java/androidx/core/telecom/internal/CallSessionLegacy.kt b/core/core-telecom/src/main/java/androidx/core/telecom/internal/CallSessionLegacy.kt
index 2c6c17f..4bc7b19 100644
--- a/core/core-telecom/src/main/java/androidx/core/telecom/internal/CallSessionLegacy.kt
+++ b/core/core-telecom/src/main/java/androidx/core/telecom/internal/CallSessionLegacy.kt
@@ -41,6 +41,7 @@
 import androidx.core.telecom.internal.utils.EndpointUtils.Companion.isBluetoothAvailable
 import androidx.core.telecom.internal.utils.EndpointUtils.Companion.isEarpieceEndpoint
 import androidx.core.telecom.internal.utils.EndpointUtils.Companion.isWiredHeadsetOrBtEndpoint
+import androidx.core.telecom.internal.utils.EndpointUtils.Companion.maybeRemoveEarpieceIfWiredEndpointPresent
 import androidx.core.telecom.internal.utils.EndpointUtils.Companion.toCallEndpointCompat
 import androidx.core.telecom.internal.utils.EndpointUtils.Companion.toCallEndpointsCompat
 import androidx.core.telecom.internal.utils.Utils.Companion.isBuildAtLeastP
@@ -76,7 +77,7 @@
     private var mAlreadyRequestedSpeaker: Boolean = false
     private var mPreviousCallEndpoint: CallEndpointCompat? = null
     private var mCurrentCallEndpoint: CallEndpointCompat? = null
-    private var mAvailableCallEndpoints: List<CallEndpointCompat>? = null
+    private var mAvailableCallEndpoints: MutableList<CallEndpointCompat> = mutableListOf()
     private var mLastClientRequestedEndpoint: CallEndpointCompat? = null
     private val mCallSessionLegacyId: Int = CallEndpointUuidTracker.startSession()
     private var mGlobalMuteStateReceiver: MuteStateReceiver? = null
@@ -100,6 +101,11 @@
         return mCurrentCallEndpoint
     }
 
+    @VisibleForTesting
+    internal fun getAvailableCallEndpointsForSession(): MutableList<CallEndpointCompat> {
+        return mAvailableCallEndpoints
+    }
+
     companion object {
         private const val WAIT_FOR_BT_TO_CONNECT_TIMEOUT: Long = 1000L
         // TODO:: b/369153472 , remove delay and instead wait until onCallEndpointChanged
@@ -173,12 +179,14 @@
         callChannels.currentEndpointChannel.trySend(mCurrentCallEndpoint!!).getOrThrow()
     }
 
-    private fun setAvailableCallEndpoints(state: CallAudioState) {
+    @VisibleForTesting
+    internal fun setAvailableCallEndpoints(state: CallAudioState) {
         val availableEndpoints =
             toCallEndpointsCompat(state, mCallSessionLegacyId)
                 .map { toRemappedCallEndpointCompat(it) }
                 .sorted()
-        mAvailableCallEndpoints = availableEndpoints
+        mAvailableCallEndpoints = availableEndpoints.toMutableList()
+        maybeRemoveEarpieceIfWiredEndpointPresent(mAvailableCallEndpoints)
         callChannels.availableEndpointChannel.trySend(availableEndpoints).getOrThrow()
     }
 
@@ -193,16 +201,14 @@
 
             // On the first call audio state change, determine if the platform started on the
             // correct audio route.  Otherwise, request an endpoint switch.
-            if (mAvailableCallEndpoints != null) {
-                switchStartingCallEndpointOnCallStart(mAvailableCallEndpoints!!)
-            }
+            switchStartingCallEndpointOnCallStart(mAvailableCallEndpoints)
             // In the event the users headset disconnects, they will likely want to continue the
             // call via the speakerphone
-            if (mCurrentCallEndpoint != null && mAvailableCallEndpoints != null) {
+            if (mCurrentCallEndpoint != null) {
                 maybeSwitchToSpeakerOnHeadsetDisconnect(
                     mCurrentCallEndpoint!!,
                     mPreviousCallEndpoint,
-                    mAvailableCallEndpoints!!,
+                    mAvailableCallEndpoints,
                 )
             }
         } catch (e: Exception) {
diff --git a/core/core-telecom/src/main/java/androidx/core/telecom/internal/utils/EndpointUtils.kt b/core/core-telecom/src/main/java/androidx/core/telecom/internal/utils/EndpointUtils.kt
index f868af5..4c6f2cd 100644
--- a/core/core-telecom/src/main/java/androidx/core/telecom/internal/utils/EndpointUtils.kt
+++ b/core/core-telecom/src/main/java/androidx/core/telecom/internal/utils/EndpointUtils.kt
@@ -39,6 +39,15 @@
         const val BLUETOOTH_DEVICE_DEFAULT_NAME = "Bluetooth Device"
         private val TAG: String = EndpointUtils::class.java.simpleName.toString()
 
+        internal fun maybeRemoveEarpieceIfWiredEndpointPresent(
+            endpoints: MutableList<CallEndpointCompat>
+        ): MutableList<CallEndpointCompat> {
+            if (endpoints.any { it.type == CallEndpointCompat.TYPE_WIRED_HEADSET }) {
+                endpoints.removeIf { it.type == CallEndpointCompat.TYPE_EARPIECE }
+            }
+            return endpoints
+        }
+
         /** [AudioDeviceInfo]s to [CallEndpointCompat]s */
         fun getEndpointsFromAudioDeviceInfo(
             c: Context,
@@ -49,10 +58,14 @@
                 return listOf()
             }
             val endpoints: MutableList<CallEndpointCompat> = mutableListOf()
+            var foundWiredHeadset = false
             val omittedDevices = StringBuilder("omitting devices =[")
             adiArr.toList().forEach { audioDeviceInfo ->
                 val endpoint = getEndpointFromAudioDeviceInfo(c, flowId, audioDeviceInfo)
                 if (endpoint.type != CallEndpointCompat.TYPE_UNKNOWN) {
+                    if (endpoint.type == CallEndpointCompat.TYPE_WIRED_HEADSET) {
+                        foundWiredHeadset = true
+                    }
                     endpoints.add(endpoint)
                 } else {
                     omittedDevices.append(
@@ -63,6 +76,9 @@
             }
             omittedDevices.append("]")
             Log.i(TAG, omittedDevices.toString())
+            if (foundWiredHeadset) {
+                endpoints.removeIf { it.type == CallEndpointCompat.TYPE_EARPIECE }
+            }
             // Sort by endpoint type.  The first element has the highest priority!
             endpoints.sort()
             return endpoints
diff --git a/core/core/src/androidTest/java/androidx/core/view/ViewGroupCompatTest.java b/core/core/src/androidTest/java/androidx/core/view/ViewGroupCompatTest.java
index 3665e9c..4009846 100644
--- a/core/core/src/androidTest/java/androidx/core/view/ViewGroupCompatTest.java
+++ b/core/core/src/androidTest/java/androidx/core/view/ViewGroupCompatTest.java
@@ -16,6 +16,8 @@
 
 package androidx.core.view;
 
+import static androidx.core.view.WindowInsetsAnimationCompat.Callback.DISPATCH_MODE_STOP;
+
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertNull;
@@ -32,6 +34,7 @@
 import android.view.WindowInsets;
 import android.widget.FrameLayout;
 
+import androidx.annotation.NonNull;
 import androidx.core.graphics.Insets;
 import androidx.core.test.R;
 import androidx.test.ext.junit.runners.AndroidJUnit4;
@@ -42,6 +45,8 @@
 import org.junit.Test;
 import org.junit.runner.RunWith;
 
+import java.util.List;
+
 
 @RunWith(AndroidJUnit4.class)
 @MediumTest
@@ -125,6 +130,20 @@
                 }
             };
 
+            ViewGroupCompat.installCompatInsetsDispatch(mViewGroup);
+
+            // This is to test if ViewCompat#setWindowInsetsAnimationCallback would overwrite the
+            // View.OnApplyWindowInsetsListener set by ViewGroupCompat#installCompatInsetsDispatch
+            ViewCompat.setWindowInsetsAnimationCallback(mViewGroup,
+                    new WindowInsetsAnimationCompat.Callback(DISPATCH_MODE_STOP) {
+                        @NonNull
+                        @Override
+                        public WindowInsetsCompat onProgress(@NonNull WindowInsetsCompat insets,
+                                @NonNull List<WindowInsetsAnimationCompat> runningAnimations) {
+                            return insets;
+                        }
+                    });
+
             // mViewGroup --+-- viewA --+-- viewA1
             //              |           |
             //              |           +-- viewA2
@@ -170,9 +189,7 @@
                 return insets;
             });
 
-            ViewGroupCompat.installCompatInsetsDispatch(mViewGroup);
-
-            ViewCompat.dispatchApplyWindowInsets(mViewGroup, new InsetsObtainer(context).obtain(
+            mViewGroup.dispatchApplyWindowInsets(new InsetsObtainer(context).obtain(
                     10, 10, 10, 10));
 
             // viewA consumes the insets, so its child views (viewA1 and viewA2) shouldn't receive
@@ -212,12 +229,12 @@
             return insets;
         }
 
-        public WindowInsetsCompat obtain(int left, int top, int right, int bottom) {
+        public WindowInsets obtain(int left, int top, int right, int bottom) {
             // Before API 28, there is no other way to create an unconsumed WindowInsets instance.
             // Calling fitSystemWindows here makes the framework dispatch a WindowInsets with the
             // given system window insets to this view.
             fitSystemWindows(new Rect(left, top, right, bottom));
-            return WindowInsetsCompat.toWindowInsetsCompat(mWindowInsets);
+            return mWindowInsets;
         }
     }
 
diff --git a/core/core/src/main/java/androidx/core/view/WindowInsetsAnimationCompat.java b/core/core/src/main/java/androidx/core/view/WindowInsetsAnimationCompat.java
index aff92af..9b57ee8 100644
--- a/core/core/src/main/java/androidx/core/view/WindowInsetsAnimationCompat.java
+++ b/core/core/src/main/java/androidx/core/view/WindowInsetsAnimationCompat.java
@@ -614,27 +614,19 @@
 
         static void setCallback(@NonNull final View view,
                 @Nullable final Callback callback) {
+            final View.OnApplyWindowInsetsListener proxyListener = callback != null
+                    ? createProxyListener(view, callback)
+                    : null;
+            view.setTag(R.id.tag_window_insets_animation_callback, proxyListener);
 
-            Object userListener = view.getTag(R.id.tag_on_apply_window_listener);
-            if (callback == null) {
-                view.setTag(R.id.tag_window_insets_animation_callback, null);
-                if (userListener == null) {
-                    // If no user defined listener is set, that means our listener is the one set.
-                    // Make sure to remove it.
-                    view.setOnApplyWindowInsetsListener(null);
-                }
-            } else {
-                View.OnApplyWindowInsetsListener proxyListener =
-                        createProxyListener(view, callback);
-                view.setTag(R.id.tag_window_insets_animation_callback, proxyListener);
-
-                // We rely on OnApplyWindowInsetsListener, but one might already be set by the
-                // application, so we only register it on the view if none is set yet.
-                // If one is set using ViewCompat.setOnApplyWindowInsetsListener,
-                // this Callback will be called by the exiting listener.
-                if (userListener == null) {
-                    view.setOnApplyWindowInsetsListener(proxyListener);
-                }
+            // We rely on View.OnApplyWindowInsetsListener, but one might already be set by the
+            // library, so we only register it on the view if none is set yet.
+            // If any of them is set via ViewGroupCompat#installCompatInsetsDispatch or
+            // ViewCompat.setOnApplyWindowInsetsListener, this Callback will be called by their
+            // listener.
+            if (view.getTag(R.id.tag_compat_insets_dispatch) == null
+                    && view.getTag(R.id.tag_on_apply_window_listener) == null) {
+                view.setOnApplyWindowInsetsListener(proxyListener);
             }
         }
 
diff --git a/development/validateRefactor.sh b/development/validateRefactor.sh
index 1fb6cd2..62e907e 100755
--- a/development/validateRefactor.sh
+++ b/development/validateRefactor.sh
@@ -186,5 +186,5 @@
 echo
 echo diffing results
 # This script performs the diff, and filters out known issues and non-issues with baselines
-python development/validateRefactorHelper.py "$passThruArgs"
+python3 development/validateRefactorHelper.py "$passThruArgs"
 echo end of difference
diff --git a/development/validateRefactorHelper.py b/development/validateRefactorHelper.py
index 39bb38d..ba33c5e 100644
--- a/development/validateRefactorHelper.py
+++ b/development/validateRefactorHelper.py
@@ -25,18 +25,26 @@
   python validateRefactorHelper.py agpKmp
 """
 import itertools
+import logging
+import queue
 import re
 import shutil
 import subprocess
 import sys
+import threading
 from typing import Dict
 
+logger = logging.getLogger(__name__)
+logging.basicConfig(level=logging.INFO)
+
 # noto-emoji-compat `bundleinside`s an externally-built with-timestamps jar.
 # classes.jar is compared using `diffuse` instead of unzipping and diffing class files.
 bannedJars = ["-x", "noto-emoji-compat-java.jar", "-x", "classes.jar"]
-# java and json aren"t for unzipping, but the poor exclude-everything-but-jars regex doesn't
+# java and json aren't for unzipping, but the poor exclude-everything-but-jars regex doesn't
 # exclude them. Same for exclude-non-klib and .kt/.knm
-areNotZips = ["-x", r"**\.java", "-x", r"**\.json", "-x", r"**\.kt", "-x", r"**\.knm"]
+areNotZips = ["-x", r"**\.java", "-x", r"**\.json", "-x", r"**\.kt", "-x", r"**\.knm", "-x", r"**\.xml",
+              "-x", r"**\.sha1", "-x", r"**\.sha256", "-x", r"**\.sha512", "-x", r"**\.md5",
+              "-x", r"**\.module", "-x", r"**\.pom"]
 # keeps making my regexes fall over :(
 hasNoExtension = ["-x", "manifest", "-x", "module"]
 doNotUnzip = bannedJars + areNotZips + hasNoExtension
@@ -45,36 +53,62 @@
     return popenAndReturn(["diff", "-r", "../../out-old/dist/", "../../out-new/dist/"] + excludes)
 
 def popenAndReturn(args):
+    logger.debug(" ".join(args))
     return subprocess.Popen(args, stdout=subprocess.PIPE).stdout.read().decode("utf-8").split("\n")
 
-# Finds and unzips all files with old/new diff that _do not_ match the argument regex.
-def findFilesMatchingWithDiffAndUnzip(regexThatMatchesEverythingElse):
+# Finds and unzips all files with old/new diff that _do not_ match the argument regexes.
+# Because the `diff` command doesn't have an --include, only --exclude.
+def findFilesNotMatchingWithDiffAndUnzip(*regexesToExclude):
+    excludeArgs = list(itertools.chain.from_iterable(zip(["-x"]*9, regexesToExclude)))
     # Exclude all things that are *not* the desired zip type
-    # (because diff doesn"t have an --include, only --exclude).
-    zipsWithDiffs = diff(["-q", "-x", regexThatMatchesEverythingElse] + doNotUnzip)
+    zipsWithDiffs = diff(["-q"] + excludeArgs + doNotUnzip)
     # Take only changed files, not new/deleted ones (the diff there is obvious)
     zipsWithDiffs = filter(lambda s: s.startswith("Files"), zipsWithDiffs)
     zipsWithDiffs = map(lambda s: s.split()[1:4:2], zipsWithDiffs)
-    zipsWithDiffs = list(itertools.chain.from_iterable(zipsWithDiffs))  # flatten
+    zipsWithDiffs = itertools.chain.from_iterable(zipsWithDiffs)  # flatten
+    workQueueOfZips = queue.LifoQueue()
+    for it in zipsWithDiffs: workQueueOfZips.put(it)
     # And unzip them
-    for filename in zipsWithDiffs:
-        print("unzipping " + filename)
-        shutil.rmtree(filename+".unzipped/")
-        subprocess.Popen(["unzip", "-qq", "-o", filename, "-d", filename+".unzipped/"])
+    # If we spam unzip commands without a break, the unzips start failing.
+    # if we wait after every Popen, the script runs very slowly
+    # So create a pool of 10 unzip workers to consume from zipsWithDiffs
+    numWorkers = 10
+    workers = []
+    for i in range(min(numWorkers, workQueueOfZips.qsize())):
+        w = threading.Thread(target=unzipWorker, args=(workQueueOfZips,))
+        w.start()
+        workers.append(w)
+    for w in workers: w.join()
 
-diffusePath = "../../prebuilts/build-tools/diffuse-0.3.0/bin/diffuse"
+def unzipWorker(workQueueOfZips):
+    while not workQueueOfZips.empty():
+        zipFilePath = workQueueOfZips.get(0)
+        try: shutil.rmtree(zipFilePath+".unzipped/")
+        except FileNotFoundError: pass
+        logger.debug("unzipping " + zipFilePath)
+        subprocess.Popen(["unzip", "-qq", "-o", zipFilePath, "-d", zipFilePath+".unzipped/"]).wait()
 
+diffusePath = "../../prebuilts/build-tools/diffuse/diffuse-0.3.0/bin/diffuser"
+
+diffuseIsPresent = True
 def compareWithDiffuse(listOfJars):
+    global diffuseIsPresent
+    if not diffuseIsPresent: return
     for jarPath in list(filter(None, listOfJars)):
-        print("jarpath: " + jarPath)
+        logger.info("jarpath: " + jarPath)
         newJarPath = jarPath.replace("out-old", "out-new")
-        print(popenAndReturn([diffusePath, "diff", "--jar", jarPath, newJarPath]))
+        try: logger.info("\n".join(popenAndReturn([diffusePath, "diff", "--jar", jarPath, newJarPath])))
+        except FileNotFoundError:
+            logger.warning(f"https://github.com/JakeWharton/diffuse is not present on disk in expected location"
+                  f" ${diffusePath}. You can install it.")
+            diffuseIsPresent = False
+            return
 
 # We might care to know whether .sha1 or .md5 files have changed, but changes in those files will
 # always be accompanied by more meaningful changes in other files, so we don"t need to show changes
 # in .sha1 or .md5 files, or in .module files showing the hashes of other files, or config names.
-excludedHashes = ["-x", "*.md5*", "-x", "*.sha**", "-I", "        \"md5\".*", \
-  "-I", "        \"sha.*", "-I", "        \"size\".*", "-I", "      \"name\".*"]
+excludedHashes = ["-x", "*.md5*", "-x", "*.sha**", "-I", "        \"md5\".*",
+"-I", "        \"sha.*", "-I", "        \"size\".*", "-I", "      \"name\".*"]
 # Don"t care about maven-metadata files because they have timestamps in them.
 # temporarily ignore knm files
 # If changes to the dackka args json are meaningful, they will affect the generated docs and show diff there
@@ -168,7 +202,17 @@
 re.compile(r"""[0-9]+c[0-9]+
 <       <artifactId>kotlinx-coroutines-core-jvm</artifactId>
 ---
->       <artifactId>kotlinx-coroutines-core</artifactId>""")
+>       <artifactId>kotlinx-coroutines-core</artifactId>"""),
+# AGP-KMP adds a new default sourceSet, which in itself doesn't do anything
+re.compile(r"""(11,17d10|12,18d11)
+<       "name": "androidRelease",
+<       "dependencies": \[
+<         "commonMain"
+<       \],
+<       "analysisPlatform": "jvm"
+<     \},
+<     \{
+"""),
 ]
 
 baselines = []
@@ -177,12 +221,13 @@
 arguments = sys.argv[1:]
 if "agpKmp" in arguments:
     arguments.remove("agpKmp"); baselines += ["agpKmp"]
-    print("IGNORING DIFF FOR agpKmp")
+    logger.info("IGNORING DIFF FOR agpKmp")
     baselinedChanges += baselinedChangesForAgpKmp
     unskippableBaselinedChanges += unskippableBaselinedChangesForAgpKmp
+    excludedFiles += ["-x", r"**\.aar.unzipped/res"]  # agp-kmp may add this empty
 if arguments:
-    print("invalid argument(s) for validateRefactorHelper: " + ", ".join(arguments))
-    print("currently recognized arguments: agpKmp")
+    logger.error("invalid argument(s) for validateRefactorHelper: " + ", ".join(arguments))
+    logger.error("currently recognized arguments: agpKmp")
     exit()
 
 # interleave "-I" to tell diffutils to 'I'gnore the baselined lines
@@ -217,7 +262,7 @@
     if len(keptContentLines) == 0: return ""
     # return value is based on `lines` because we want to retain ordering we may have lost during processing
     # We want to keep keptContentLines, and formatting lines like "---" and the header (which don't start with <>).
-    return "\n".join([line for line in lines if line != "" and ((not line[0] in "<>") or line in keptContentLines)])
+    return "\n".join([line for line in lines if (line != "") and ((not line[0] in "<>") or line in keptContentLines)])
 
 # The output of diff entails multiple files, and multiple segments per file
 # This function removes baselined changes from the entire diff output
@@ -245,18 +290,24 @@
         if len(result) != 0: processedPerFileDiffs += [newFilePath + "\n" + "\n".join(result)]
     return "\ndiff ".join(processedPerFileDiffs)
 
+# We unzip multiple times in this order because e.g. zips can contain apks.
 # Find all zip files with a diff, e.g. the tip-of-tree-repository file, and maybe the docs zip
-# findFilesMatchingWithDiffAndUnzip(r"**\.[^z][a-z]*")
+logger.info("UNZIPPING ZIP FILES");
+findFilesNotMatchingWithDiffAndUnzip(r"**\.[^z][a-z]*")
 # Find all aar and apk files with a diff. The proper regex would be `.*\..*[^akpr]+.*`, but it
 # doesn"t work in difftools exclude's very limited regex syntax.
-findFilesMatchingWithDiffAndUnzip(r"**\.[^a][a-z]*")
+logger.info("UNZIPPING AAR/APK FILES");
+findFilesNotMatchingWithDiffAndUnzip(r"**\.zip", r"**\.jar", r"**\.klib")
 # Find all jars and klibs and unzip them (comes after because they could be inside aars/apks).
-findFilesMatchingWithDiffAndUnzip(r"**\.[^j][a-z]*")
-findFilesMatchingWithDiffAndUnzip(r"**\.[^k][a-z]*")
+logger.info("UNZIPPING JAR/KLIB FILES");
+findFilesNotMatchingWithDiffAndUnzip(r"**\.zip", r"**\.aar", r"**\.apk")
+
 # now find all diffs in classes.jars
-classesJarsWithDiffs = popenAndReturn(["find", "../../out-old/dist/", "-name", "classes.jar"])
-print("classes.jar s: " + str(classesJarsWithDiffs))
-compareWithDiffuse(classesJarsWithDiffs)
+# TODO(375636734) Disabled because this tracks internal methods' diffs
+# classesJarsWithDiffs = popenAndReturn(["find", "../../out-old/dist/", "-name", "classes.jar"])
+logger.info("classes.jar s: " + str(classesJarsWithDiffs))
+# compareWithDiffuse(classesJarsWithDiffs)
+
 # Now find all diffs in non-zipped files
 finalExcludes = excludedHashes + excludedFiles + excludedZips + baselinedChangesArgs
 finalDiff = "\n".join(diff(finalExcludes))
diff --git a/docs-public/build.gradle b/docs-public/build.gradle
index 4791417..5498f46 100644
--- a/docs-public/build.gradle
+++ b/docs-public/build.gradle
@@ -238,32 +238,32 @@
     docs("androidx.media2:media2-widget:1.3.0")
     docs("androidx.media:media:1.7.0")
     // androidx.media3 is not hosted in androidx
-    docsWithoutApiSince("androidx.media3:media3-cast:1.5.0-beta01")
-    docsWithoutApiSince("androidx.media3:media3-common:1.5.0-beta01")
-    docsWithoutApiSince("androidx.media3:media3-common-ktx:1.5.0-beta01")
-    docsWithoutApiSince("androidx.media3:media3-container:1.5.0-beta01")
-    docsWithoutApiSince("androidx.media3:media3-database:1.5.0-beta01")
-    docsWithoutApiSince("androidx.media3:media3-datasource:1.5.0-beta01")
-    docsWithoutApiSince("androidx.media3:media3-datasource-cronet:1.5.0-beta01")
-    docsWithoutApiSince("androidx.media3:media3-datasource-okhttp:1.5.0-beta01")
-    docsWithoutApiSince("androidx.media3:media3-datasource-rtmp:1.5.0-beta01")
-    docsWithoutApiSince("androidx.media3:media3-decoder:1.5.0-beta01")
-    docsWithoutApiSince("androidx.media3:media3-effect:1.5.0-beta01")
-    docsWithoutApiSince("androidx.media3:media3-exoplayer:1.5.0-beta01")
-    docsWithoutApiSince("androidx.media3:media3-exoplayer-dash:1.5.0-beta01")
-    docsWithoutApiSince("androidx.media3:media3-exoplayer-hls:1.5.0-beta01")
-    docsWithoutApiSince("androidx.media3:media3-exoplayer-ima:1.5.0-beta01")
-    docsWithoutApiSince("androidx.media3:media3-exoplayer-rtsp:1.5.0-beta01")
-    docsWithoutApiSince("androidx.media3:media3-exoplayer-smoothstreaming:1.5.0-beta01")
-    docsWithoutApiSince("androidx.media3:media3-exoplayer-workmanager:1.5.0-beta01")
-    docsWithoutApiSince("androidx.media3:media3-extractor:1.5.0-beta01")
-    docsWithoutApiSince("androidx.media3:media3-muxer:1.5.0-beta01")
-    docsWithoutApiSince("androidx.media3:media3-session:1.5.0-beta01")
-    docsWithoutApiSince("androidx.media3:media3-test-utils:1.5.0-beta01")
-    docsWithoutApiSince("androidx.media3:media3-test-utils-robolectric:1.5.0-beta01")
-    docsWithoutApiSince("androidx.media3:media3-transformer:1.5.0-beta01")
-    docsWithoutApiSince("androidx.media3:media3-ui:1.5.0-beta01")
-    docsWithoutApiSince("androidx.media3:media3-ui-leanback:1.5.0-beta01")
+    docsWithoutApiSince("androidx.media3:media3-cast:1.5.0-rc01")
+    docsWithoutApiSince("androidx.media3:media3-common:1.5.0-rc01")
+    docsWithoutApiSince("androidx.media3:media3-common-ktx:1.5.0-rc01")
+    docsWithoutApiSince("androidx.media3:media3-container:1.5.0-rc01")
+    docsWithoutApiSince("androidx.media3:media3-database:1.5.0-rc01")
+    docsWithoutApiSince("androidx.media3:media3-datasource:1.5.0-rc01")
+    docsWithoutApiSince("androidx.media3:media3-datasource-cronet:1.5.0-rc01")
+    docsWithoutApiSince("androidx.media3:media3-datasource-okhttp:1.5.0-rc01")
+    docsWithoutApiSince("androidx.media3:media3-datasource-rtmp:1.5.0-rc01")
+    docsWithoutApiSince("androidx.media3:media3-decoder:1.5.0-rc01")
+    docsWithoutApiSince("androidx.media3:media3-effect:1.5.0-rc01")
+    docsWithoutApiSince("androidx.media3:media3-exoplayer:1.5.0-rc01")
+    docsWithoutApiSince("androidx.media3:media3-exoplayer-dash:1.5.0-rc01")
+    docsWithoutApiSince("androidx.media3:media3-exoplayer-hls:1.5.0-rc01")
+    docsWithoutApiSince("androidx.media3:media3-exoplayer-ima:1.5.0-rc01")
+    docsWithoutApiSince("androidx.media3:media3-exoplayer-rtsp:1.5.0-rc01")
+    docsWithoutApiSince("androidx.media3:media3-exoplayer-smoothstreaming:1.5.0-rc01")
+    docsWithoutApiSince("androidx.media3:media3-exoplayer-workmanager:1.5.0-rc01")
+    docsWithoutApiSince("androidx.media3:media3-extractor:1.5.0-rc01")
+    docsWithoutApiSince("androidx.media3:media3-muxer:1.5.0-rc01")
+    docsWithoutApiSince("androidx.media3:media3-session:1.5.0-rc01")
+    docsWithoutApiSince("androidx.media3:media3-test-utils:1.5.0-rc01")
+    docsWithoutApiSince("androidx.media3:media3-test-utils-robolectric:1.5.0-rc01")
+    docsWithoutApiSince("androidx.media3:media3-transformer:1.5.0-rc01")
+    docsWithoutApiSince("androidx.media3:media3-ui:1.5.0-rc01")
+    docsWithoutApiSince("androidx.media3:media3-ui-leanback:1.5.0-rc01")
     docs("androidx.mediarouter:mediarouter:1.7.0")
     docs("androidx.mediarouter:mediarouter-testing:1.7.0")
     docs("androidx.metrics:metrics-performance:1.0.0-beta01")
diff --git a/docs-tip-of-tree/build.gradle b/docs-tip-of-tree/build.gradle
index 1ddf116..2fb1ba5 100644
--- a/docs-tip-of-tree/build.gradle
+++ b/docs-tip-of-tree/build.gradle
@@ -240,6 +240,7 @@
     kmpDocs(project(":lifecycle:lifecycle-viewmodel-compose"))
     docs(project(":lifecycle:lifecycle-viewmodel-ktx"))
     kmpDocs(project(":lifecycle:lifecycle-viewmodel-savedstate"))
+    samples(project(":lifecycle:lifecycle-viewmodel-savedstate-samples"))
     kmpDocs(project(":lifecycle:lifecycle-viewmodel-testing"))
     docs(project(":loader:loader"))
     docs(project(":loader:loader-ktx"))
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 f3dabe7..41cd8c1 100644
--- a/exifinterface/exifinterface/src/main/java/androidx/exifinterface/media/ExifInterface.java
+++ b/exifinterface/exifinterface/src/main/java/androidx/exifinterface/media/ExifInterface.java
@@ -23,6 +23,7 @@
 import static androidx.exifinterface.media.ExifInterfaceUtils.parseSubSeconds;
 import static androidx.exifinterface.media.ExifInterfaceUtils.startsWith;
 
+import static java.lang.annotation.ElementType.TYPE_USE;
 import static java.nio.ByteOrder.BIG_ENDIAN;
 import static java.nio.ByteOrder.LITTLE_ENDIAN;
 
@@ -65,6 +66,7 @@
 import java.io.OutputStream;
 import java.lang.annotation.Retention;
 import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
 import java.nio.ByteBuffer;
 import java.nio.ByteOrder;
 import java.nio.charset.Charset;
@@ -3032,6 +3034,36 @@
     @IntDef({STREAM_TYPE_FULL_IMAGE_DATA, STREAM_TYPE_EXIF_DATA_ONLY})
     public @interface ExifStreamType {}
 
+    @Retention(RetentionPolicy.SOURCE)
+    @Target(TYPE_USE)
+    @IntDef({
+        XMP_HANDLING_TIFF_700_ONLY,
+        XMP_HANDLING_PREFER_SEPARATE,
+        XMP_HANDLING_PREFER_TIFF_700_IF_PRESENT
+    })
+    private @interface XmpHandling {}
+
+    /**
+     * The format only supports XMP data stored in the TIFF/Exif 700 tag (or it supports storing XMP
+     * in a separate segment but this should never be used).
+     */
+    private static final int XMP_HANDLING_TIFF_700_ONLY = 1;
+
+    /**
+     * The format supports XMP data stored in a separate segment, and this should always be
+     * preferred if present.
+     *
+     * <p>If XMP data is only present in the TIFF/Exif 700 tag it will be read and written from
+     * there.
+     */
+    private static final int XMP_HANDLING_PREFER_SEPARATE = 2;
+
+    /**
+     * The format supports XMP data stored in a separate segment, and this should be preferred
+     * unless XMP is also present in the TIFF/Exif 700 tag.
+     */
+    private static final int XMP_HANDLING_PREFER_TIFF_700_IF_PRESENT = 3;
+
     // Maximum size for checking file type signature (see image_type_recognition_lite.cc)
     private static final int SIGNATURE_CHECK_SIZE = 5000;
 
@@ -3097,8 +3129,8 @@
     private static final int WEBP_CHUNK_TYPE_BYTE_LENGTH = 4;
     private static final int WEBP_CHUNK_SIZE_BYTE_LENGTH = 4;
 
-    private static SimpleDateFormat sFormatterPrimary;
-    private static SimpleDateFormat sFormatterSecondary;
+    private static final SimpleDateFormat sFormatterPrimary;
+    private static final SimpleDateFormat sFormatterSecondary;
 
     // See Exchangeable image file format for digital still cameras: Exif version 2.2.
     // The following values are for parsing EXIF data area. There are tag groups in EXIF data area.
@@ -3132,17 +3164,16 @@
     private static final int SKIP_BUFFER_SIZE = 8192;
 
     // Names for the data formats for debugging purpose.
-    static final String[] IFD_FORMAT_NAMES = new String[] {
+    private static final String[] IFD_FORMAT_NAMES = new String[] {
             "", "BYTE", "STRING", "USHORT", "ULONG", "URATIONAL", "SBYTE", "UNDEFINED", "SSHORT",
             "SLONG", "SRATIONAL", "SINGLE", "DOUBLE", "IFD"
     };
     // Sizes of the components of each IFD value format
-    static final int[] IFD_FORMAT_BYTES_PER_FORMAT = new int[] {
+    private static final int[] IFD_FORMAT_BYTES_PER_FORMAT = new int[] {
             0, 1, 1, 2, 4, 8, 1, 1, 2, 4, 8, 4, 8, 1
     };
 
-    @SuppressWarnings("WeakerAccess") /* synthetic access */
-    static final byte[] EXIF_ASCII_PREFIX = new byte[] {
+    private static final byte[] EXIF_ASCII_PREFIX = new byte[] {
             0x41, 0x53, 0x43, 0x49, 0x49, 0x0, 0x0, 0x0
     };
 
@@ -3153,8 +3184,7 @@
         public final long numerator;
         public final long denominator;
 
-        @SuppressWarnings("WeakerAccess") /* synthetic access */
-        Rational(long numerator, long denominator) {
+        private Rational(long numerator, long denominator) {
             // Handle erroneous case
             if (denominator == 0) {
                 this.numerator = 0;
@@ -3220,12 +3250,10 @@
         public final long bytesOffset;
         public final byte[] bytes;
 
-        @SuppressWarnings("WeakerAccess") /* synthetic access */
         ExifAttribute(int format, int numberOfComponents, byte[] bytes) {
             this(format, numberOfComponents, BYTES_OFFSET_UNKNOWN, bytes);
         }
 
-        @SuppressWarnings("WeakerAccess") /* synthetic access */
         ExifAttribute(int format, int numberOfComponents, long bytesOffset, byte[] bytes) {
             this.format = format;
             this.numberOfComponents = numberOfComponents;
@@ -3327,7 +3355,6 @@
             return "(" + IFD_FORMAT_NAMES[format] + ", data length:" + bytes.length + ")";
         }
 
-        @SuppressWarnings("WeakerAccess") /* synthetic access */
         Object getValue(ByteOrder byteOrder) {
             ByteOrderedDataInputStream inputStream = null;
             try {
@@ -3575,13 +3602,12 @@
     }
 
     // A class for indicating EXIF tag.
-    static class ExifTag {
+    private static class ExifTag {
         public final int number;
         public final String name;
         public final int primaryFormat;
         public final int secondaryFormat;
 
-        @SuppressWarnings("WeakerAccess") /* synthetic access */
         ExifTag(String name, int number, int format) {
             this.name = name;
             this.number = number;
@@ -3589,7 +3615,6 @@
             this.secondaryFormat = -1;
         }
 
-        @SuppressWarnings("WeakerAccess") /* synthetic access */
         ExifTag(String name, int number, int primaryFormat, int secondaryFormat) {
             this.name = name;
             this.number = number;
@@ -3597,7 +3622,6 @@
             this.secondaryFormat = secondaryFormat;
         }
 
-        @SuppressWarnings("WeakerAccess") /* synthetic access */
         boolean isFormatCompatible(int format) {
             if (primaryFormat == IFD_FORMAT_UNDEFINED || format == IFD_FORMAT_UNDEFINED) {
                 return true;
@@ -3862,23 +3886,24 @@
             IFD_TYPE_ORF_CAMERA_SETTINGS, IFD_TYPE_ORF_IMAGE_PROCESSING, IFD_TYPE_PEF})
     public @interface IfdType {}
 
-    static final int IFD_TYPE_PRIMARY = 0;
+    private static final int IFD_TYPE_PRIMARY = 0;
     private static final int IFD_TYPE_EXIF = 1;
     private static final int IFD_TYPE_GPS = 2;
     private static final int IFD_TYPE_INTEROPERABILITY = 3;
-    static final int IFD_TYPE_THUMBNAIL = 4;
-    static final int IFD_TYPE_PREVIEW = 5;
+    private static final int IFD_TYPE_THUMBNAIL = 4;
+    private static final int IFD_TYPE_PREVIEW = 5;
     private static final int IFD_TYPE_ORF_MAKER_NOTE = 6;
     private static final int IFD_TYPE_ORF_CAMERA_SETTINGS = 7;
     private static final int IFD_TYPE_ORF_IMAGE_PROCESSING = 8;
     private static final int IFD_TYPE_PEF = 9;
 
-    // List of Exif tag groups
-    static final ExifTag[][] EXIF_TAGS = new ExifTag[][] {
-            IFD_TIFF_TAGS, IFD_EXIF_TAGS, IFD_GPS_TAGS, IFD_INTEROPERABILITY_TAGS,
-            IFD_THUMBNAIL_TAGS, IFD_TIFF_TAGS, ORF_MAKER_NOTE_TAGS, ORF_CAMERA_SETTINGS_TAGS,
-            ORF_IMAGE_PROCESSING_TAGS, PEF_TAGS
-    };
+    // List of Exif tag groups, indexed by the IDF_TYPE_* constants above.
+    static final ExifTag[][] EXIF_TAGS =
+            new ExifTag[][] {
+                IFD_TIFF_TAGS, IFD_EXIF_TAGS, IFD_GPS_TAGS, IFD_INTEROPERABILITY_TAGS,
+                IFD_THUMBNAIL_TAGS, IFD_TIFF_TAGS, ORF_MAKER_NOTE_TAGS, ORF_CAMERA_SETTINGS_TAGS,
+                ORF_IMAGE_PROCESSING_TAGS, PEF_TAGS
+            };
     // List of tags for pointing to the other image file directory offset.
     private static final ExifTag[] EXIF_POINTER_TAGS = new ExifTag[] {
             new ExifTag(TAG_SUB_IFD_POINTER, 330, IFD_FORMAT_ULONG),
@@ -3928,17 +3953,16 @@
     // The following values are defined for handling JPEG streams. In this implementation, we are
     // not only getting information from EXIF but also from some JPEG special segments such as
     // MARKER_COM for user comment and MARKER_SOFx for image width and height.
-    @SuppressWarnings("WeakerAccess") /* synthetic access */
-    static final Charset ASCII = Charset.forName("US-ASCII");
+    private static final Charset ASCII = Charset.forName("US-ASCII");
     // Identifier for EXIF APP1 segment in JPEG
-    static final byte[] IDENTIFIER_EXIF_APP1 = "Exif\0\0".getBytes(ASCII);
+    @VisibleForTesting static final byte[] IDENTIFIER_EXIF_APP1 = "Exif\0\0".getBytes(ASCII);
     // Identifier for XMP APP1 segment in JPEG
     private static final byte[] IDENTIFIER_XMP_APP1 =
             "http://ns.adobe.com/xap/1.0/\0".getBytes(ASCII);
     // JPEG segment markers, that each marker consumes two bytes beginning with 0xff and ending with
     // the indicator. There is no SOF4, SOF8, SOF16 markers in JPEG and SOFx markers indicates start
     // of frame(baseline DCT) and the image size info exists in its beginning part.
-    static final byte MARKER = (byte) 0xff;
+    private static final byte MARKER = (byte) 0xff;
     private static final byte MARKER_SOI = (byte) 0xd8;
     private static final byte MARKER_SOF0 = (byte) 0xc0;
     private static final byte MARKER_SOF1 = (byte) 0xc1;
@@ -3954,27 +3978,22 @@
     private static final byte MARKER_SOF14 = (byte) 0xce;
     private static final byte MARKER_SOF15 = (byte) 0xcf;
     private static final byte MARKER_SOS = (byte) 0xda;
-    static final byte MARKER_APP1 = (byte) 0xe1;
+    @VisibleForTesting static final byte MARKER_APP1 = (byte) 0xe1;
     private static final byte MARKER_COM = (byte) 0xfe;
-    static final byte MARKER_EOI = (byte) 0xd9;
+    private static final byte MARKER_EOI = (byte) 0xd9;
 
     // Supported Image File Types
-    static final int IMAGE_TYPE_UNKNOWN = 0;
-    static final int IMAGE_TYPE_ARW = 1;
-    static final int IMAGE_TYPE_CR2 = 2;
-    static final int IMAGE_TYPE_DNG = 3;
-    static final int IMAGE_TYPE_JPEG = 4;
-    static final int IMAGE_TYPE_NEF = 5;
-    static final int IMAGE_TYPE_NRW = 6;
-    static final int IMAGE_TYPE_ORF = 7;
-    static final int IMAGE_TYPE_PEF = 8;
-    static final int IMAGE_TYPE_RAF = 9;
-    static final int IMAGE_TYPE_RW2 = 10;
-    static final int IMAGE_TYPE_SRW = 11;
-    static final int IMAGE_TYPE_HEIC = 12;
-    static final int IMAGE_TYPE_PNG = 13;
-    static final int IMAGE_TYPE_WEBP = 14;
-    static final int IMAGE_TYPE_AVIF = 15;
+    private static final int IMAGE_TYPE_UNKNOWN = 0;
+    private static final int IMAGE_TYPE_DNG = 3;
+    private static final int IMAGE_TYPE_JPEG = 4;
+    private static final int IMAGE_TYPE_ORF = 7;
+    private static final int IMAGE_TYPE_PEF = 8;
+    private static final int IMAGE_TYPE_RAF = 9;
+    private static final int IMAGE_TYPE_RW2 = 10;
+    private static final int IMAGE_TYPE_HEIC = 12;
+    private static final int IMAGE_TYPE_PNG = 13;
+    private static final int IMAGE_TYPE_WEBP = 14;
+    private static final int IMAGE_TYPE_AVIF = 15;
 
     static {
         sFormatterPrimary = new SimpleDateFormat("yyyy:MM:dd HH:mm:ss", Locale.US);
@@ -4024,9 +4043,15 @@
     private int mOrfThumbnailOffset;
     private int mOrfThumbnailLength;
     private boolean mModified;
-    // XMP data can be contained as either part of the EXIF data (tag number 700), or as a
-    // separate data marker (a separate MARKER_APP1).
-    private boolean mXmpIsFromSeparateMarker;
+
+    /**
+     * XMP data can occur as either part of the TIFF/Exif data (tag number 700), or as a separate
+     * section of the file (e.g. a separate APP1 segment in JPEG). XMP read from within the
+     * TIFF/Exif data is stored in {@link #mAttributes}, while XMP read from a separate section is
+     * here. If both are present, the disambiguation rules vary per file format, see
+     * {@link #getXmpHandlingForImageType(int)}.
+     */
+    @Nullable private ExifAttribute mXmpFromSeparateMarker;
 
     // Pattern to check non zero timestamp
     private static final Pattern NON_ZERO_TIME_PATTERN = Pattern.compile(".*[1-9].*");
@@ -4205,7 +4230,6 @@
      *
      * @param tag the name of the tag.
      */
-    @SuppressWarnings("deprecation")
     private @Nullable ExifAttribute getExifAttribute(@NonNull String tag) {
         if (tag == null) {
             throw new NullPointerException("tag shouldn't be null");
@@ -4218,6 +4242,11 @@
             }
             tag = TAG_PHOTOGRAPHIC_SENSITIVITY;
         }
+        if (TAG_XMP.equals(tag)
+                && getXmpHandlingForImageType(mMimeType) == XMP_HANDLING_PREFER_SEPARATE
+                && mXmpFromSeparateMarker != null) {
+            return mXmpFromSeparateMarker;
+        }
         // Retrieves all tag groups. The value from primary image tag group has a higher priority
         // than the value from the thumbnail tag group if there are more than one candidates.
         for (int i = 0; i < EXIF_TAGS.length; ++i) {
@@ -4226,9 +4255,40 @@
                 return value;
             }
         }
+        if (TAG_XMP.equals(tag) && mXmpFromSeparateMarker != null) {
+            return mXmpFromSeparateMarker;
+        }
         return null;
     }
 
+    private static @XmpHandling int getXmpHandlingForImageType(int imageType) {
+        switch (imageType) {
+            // ExifInterface has a documented (but spec-violating) preference for reading and
+            // writing JPEG and HEIC XMP data from Exif/TIFF tag 700 instead of a separate XMP
+            // APP1 segment (for JPEG) or a top-level XMP UUID box (for HEIC) if both are present.
+            case IMAGE_TYPE_JPEG:
+            case IMAGE_TYPE_HEIC:
+                return XMP_HANDLING_PREFER_TIFF_700_IF_PRESENT;
+            // RAF stores XMP/Exif in JPEG, but we have no documented backwards-compat obligations
+            // so we can implement the spec to store XMP in a separate APP1 segment.
+            case IMAGE_TYPE_RAF:
+            case IMAGE_TYPE_AVIF:
+                return XMP_HANDLING_PREFER_SEPARATE;
+            case IMAGE_TYPE_DNG:
+            case IMAGE_TYPE_ORF:
+            case IMAGE_TYPE_PEF:
+            case IMAGE_TYPE_RW2:
+            case IMAGE_TYPE_UNKNOWN:
+            // PNG and WebP support a separate XMP chunk (so should be
+            // XMP_HANDLING_PREFER_SEPARATE), but ExifInterface doesn't currently read or write
+            // them.
+            case IMAGE_TYPE_PNG:
+            case IMAGE_TYPE_WEBP:
+            default:
+                return XMP_HANDLING_TIFF_700_ONLY;
+        }
+    }
+
     /**
      * Returns the value of the specified tag or {@code null} if there
      * is no such tag in the image file.
@@ -4388,15 +4448,22 @@
             }
         }
 
-        for (int i = 0 ; i < EXIF_TAGS.length; ++i) {
-            if (i == IFD_TYPE_THUMBNAIL && !mHasThumbnail) {
-                continue;
+        if (TAG_XMP.equals(tag)) {
+            boolean containsTiff700Xmp =
+                    mAttributes[IFD_TYPE_PRIMARY].containsKey(TAG_XMP)
+                            || mAttributes[IFD_TYPE_PREVIEW].containsKey(TAG_XMP);
+            @XmpHandling int xmpHandling = getXmpHandlingForImageType(mMimeType);
+            if ((xmpHandling == XMP_HANDLING_PREFER_SEPARATE
+                            && (mXmpFromSeparateMarker != null || !containsTiff700Xmp))
+                    || (xmpHandling == XMP_HANDLING_PREFER_TIFF_700_IF_PRESENT
+                            && !containsTiff700Xmp)) {
+                mXmpFromSeparateMarker = ExifAttribute.createByte(value);
+                return;
             }
-            if (tag.equals(TAG_XMP) && i == IFD_TYPE_PREVIEW && mXmpIsFromSeparateMarker) {
-                // XMP was read from a standalone XMP APP1 segment in the source file, and only
-                // stored in sExifTagMapsForWriting[IFD_TYPE_PRIMARY], so we shouldn't store the
-                // updated value in sExifTagMapsForWriting[IFD_TYPE_PREVIEW] here, otherwise we risk
-                // incorrectly writing the updated value twice in the resulting file.
+        }
+
+        for (int i = 0; i < EXIF_TAGS.length; ++i) {
+            if (i == IFD_TYPE_THUMBNAIL && !mHasThumbnail) {
                 continue;
             }
             final ExifTag exifTag = sExifTagMapsForWriting[i].get(tag);
@@ -5744,7 +5811,6 @@
                     length = 0;
 
                     if (startsWith(bytes, IDENTIFIER_EXIF_APP1)) {
-                        byte[] xmpBeforeReadingExif = getAttributeBytes(TAG_XMP);
                         final byte[] value = Arrays.copyOfRange(bytes, IDENTIFIER_EXIF_APP1.length,
                                 bytes.length);
                         // Save offset to EXIF data for handling thumbnail and attribute offsets.
@@ -5754,28 +5820,13 @@
                         readExifSegment(value, imageType);
 
                         setThumbnailData(new ByteOrderedDataInputStream(value));
-
-                        if (getAttributeBytes(TAG_XMP) == null) {
-                            // XMP should be stored in a separate APP1 segment (see XMP spec part 3
-                            // section 3.3.2). If the Exif segment didn't contain XMP then we set
-                            // this to true to ensure any XMP data added will get written out to a
-                            // separate segment.
-                            mXmpIsFromSeparateMarker = true;
-                        } else if (xmpBeforeReadingExif != getAttributeBytes(TAG_XMP)) {
-                            mXmpIsFromSeparateMarker = false;
-                        }
                     } else if (startsWith(bytes, IDENTIFIER_XMP_APP1)) {
                         // See XMP Specification Part 3: Storage in Files, 1.1.3 JPEG, Table 6
                         final int offset = start + IDENTIFIER_XMP_APP1.length;
                         final byte[] value = Arrays.copyOfRange(bytes,
                                 IDENTIFIER_XMP_APP1.length, bytes.length);
-                        // TODO: check if ignoring separate XMP data when tag 700 already exists is
-                        //  valid.
-                        if (getAttribute(TAG_XMP) == null) {
-                            mAttributes[IFD_TYPE_PRIMARY].put(TAG_XMP, new ExifAttribute(
-                                    IFD_FORMAT_BYTE, value.length, offset, value));
-                            mXmpIsFromSeparateMarker = true;
-                        }
+                        mXmpFromSeparateMarker =
+                                new ExifAttribute(IFD_FORMAT_BYTE, value.length, offset, value);
                     }
                     break;
                 }
@@ -6097,10 +6148,8 @@
                     in.seek(offset);
                     byte[] xmpBytes = new byte[length];
                     in.readFully(xmpBytes);
-                    if (getAttribute(TAG_XMP) == null) {
-                        mAttributes[IFD_TYPE_PRIMARY].put(TAG_XMP, new ExifAttribute(
-                                IFD_FORMAT_BYTE, xmpBytes.length, offset, xmpBytes));
-                    }
+                    mXmpFromSeparateMarker =
+                            new ExifAttribute(IFD_FORMAT_BYTE, xmpBytes.length, offset, xmpBytes);
                 }
 
                 if (DEBUG) {
@@ -6447,33 +6496,20 @@
         }
         dataOutputStream.writeByte(MARKER_SOI);
 
-        // Remove XMP data if it is from a separate marker (IDENTIFIER_XMP_APP1, not
-        // IDENTIFIER_EXIF_APP1)
-        // Will re-add it later after the rest of the file is written
-        ExifAttribute xmpAttribute = null;
-        if (getAttribute(TAG_XMP) != null && mXmpIsFromSeparateMarker) {
-            xmpAttribute = mAttributes[IFD_TYPE_PRIMARY].remove(TAG_XMP);
-        }
-
         // Write EXIF APP1 segment
         dataOutputStream.writeByte(MARKER);
         dataOutputStream.writeByte(MARKER_APP1);
         writeExifSegment(dataOutputStream);
 
-        if (xmpAttribute != null && mXmpIsFromSeparateMarker) {
+        if (mXmpFromSeparateMarker != null) {
             // Write XMP APP1 segment. The XMP spec (part 3, section 1.1.3) recommends for this to
             // directly follow the Exif APP1 segment.
             dataOutputStream.write(MARKER);
             dataOutputStream.writeByte(MARKER_APP1);
-            int length = 2 + IDENTIFIER_XMP_APP1.length + xmpAttribute.bytes.length;
+            int length = 2 + IDENTIFIER_XMP_APP1.length + mXmpFromSeparateMarker.bytes.length;
             dataOutputStream.writeUnsignedShort(length);
             dataOutputStream.write(IDENTIFIER_XMP_APP1);
-            dataOutputStream.write(xmpAttribute.bytes);
-        }
-
-        // Re-add previously removed XMP data.
-        if (xmpAttribute != null) {
-            mAttributes[IFD_TYPE_PRIMARY].put(TAG_XMP, xmpAttribute);
+            dataOutputStream.write(mXmpFromSeparateMarker.bytes);
         }
 
         byte[] bytes = new byte[4096];
@@ -6503,8 +6539,7 @@
                     if (identifier != null) {
                         dataInputStream.readFully(identifier);
                         if (startsWith(identifier, IDENTIFIER_EXIF_APP1)
-                                || (startsWith(identifier, IDENTIFIER_XMP_APP1)
-                                        && mXmpIsFromSeparateMarker)) {
+                                || startsWith(identifier, IDENTIFIER_XMP_APP1)) {
                             // Skip the original EXIF or XMP APP1 segment.
                             dataInputStream.skipFully(length - identifier.length);
                             break;
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index b535274..c6259a9 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -28,6 +28,7 @@
 byteBuddy = "1.14.9"
 asm = "9.7"
 cmake = "3.22.1"
+composeCompilerPlugin = "2.0.10-custom-branch-7"
 dagger = "2.49"
 dependencyAnalysisGradlePlugin = "1.32.0"
 dexmaker = "2.28.4"
diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml
index 710c4d9..9d41d22 100644
--- a/gradle/verification-metadata.xml
+++ b/gradle/verification-metadata.xml
@@ -714,6 +714,16 @@
             <sha256 value="8d25d21f7f2aca27dc10638ad417bbb08a189310274bc322aa620aafe7e82c92" origin="Generated by Gradle" reason="Unsigned. Used by media3. b/372529250"/>
          </artifact>
       </component>
+      <component group="org.jetbrains.kotlin" name="kotlin-compose-compiler-plugin-embeddable" version="2.0.10-custom-branch-7">
+         <artifact name="kotlin-compose-compiler-plugin-embeddable-2.0.10-custom-branch-7.jar">
+            <md5 value="fdf7b7b01e609b7eb990e0b16f01cb96" origin="Generated by Gradle"/>
+            <sha256 value="8348f83c0405ce3f4335a2285b63c2821c123192a2ee4da7505432b42d669602" origin="Generated by Gradle" reason="Artifact is not signed"/>
+         </artifact>
+         <artifact name="kotlin-compose-compiler-plugin-embeddable-2.0.10-custom-branch-7.pom">
+            <md5 value="e2d4bfd6d1794c4599d7c32d8b7accec" origin="Generated by Gradle"/>
+            <sha256 value="a28b7ecf0ffa35f32482973b9cb04a7a4940e0eec53f066631c899fabe4d0951" origin="Generated by Gradle" reason="Artifact is not signed"/>
+         </artifact>
+      </component>
       <component group="org.jetbrains.kotlinx" name="kotlinx-benchmark-plugin" version="0.4.11">
          <artifact name="kotlinx-benchmark-plugin-0.4.11.jar">
             <sha256 value="5c337e082137eb3cdf64b27b11e16b97e5dd0a7905aa9c2d7c8c323601e5c31b" origin="Generated by Gradle" reason="https://github.com/Kotlin/kotlinx-benchmark/issues/115"/>
diff --git a/libraryversions.toml b/libraryversions.toml
index c090896..dbe9d92 100644
--- a/libraryversions.toml
+++ b/libraryversions.toml
@@ -3,7 +3,7 @@
 ANNOTATION = "1.9.0-rc01"
 ANNOTATION_EXPERIMENTAL = "1.5.0-alpha01"
 APPCOMPAT = "1.8.0-alpha01"
-APPSEARCH = "1.1.0-alpha06"
+APPSEARCH = "1.1.0-alpha07"
 ARCH_CORE = "2.3.0-alpha01"
 ASYNCLAYOUTINFLATER = "1.1.0-alpha02"
 AUTOFILL = "1.3.0-beta01"
diff --git a/lifecycle/lifecycle-viewmodel-savedstate-samples/build.gradle b/lifecycle/lifecycle-viewmodel-savedstate-samples/build.gradle
new file mode 100644
index 0000000..f6ec15b
--- /dev/null
+++ b/lifecycle/lifecycle-viewmodel-savedstate-samples/build.gradle
@@ -0,0 +1,52 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * This file was created using the `create_project.py` script located in the
+ * `<AndroidX root>/development/project-creator` directory.
+ *
+ * Please use that script when creating a new project, rather than copying an existing project and
+ * modifying its settings.
+ */
+
+import androidx.build.KotlinTarget
+import androidx.build.LibraryType
+
+plugins {
+    id("AndroidXPlugin")
+    id("com.android.library")
+    id("org.jetbrains.kotlin.android")
+    alias(libs.plugins.kotlinSerialization)
+}
+
+dependencies {
+    api(libs.kotlinStdlib)
+    compileOnly(project(":annotation:annotation-sampled"))
+    implementation(project(":lifecycle:lifecycle-viewmodel"))
+    implementation(project(":lifecycle:lifecycle-viewmodel-savedstate"))
+}
+
+android {
+    namespace "androidx.lifecycle.viewmodel.savedstate.samples"
+}
+
+androidx {
+    name = "androidx.lifecycle:lifecycle-viewmodel-savedstate-samples"
+    type = LibraryType.SAMPLES
+    inceptionYear = "2024"
+    description = "Lifecycle ViewModel SavedState Samples"
+    kotlinTarget = KotlinTarget.KOTLIN_1_9
+}
diff --git a/lifecycle/lifecycle-viewmodel-savedstate-samples/src/main/java/androidx/lifecycle/LifecycleViewModelSavedStateSamples.kt b/lifecycle/lifecycle-viewmodel-savedstate-samples/src/main/java/androidx/lifecycle/LifecycleViewModelSavedStateSamples.kt
new file mode 100644
index 0000000..0c0c2d4
--- /dev/null
+++ b/lifecycle/lifecycle-viewmodel-savedstate-samples/src/main/java/androidx/lifecycle/LifecycleViewModelSavedStateSamples.kt
@@ -0,0 +1,59 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.lifecycle
+
+import androidx.annotation.Sampled
+import kotlinx.serialization.InternalSerializationApi
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.serializer
+
+@Sampled
+fun delegate() {
+    @Serializable data class User(val id: Int, val name: String)
+    class ProfileViewModel(savedStateHandle: SavedStateHandle) : ViewModel() {
+        val user by savedStateHandle.saved { User(123, "foo") }
+    }
+}
+
+@Sampled
+fun delegateExplicitKey() {
+    @Serializable data class User(val id: Int, val name: String)
+    class ProfileViewModel(savedStateHandle: SavedStateHandle) : ViewModel() {
+        val user by savedStateHandle.saved(key = "bar") { User(123, "foo") }
+    }
+}
+
+@OptIn(InternalSerializationApi::class)
+@Sampled
+fun delegateExplicitSerializer() {
+    @Serializable data class User(val id: Int, val name: String)
+    class ProfileViewModel(savedStateHandle: SavedStateHandle) : ViewModel() {
+        val user by savedStateHandle.saved(User::class.serializer()) { User(123, "foo") }
+    }
+}
+
+@OptIn(InternalSerializationApi::class)
+@Sampled
+fun delegateExplicitKeyAndSerializer() {
+    @Serializable data class User(val id: Int, val name: String)
+    class ProfileViewModel(savedStateHandle: SavedStateHandle) : ViewModel() {
+        val user by
+            savedStateHandle.saved(key = "bar", serializer = User::class.serializer()) {
+                User(123, "foo")
+            }
+    }
+}
diff --git a/lifecycle/lifecycle-viewmodel-savedstate/api/current.txt b/lifecycle/lifecycle-viewmodel-savedstate/api/current.txt
index 58ae453..682b5ef 100644
--- a/lifecycle/lifecycle-viewmodel-savedstate/api/current.txt
+++ b/lifecycle/lifecycle-viewmodel-savedstate/api/current.txt
@@ -27,6 +27,13 @@
   public static final class SavedStateHandle.Companion {
   }
 
+  public final class SavedStateHandleDelegatesKt {
+    method public static inline <reified T> kotlin.properties.ReadWriteProperty<java.lang.Object?,T> saved(androidx.lifecycle.SavedStateHandle, String key, kotlin.jvm.functions.Function0<? extends T> init);
+    method public static <T> kotlin.properties.ReadWriteProperty<java.lang.Object?,T> saved(androidx.lifecycle.SavedStateHandle, String key, kotlinx.serialization.KSerializer<T> serializer, kotlin.jvm.functions.Function0<? extends T> init);
+    method public static inline <reified T> kotlin.properties.ReadWriteProperty<java.lang.Object?,T> saved(androidx.lifecycle.SavedStateHandle, kotlin.jvm.functions.Function0<? extends T> init);
+    method public static <T> kotlin.properties.ReadWriteProperty<java.lang.Object?,T> saved(androidx.lifecycle.SavedStateHandle, kotlinx.serialization.KSerializer<T> serializer, kotlin.jvm.functions.Function0<? extends T> init);
+  }
+
   public final class SavedStateHandleSupport {
     method @MainThread public static androidx.lifecycle.SavedStateHandle createSavedStateHandle(androidx.lifecycle.viewmodel.CreationExtras);
     method @MainThread public static <T extends androidx.savedstate.SavedStateRegistryOwner & androidx.lifecycle.ViewModelStoreOwner> void enableSavedStateHandles(T);
diff --git a/lifecycle/lifecycle-viewmodel-savedstate/api/restricted_current.txt b/lifecycle/lifecycle-viewmodel-savedstate/api/restricted_current.txt
index 58ae453..682b5ef 100644
--- a/lifecycle/lifecycle-viewmodel-savedstate/api/restricted_current.txt
+++ b/lifecycle/lifecycle-viewmodel-savedstate/api/restricted_current.txt
@@ -27,6 +27,13 @@
   public static final class SavedStateHandle.Companion {
   }
 
+  public final class SavedStateHandleDelegatesKt {
+    method public static inline <reified T> kotlin.properties.ReadWriteProperty<java.lang.Object?,T> saved(androidx.lifecycle.SavedStateHandle, String key, kotlin.jvm.functions.Function0<? extends T> init);
+    method public static <T> kotlin.properties.ReadWriteProperty<java.lang.Object?,T> saved(androidx.lifecycle.SavedStateHandle, String key, kotlinx.serialization.KSerializer<T> serializer, kotlin.jvm.functions.Function0<? extends T> init);
+    method public static inline <reified T> kotlin.properties.ReadWriteProperty<java.lang.Object?,T> saved(androidx.lifecycle.SavedStateHandle, kotlin.jvm.functions.Function0<? extends T> init);
+    method public static <T> kotlin.properties.ReadWriteProperty<java.lang.Object?,T> saved(androidx.lifecycle.SavedStateHandle, kotlinx.serialization.KSerializer<T> serializer, kotlin.jvm.functions.Function0<? extends T> init);
+  }
+
   public final class SavedStateHandleSupport {
     method @MainThread public static androidx.lifecycle.SavedStateHandle createSavedStateHandle(androidx.lifecycle.viewmodel.CreationExtras);
     method @MainThread public static <T extends androidx.savedstate.SavedStateRegistryOwner & androidx.lifecycle.ViewModelStoreOwner> void enableSavedStateHandles(T);
diff --git a/lifecycle/lifecycle-viewmodel-savedstate/bcv/native/current.txt b/lifecycle/lifecycle-viewmodel-savedstate/bcv/native/current.txt
index 5041c3f..c2a44e9 100644
--- a/lifecycle/lifecycle-viewmodel-savedstate/bcv/native/current.txt
+++ b/lifecycle/lifecycle-viewmodel-savedstate/bcv/native/current.txt
@@ -42,3 +42,7 @@
 
 final fun (androidx.lifecycle.viewmodel/CreationExtras).androidx.lifecycle/createSavedStateHandle(): androidx.lifecycle/SavedStateHandle // androidx.lifecycle/createSavedStateHandle|createSavedStateHandle@androidx.lifecycle.viewmodel.CreationExtras(){}[0]
 final fun <#A: androidx.lifecycle/ViewModelStoreOwner & androidx.savedstate/SavedStateRegistryOwner> (#A).androidx.lifecycle/enableSavedStateHandles() // androidx.lifecycle/enableSavedStateHandles|enableSavedStateHandles@0:0(){0§<androidx.savedstate.SavedStateRegistryOwner&androidx.lifecycle.ViewModelStoreOwner>}[0]
+final fun <#A: kotlin/Any> (androidx.lifecycle/SavedStateHandle).androidx.lifecycle/saved(kotlin/String, kotlinx.serialization/KSerializer<#A>, kotlin/Function0<#A>): kotlin.properties/ReadWriteProperty<kotlin/Any?, #A> // androidx.lifecycle/saved|[email protected](kotlin.String;kotlinx.serialization.KSerializer<0:0>;kotlin.Function0<0:0>){0§<kotlin.Any>}[0]
+final fun <#A: kotlin/Any> (androidx.lifecycle/SavedStateHandle).androidx.lifecycle/saved(kotlinx.serialization/KSerializer<#A>, kotlin/Function0<#A>): kotlin.properties/ReadWriteProperty<kotlin/Any?, #A> // androidx.lifecycle/saved|[email protected](kotlinx.serialization.KSerializer<0:0>;kotlin.Function0<0:0>){0§<kotlin.Any>}[0]
+final inline fun <#A: reified kotlin/Any> (androidx.lifecycle/SavedStateHandle).androidx.lifecycle/saved(kotlin/String, noinline kotlin/Function0<#A>): kotlin.properties/ReadWriteProperty<kotlin/Any?, #A> // androidx.lifecycle/saved|[email protected](kotlin.String;kotlin.Function0<0:0>){0§<kotlin.Any>}[0]
+final inline fun <#A: reified kotlin/Any> (androidx.lifecycle/SavedStateHandle).androidx.lifecycle/saved(noinline kotlin/Function0<#A>): kotlin.properties/ReadWriteProperty<kotlin/Any?, #A> // androidx.lifecycle/saved|[email protected](kotlin.Function0<0:0>){0§<kotlin.Any>}[0]
diff --git a/lifecycle/lifecycle-viewmodel-savedstate/build.gradle b/lifecycle/lifecycle-viewmodel-savedstate/build.gradle
index 43d5b27..b9f335c 100644
--- a/lifecycle/lifecycle-viewmodel-savedstate/build.gradle
+++ b/lifecycle/lifecycle-viewmodel-savedstate/build.gradle
@@ -30,6 +30,7 @@
 plugins {
     id("AndroidXPlugin")
     id("com.android.library")
+    alias(libs.plugins.kotlinSerialization)
 }
 
 androidXMultiplatform {
@@ -49,6 +50,7 @@
                 api(project(":lifecycle:lifecycle-viewmodel"))
                 api(libs.kotlinStdlib)
                 api(libs.kotlinCoroutinesCore)
+                api(libs.kotlinSerializationCore)
             }
         }
 
@@ -86,6 +88,7 @@
                 implementation project(":lifecycle:lifecycle-livedata-core")
                 implementation ("androidx.fragment:fragment:1.3.0")
                 implementation project(":internal-testutils-runtime")
+                implementation(project(":lifecycle:lifecycle-viewmodel"))
                 implementation(libs.truth)
                 implementation(libs.testExtJunit)
                 implementation(libs.testCore)
diff --git a/lifecycle/lifecycle-viewmodel-savedstate/src/androidInstrumentedTest/AndroidManifest.xml b/lifecycle/lifecycle-viewmodel-savedstate/src/androidInstrumentedTest/AndroidManifest.xml
index 50815d9..a8f6610c 100644
--- a/lifecycle/lifecycle-viewmodel-savedstate/src/androidInstrumentedTest/AndroidManifest.xml
+++ b/lifecycle/lifecycle-viewmodel-savedstate/src/androidInstrumentedTest/AndroidManifest.xml
@@ -22,6 +22,8 @@
             android:name="androidx.lifecycle.viewmodel.savedstate.FakingSavedStateActivity"/>
         <activity
             android:name="androidx.lifecycle.viewmodel.savedstate.SavedStateFactoryTest$MyActivity"/>
+        <activity
+            android:name="androidx.activity.ComponentActivity"/>
     </application>
 
 </manifest>
diff --git a/lifecycle/lifecycle-viewmodel-savedstate/src/androidInstrumentedTest/kotlin/androidx/lifecycle/SerializationTest.kt b/lifecycle/lifecycle-viewmodel-savedstate/src/androidInstrumentedTest/kotlin/androidx/lifecycle/SerializationTest.kt
new file mode 100644
index 0000000..0766242
--- /dev/null
+++ b/lifecycle/lifecycle-viewmodel-savedstate/src/androidInstrumentedTest/kotlin/androidx/lifecycle/SerializationTest.kt
@@ -0,0 +1,139 @@
+/*
+ * Copyright 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.lifecycle
+
+import androidx.activity.ComponentActivity
+import androidx.test.ext.junit.rules.ActivityScenarioRule
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.google.common.truth.Truth.assertThat
+import kotlinx.serialization.builtins.serializer
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@SmallTest
+@RunWith(AndroidJUnit4::class)
+class SerializationTest {
+
+    @get:Rule val activityTestRuleScenario = ActivityScenarioRule(ComponentActivity::class.java)
+
+    @Test
+    fun simpleRestore() {
+        activityTestRuleScenario.scenario.onActivity { activity ->
+            val viewModel: MyViewModel = ViewModelProvider(activity)[MyViewModel::class]
+            var value by viewModel.savedStateHandle.saved { 1 }
+            assertThat(value).isEqualTo(1)
+            value = 2
+            assertThat(value).isEqualTo(2)
+        }
+        activityTestRuleScenario.scenario.recreate()
+        activityTestRuleScenario.scenario.onActivity { activity ->
+            val viewModel: MyViewModel = ViewModelProvider(activity)[MyViewModel::class]
+            var value: Int by
+                viewModel.savedStateHandle.saved { error("Unexpected initializer call") }
+            assertThat(value).isEqualTo(2)
+        }
+    }
+
+    @Test
+    fun explicitKey() {
+        activityTestRuleScenario.scenario.onActivity { activity ->
+            val viewModel: MyViewModel = ViewModelProvider(activity)[MyViewModel::class]
+            var value by viewModel.savedStateHandle.saved(key = "foo") { 1 }
+            assertThat(value).isEqualTo(1)
+            value = 2
+            assertThat(value).isEqualTo(2)
+        }
+        activityTestRuleScenario.scenario.recreate()
+        activityTestRuleScenario.scenario.onActivity { activity ->
+            val viewModel: MyViewModel = ViewModelProvider(activity)[MyViewModel::class]
+            var value: Int by
+                viewModel.savedStateHandle.saved(key = "foo") {
+                    error("Unexpected initializer call")
+                }
+            assertThat(value).isEqualTo(2)
+        }
+    }
+
+    @Test
+    fun explicitSerializer() {
+        activityTestRuleScenario.scenario.onActivity { activity ->
+            val viewModel: MyViewModel = ViewModelProvider(activity)[MyViewModel::class]
+            val value by viewModel.savedStateHandle.saved(serializer = Int.serializer()) { 1 }
+            assertThat(value).isEqualTo(1)
+        }
+        activityTestRuleScenario.scenario.recreate()
+        activityTestRuleScenario.scenario.onActivity { activity ->
+            val viewModel: MyViewModel = ViewModelProvider(activity)[MyViewModel::class]
+            val value: Int by
+                viewModel.savedStateHandle.saved(serializer = Int.serializer()) {
+                    error("Unexpected initializer call")
+                }
+            assertThat(value).isEqualTo(1)
+        }
+    }
+
+    @Test
+    fun explicitKeyAndSerializer() {
+        activityTestRuleScenario.scenario.onActivity { activity ->
+            val viewModel: MyViewModel = ViewModelProvider(activity)[MyViewModel::class]
+            val value by
+                viewModel.savedStateHandle.saved(key = "foo", serializer = Int.serializer()) { 1 }
+            assertThat(value).isEqualTo(1)
+        }
+        activityTestRuleScenario.scenario.recreate()
+        activityTestRuleScenario.scenario.onActivity { activity ->
+            val viewModel: MyViewModel = ViewModelProvider(activity)[MyViewModel::class]
+            val value: Int by
+                viewModel.savedStateHandle.saved(key = "foo", serializer = Int.serializer()) {
+                    error("Unexpected initializer call")
+                }
+            assertThat(value).isEqualTo(1)
+        }
+    }
+
+    @Test
+    fun duplicateKeys() {
+        activityTestRuleScenario.scenario.onActivity { activity ->
+            val viewModel: MyViewModel = ViewModelProvider(activity)[MyViewModel::class]
+            val serializable by viewModel.savedStateHandle.saved(key = "foo") { 1 }
+            assertThat(serializable).isEqualTo(1)
+            val duplicate by viewModel.savedStateHandle.saved(key = "foo") { 2 }
+            assertThat(duplicate).isEqualTo(2)
+            // The value is from the initializer.
+            assertThat(serializable).isEqualTo(1)
+        }
+        activityTestRuleScenario.scenario.recreate()
+        activityTestRuleScenario.scenario.onActivity { activity ->
+            val viewModel: MyViewModel = ViewModelProvider(activity)[MyViewModel::class]
+            val serializable: Int by
+                viewModel.savedStateHandle.saved(key = "foo") {
+                    error("Unexpected initializer call")
+                }
+            assertThat(serializable).isEqualTo(2)
+            val duplicate: Int by
+                viewModel.savedStateHandle.saved(key = "foo") {
+                    error("Unexpected initializer call")
+                }
+            assertThat(duplicate).isEqualTo(2)
+            assertThat(serializable).isEqualTo(2)
+        }
+    }
+}
+
+class MyViewModel(val savedStateHandle: SavedStateHandle) : ViewModel()
diff --git a/lifecycle/lifecycle-viewmodel-savedstate/src/commonMain/kotlin/androidx/lifecycle/SavedStateHandleDelegates.kt b/lifecycle/lifecycle-viewmodel-savedstate/src/commonMain/kotlin/androidx/lifecycle/SavedStateHandleDelegates.kt
new file mode 100644
index 0000000..ce7301c2
--- /dev/null
+++ b/lifecycle/lifecycle-viewmodel-savedstate/src/commonMain/kotlin/androidx/lifecycle/SavedStateHandleDelegates.kt
@@ -0,0 +1,144 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.lifecycle
+
+import androidx.savedstate.SavedState
+import androidx.savedstate.read
+import androidx.savedstate.serialization.decodeFromSavedState
+import androidx.savedstate.serialization.encodeToSavedState
+import kotlin.properties.ReadWriteProperty
+import kotlin.reflect.KProperty
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.serializer
+
+/**
+ * Returns a property delegate that uses [SavedStateHandle] to save and restore a value of type [T]
+ * with fully qualified property or variable name as key and the default serializer.
+ *
+ * @sample androidx.lifecycle.delegate
+ * @param init The function to provide the initial value of the property.
+ * @return A property delegate that manages the saving and restoring of the value.
+ */
+inline fun <reified T : Any> SavedStateHandle.saved(
+    noinline init: () -> T,
+): ReadWriteProperty<Any?, T> {
+    return saved(serializer(), init)
+}
+
+/**
+ * Returns a property delegate that uses [SavedStateHandle] to save and restore a value of type [T]
+ * with the default serializer.
+ *
+ * @sample androidx.lifecycle.delegateExplicitKey
+ * @param key The [String] key to use for storing the value in the [SavedStateHandle].
+ * @param init The function to provide the initial value of the property.
+ * @return A property delegate that manages the saving and restoring of the value.
+ */
+inline fun <reified T : Any> SavedStateHandle.saved(
+    key: String,
+    noinline init: () -> T,
+): ReadWriteProperty<Any?, T> {
+    return saved(key, serializer(), init)
+}
+
+/**
+ * Returns a property delegate that uses [SavedStateHandle] to save and restore a value of type [T]
+ * with fully qualified property or variable name as key.
+ *
+ * @sample androidx.lifecycle.delegateExplicitSerializer
+ * @param serializer The [KSerializer] to use for serializing and deserializing the value.
+ * @param init The function to provide the initial value of the property.
+ * @return A property delegate that manages the saving and restoring of the value.
+ */
+fun <T : Any> SavedStateHandle.saved(
+    serializer: KSerializer<T>,
+    init: () -> T,
+): ReadWriteProperty<Any?, T> {
+    return SerializablePropertyDelegate(
+        savedStateHandle = this,
+        key = null,
+        serializer = serializer,
+        init = init
+    )
+}
+
+/**
+ * Returns a property delegate that uses [SavedStateHandle] to save and restore a value of type [T].
+ *
+ * @sample androidx.lifecycle.delegateExplicitKeyAndSerializer
+ * @param key The [String] key to use for storing the value in the [SavedStateHandle].
+ * @param serializer The [KSerializer] to use for serializing and deserializing the value.
+ * @param init The function to provide the initial value of the property.
+ * @return A property delegate that manages the saving and restoring of the value.
+ */
+fun <T : Any> SavedStateHandle.saved(
+    key: String,
+    serializer: KSerializer<T>,
+    init: () -> T,
+): ReadWriteProperty<Any?, T> {
+    return SerializablePropertyDelegate(
+        savedStateHandle = this,
+        key = key,
+        serializer = serializer,
+        init = init
+    )
+}
+
+private class SerializablePropertyDelegate<T : Any>(
+    private val savedStateHandle: SavedStateHandle,
+    private val key: String?,
+    private val serializer: KSerializer<T>,
+    private val init: () -> T,
+) : ReadWriteProperty<Any?, T> {
+    private lateinit var value: T
+
+    private fun lazyInit(thisRef: Any?, property: KProperty<*>) {
+        if (::value.isInitialized) return
+
+        val qualifiedKey =
+            if (key != null) {
+                key
+            } else {
+                val classNamePrefix =
+                    if (thisRef != null) thisRef::class.qualifiedName + "." else ""
+                classNamePrefix + property.name
+            }
+
+        val restoredState = savedStateHandle.get<SavedState>(qualifiedKey)
+        val value =
+            if (restoredState != null && restoredState.read { !isEmpty() }) {
+                decodeFromSavedState(serializer, restoredState)
+            } else {
+                init()
+            }
+        this.value = value
+
+        savedStateHandle.setSavedStateProvider(qualifiedKey) {
+            encodeToSavedState(serializer, this.value)
+        }
+    }
+
+    override fun getValue(thisRef: Any?, property: KProperty<*>): T {
+        lazyInit(thisRef, property)
+        return value
+    }
+
+    override fun setValue(thisRef: Any?, property: KProperty<*>, value: T) {
+        lazyInit(thisRef, property)
+        this.value = value
+    }
+}
diff --git a/mediarouter/mediarouter/src/main/res/values-ja/strings.xml b/mediarouter/mediarouter/src/main/res/values-ja/strings.xml
index e50c76e..d1589d9 100644
--- a/mediarouter/mediarouter/src/main/res/values-ja/strings.xml
+++ b/mediarouter/mediarouter/src/main/res/values-ja/strings.xml
@@ -46,7 +46,7 @@
     <string name="mr_chooser_wifi_warning_description_phone" msgid="2555886884770958244">"他のデバイスがこのスマートフォンと同じ Wi-Fi ネットワークに接続していることを確認してください"</string>
     <string name="mr_chooser_wifi_warning_description_tablet" msgid="6038748488793588164">"他のデバイスがこのタブレットと同じ Wi-Fi ネットワークに接続していることを確認してください"</string>
     <string name="mr_chooser_wifi_warning_description_tv" msgid="5845921667085074878">"他のデバイスがこのテレビと同じ Wi-Fi ネットワークに接続していることを確認してください"</string>
-    <string name="mr_chooser_wifi_warning_description_watch" msgid="5255021372884233706">"他のデバイスがこのスマートウォッチと同じ Wi-Fi ネットワークに接続していることを確認してください"</string>
+    <string name="mr_chooser_wifi_warning_description_watch" msgid="5255021372884233706">"もう一方のデバイスがこのスマートウォッチと同じ Wi-Fi ネットワークに接続していることを確認してください"</string>
     <string name="mr_chooser_wifi_warning_description_car" msgid="2998902945608081567">"他のデバイスがこの車と同じ Wi-Fi ネットワークに接続していることを確認してください"</string>
     <string name="mr_chooser_wifi_warning_description_unknown" msgid="3459891599800041449">"他のデバイスがこのデバイスと同じ Wi-Fi ネットワークに接続していることを確認してください"</string>
     <string name="mr_chooser_wifi_learn_more" msgid="3799500840179081429"><a href="https://support.google.com/chromecast/?p=trouble-finding-devices">"詳細"</a></string>
diff --git a/mediarouter/mediarouter/src/main/res/values-zh-rTW/strings.xml b/mediarouter/mediarouter/src/main/res/values-zh-rTW/strings.xml
index f400c48..e7aa096 100644
--- a/mediarouter/mediarouter/src/main/res/values-zh-rTW/strings.xml
+++ b/mediarouter/mediarouter/src/main/res/values-zh-rTW/strings.xml
@@ -43,7 +43,7 @@
     <string name="mr_dialog_transferable_header" msgid="6068257520605505468">"在群組上播放"</string>
     <string name="mr_cast_dialog_title_view_placeholder" msgid="2175930138959078155">"沒有可用的資訊"</string>
     <string name="mr_chooser_zero_routes_found_title" msgid="5213435473397442608">"找不到裝置"</string>
-    <string name="mr_chooser_wifi_warning_description_phone" msgid="2555886884770958244">"確認其他裝置與這部手機連上同一個 Wi-Fi 網路"</string>
+    <string name="mr_chooser_wifi_warning_description_phone" msgid="2555886884770958244">"請確認另一部裝置與這支手機都連上同一個 Wi-Fi 網路"</string>
     <string name="mr_chooser_wifi_warning_description_tablet" msgid="6038748488793588164">"確認其他裝置與這部平板電腦連上同一個 Wi-Fi 網路"</string>
     <string name="mr_chooser_wifi_warning_description_tv" msgid="5845921667085074878">"確認其他裝置與這部電視連上同一個 Wi-Fi 網路"</string>
     <string name="mr_chooser_wifi_warning_description_watch" msgid="5255021372884233706">"確認其他裝置與這支手錶連上同一個 Wi-Fi 網路"</string>
diff --git a/pdf/integration-tests/testapp/build.gradle b/pdf/integration-tests/testapp/build.gradle
index a6de465..4a6ddfe 100644
--- a/pdf/integration-tests/testapp/build.gradle
+++ b/pdf/integration-tests/testapp/build.gradle
@@ -9,9 +9,9 @@
 
     defaultConfig {
         applicationId "androidx.pdf.testapp"
-        minSdk 35
+        minSdk 31
         compileSdk 35
-        targetSdk 35
+        targetSdk 31
     }
 
     sourceSets {
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 f910b55..9d4d1fe 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
@@ -18,8 +18,9 @@
 
 import android.annotation.SuppressLint
 import android.content.pm.ActivityInfo
+import android.os.Build
 import android.view.KeyEvent
-import androidx.core.view.WindowCompat
+import androidx.annotation.RequiresExtension
 import androidx.fragment.app.testing.FragmentScenario
 import androidx.fragment.app.testing.launchFragmentInContainer
 import androidx.lifecycle.Lifecycle
@@ -42,9 +43,9 @@
 import androidx.test.espresso.matcher.ViewMatchers.withId
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.LargeTest
+import androidx.test.filters.SdkSuppress
 import androidx.test.platform.app.InstrumentationRegistry
 import org.junit.After
-import org.junit.Assert
 import org.junit.Before
 import org.junit.Test
 import org.junit.runner.RunWith
@@ -52,6 +53,8 @@
 @SuppressLint("BanThreadSleep")
 @LargeTest
 @RunWith(AndroidJUnit4::class)
+@SdkSuppress(minSdkVersion = 35)
+@RequiresExtension(extension = Build.VERSION_CODES.S, version = 13)
 class PdfViewerFragmentTestSuite {
 
     private lateinit var scenario: FragmentScenario<TestPdfViewerFragment>
@@ -208,7 +211,6 @@
         scenario.onFragment { it.isToolboxVisible = true }
         onView(withId(R.id.edit_fab)).check(matches(isDisplayed()))
         onView(withId(androidx.pdf.testapp.R.id.host_Search)).check(matches(isDisplayed()))
-        onView(withId(androidx.pdf.testapp.R.id.toggle_full_screen)).check(matches(isDisplayed()))
 
         // Hide the toolbox and check visibility of buttons
         scenario.onFragment { it.isToolboxVisible = false }
@@ -216,67 +218,17 @@
             .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.GONE)))
         onView(withId(androidx.pdf.testapp.R.id.host_Search)).check(matches(isDisplayed()))
 
-        // --- Enter immersive mode and check visibility of buttons --- //
-
-        // Enter immersive mode by clicking the fullscreen button
-        onView(withId(androidx.pdf.testapp.R.id.toggle_full_screen)).perform(click())
-
-        scenario.onFragment { fragment ->
-            val activity = fragment.requireActivity()
-            val windowInsetsController =
-                WindowCompat.getInsetsController(activity.window, activity.window.decorView)
-
-            // Check if the system bars are hidden
-            Assert.assertFalse(windowInsetsController.isAppearanceLightNavigationBars)
-            Assert.assertFalse(windowInsetsController.isAppearanceLightStatusBars)
-        }
-
-        // Assert that the "Edit" FAB is hidden
+        // Enter immersive mode and check visibility of buttons
+        scenario.onFragment { it.onRequestImmersiveMode(true) }
         onView(withId(R.id.edit_fab))
             .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.GONE)))
-
-        // Assert that the host app's search button is visible
         onView(withId(androidx.pdf.testapp.R.id.host_Search))
-            .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE)))
-
-        // Assert that the fullscreen toggle button is visible
-        onView(withId(androidx.pdf.testapp.R.id.toggle_full_screen))
-            .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE)))
-
-        // --- Test search functionality in Immersive mode ---
-
-        // Toggle search menu
-        val searchViewAssertion = SearchViewAssertions()
-        scenario.onFragment { it.isTextSearchActive = true }
-        onView(withId(R.id.search_container)).check(matches(isDisplayed()))
-
-        // Perform a search and assert that results are found
-        onView(withId(R.id.find_query_box)).perform(typeText(SEARCH_QUERY))
-        onView(withId(R.id.match_status_textview)).check(matches(isDisplayed()))
-        onView(withId(R.id.match_status_textview)).check(searchViewAssertion.extractAndMatch())
-
-        // Prev/next search results
-        onView(withId(R.id.find_prev_btn)).perform(click())
-        onView(withId(R.id.match_status_textview)).check(searchViewAssertion.matchPrevious())
-        onView(withId(R.id.find_next_btn)).perform(click())
-        onView(withId(R.id.match_status_textview)).check(searchViewAssertion.matchNext())
-        onView(withId(R.id.find_next_btn)).perform(click())
-        onView(withId(R.id.match_status_textview)).check(searchViewAssertion.matchNext())
-
-        // Assert for keyboard collapse
-        onView(withId(R.id.find_query_box)).perform(click())
-        onView(withId(R.id.close_btn)).perform(click())
-        onView(withId(R.id.find_query_box))
             .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.GONE)))
 
-        // --- Test scroll functionality in immersive mode ---
-        onView(withId(R.id.parent_pdf_container)).perform(swipeUp())
-        onView(withId(R.id.parent_pdf_container)).perform(swipeDown())
-
-        // --- Exit immersive mode and check visibility of buttons ---
-
-        // Click the fullscreen button again to exit immersive mode
-        onView(withId(androidx.pdf.testapp.R.id.toggle_full_screen)).perform(click())
+        // Exit immersive mode and check visibility of buttons
+        scenario.onFragment { it.onRequestImmersiveMode(false) }
+        onView(withId(R.id.edit_fab)).check(matches(isDisplayed()))
+        onView(withId(androidx.pdf.testapp.R.id.host_Search)).check(matches(isDisplayed()))
 
         // Click the host app search button and check visibility of elements
         onView(withId(androidx.pdf.testapp.R.id.host_Search)).perform(click())
@@ -284,7 +236,7 @@
         onView(withId(R.id.edit_fab))
             .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.GONE)))
         onView(withId(androidx.pdf.testapp.R.id.host_Search))
-            .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE)))
+            .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.GONE)))
     }
 
     fun testPdfViewerFragment_setDocumentUri_passwordProtected_portrait() {
diff --git a/pdf/integration-tests/testapp/src/androidTest/kotlin/androidx/pdf/TestPdfViewerFragment.kt b/pdf/integration-tests/testapp/src/androidTest/kotlin/androidx/pdf/TestPdfViewerFragment.kt
index e25967c..436263d 100644
--- a/pdf/integration-tests/testapp/src/androidTest/kotlin/androidx/pdf/TestPdfViewerFragment.kt
+++ b/pdf/integration-tests/testapp/src/androidTest/kotlin/androidx/pdf/TestPdfViewerFragment.kt
@@ -16,36 +16,31 @@
 
 package androidx.pdf
 
+import android.os.Build
 import android.os.Bundle
 import android.view.LayoutInflater
 import android.view.View
 import android.view.ViewGroup
 import android.widget.FrameLayout
+import androidx.annotation.RequiresExtension
 import androidx.annotation.RestrictTo
 import androidx.core.view.ViewCompat
-import androidx.core.view.WindowCompat
 import androidx.core.view.WindowInsetsCompat
 import androidx.pdf.idlingresource.PdfIdlingResource
 import androidx.pdf.testapp.R
 import androidx.pdf.viewer.fragment.PdfViewerFragment
-import com.google.android.material.button.MaterialButton
 import com.google.android.material.floatingactionbutton.FloatingActionButton
 
+@RequiresExtension(extension = Build.VERSION_CODES.S, version = 13)
 @RestrictTo(RestrictTo.Scope.LIBRARY)
 internal class TestPdfViewerFragment : PdfViewerFragment() {
     private var hostView: FrameLayout? = null
     private var search: FloatingActionButton? = null
-    private var fullScreen: FloatingActionButton? = null
-    private var isImmersiveModeEnabled = false
-
     val pdfLoadingIdlingResource = PdfIdlingResource(PDF_LOAD_RESOURCE_NAME)
 
     var documentLoaded = false
     var documentError: Throwable? = null
 
-    private var openPdfButton: MaterialButton? = null
-    private var searchButton: MaterialButton? = null
-
     override fun onCreateView(
         inflater: LayoutInflater,
         container: ViewGroup?,
@@ -56,7 +51,6 @@
         // Inflate the custom layout for this fragment
         hostView = inflater.inflate(R.layout.fragment_host, container, false) as FrameLayout
         search = hostView?.findViewById(R.id.host_Search)
-        fullScreen = hostView?.findViewById(R.id.toggle_full_screen)
 
         hostView?.let { hostView -> handleInsets(hostView) }
 
@@ -68,41 +62,12 @@
 
         // Set up search button click listener
         search?.setOnClickListener { isTextSearchActive = true }
-        fullScreen?.setOnClickListener { toggleImmersiveMode() }
         return hostView
     }
 
-    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
-        super.onViewCreated(view, savedInstanceState)
-        openPdfButton = activity?.findViewById(R.id.open_pdf)
-        searchButton = activity?.findViewById(R.id.search_button)
-    }
-
-    private fun toggleImmersiveMode() {
-        isImmersiveModeEnabled = !isImmersiveModeEnabled
-        updateSystemUi(!isImmersiveModeEnabled)
-        updateBottomButtonsVisibility(!isImmersiveModeEnabled)
-        onRequestImmersiveMode(isImmersiveModeEnabled)
-    }
-
-    private fun updateSystemUi(showSystemUi: Boolean) {
-        val insetsController =
-            WindowCompat.getInsetsController(requireActivity().window, hostView!!)
-        if (showSystemUi) {
-            insetsController.show(WindowInsetsCompat.Type.systemBars())
-        } else {
-            insetsController.hide(WindowInsetsCompat.Type.systemBars())
-        }
-    }
-
-    private fun updateBottomButtonsVisibility(visibility: Boolean) {
-        if (visibility) {
-            openPdfButton?.visibility = View.VISIBLE
-            searchButton?.visibility = View.VISIBLE
-        } else {
-            openPdfButton?.visibility = View.GONE
-            searchButton?.visibility = View.GONE
-        }
+    override fun onRequestImmersiveMode(enterImmersive: Boolean) {
+        super.onRequestImmersiveMode(enterImmersive)
+        if (!enterImmersive) search?.show() else search?.hide()
     }
 
     override fun onLoadDocumentSuccess() {
diff --git a/pdf/integration-tests/testapp/src/main/kotlin/androidx/pdf/testapp/ui/BasicPdfFragment.kt b/pdf/integration-tests/testapp/src/main/kotlin/androidx/pdf/testapp/ui/BasicPdfFragment.kt
index 77e9c23..6458039 100644
--- a/pdf/integration-tests/testapp/src/main/kotlin/androidx/pdf/testapp/ui/BasicPdfFragment.kt
+++ b/pdf/integration-tests/testapp/src/main/kotlin/androidx/pdf/testapp/ui/BasicPdfFragment.kt
@@ -17,13 +17,17 @@
 package androidx.pdf.testapp.ui
 
 import android.annotation.SuppressLint
+import android.content.Intent
 import android.net.Uri
+import android.os.Build
 import android.os.Bundle
+import android.os.ext.SdkExtensions
 import android.view.LayoutInflater
 import android.view.View
 import android.view.ViewGroup
 import androidx.activity.result.ActivityResultLauncher
 import androidx.activity.result.contract.ActivityResultContracts.GetContent
+import androidx.annotation.RequiresExtension
 import androidx.annotation.RestrictTo
 import androidx.annotation.VisibleForTesting
 import androidx.fragment.app.Fragment
@@ -43,11 +47,19 @@
     var filePicker: ActivityResultLauncher<String> =
         registerForActivityResult(GetContent()) { uri: Uri? ->
             uri?.let {
-                if (!isPdfViewInitialized) {
-                    setPdfView()
-                    isPdfViewInitialized = true
+                if (SdkExtensions.getExtensionVersion(Build.VERSION_CODES.S) >= 13) {
+                    if (!isPdfViewInitialized) {
+                        setPdfView()
+                        isPdfViewInitialized = true
+                    }
+                    pdfViewerFragment?.documentUri = uri
+                } else {
+                    /**
+                     * Send an intent to other apps who support opening PDFs in case PdfViewer
+                     * library is not supported due to SdkExtension limitations.
+                     */
+                    sendIntentToOpenPdf(uri)
                 }
-                pdfViewerFragment?.documentUri = uri
             }
         }
 
@@ -72,11 +84,23 @@
         val searchButton: MaterialButton = pdfInteraction.searchButton
 
         getContentButton.setOnClickListener { filePicker.launch(MIME_TYPE_PDF) }
-        searchButton.setOnClickListener { setFindInFileViewVisible() }
-
+        if (SdkExtensions.getExtensionVersion(Build.VERSION_CODES.S) >= 13) {
+            searchButton.setOnClickListener { setFindInFileViewVisible() }
+        }
         return pdfInteraction.root
     }
 
+    private fun sendIntentToOpenPdf(uri: Uri) {
+        val intent =
+            Intent(Intent.ACTION_VIEW).apply {
+                setDataAndType(uri, "application/pdf")
+                flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NO_HISTORY
+            }
+        val chooser = Intent.createChooser(intent, "Open PDF")
+        startActivity(chooser)
+    }
+
+    @RequiresExtension(extension = Build.VERSION_CODES.S, version = 13)
     private fun setPdfView() {
         val fragmentManager: FragmentManager = childFragmentManager
 
@@ -92,6 +116,7 @@
         fragmentManager.executePendingTransactions()
     }
 
+    @RequiresExtension(extension = Build.VERSION_CODES.S, version = 13)
     private fun setFindInFileViewVisible() {
         if (pdfViewerFragment != null) {
             pdfViewerFragment!!.isTextSearchActive = true
diff --git a/pdf/integration-tests/testapp/src/main/kotlin/androidx/pdf/testapp/ui/HostFragment.kt b/pdf/integration-tests/testapp/src/main/kotlin/androidx/pdf/testapp/ui/HostFragment.kt
index 0f7fb28..3f81543 100644
--- a/pdf/integration-tests/testapp/src/main/kotlin/androidx/pdf/testapp/ui/HostFragment.kt
+++ b/pdf/integration-tests/testapp/src/main/kotlin/androidx/pdf/testapp/ui/HostFragment.kt
@@ -16,17 +16,16 @@
 
 package androidx.pdf.testapp.ui
 
+import android.os.Build
 import android.os.Bundle
 import android.view.LayoutInflater
 import android.view.View
 import android.view.ViewGroup
 import android.widget.FrameLayout
+import androidx.annotation.RequiresExtension
 import androidx.core.os.OperationCanceledException
-import androidx.core.view.WindowCompat
-import androidx.core.view.WindowInsetsCompat
 import androidx.pdf.testapp.R
 import androidx.pdf.viewer.fragment.PdfViewerFragment
-import com.google.android.material.button.MaterialButton
 import com.google.android.material.floatingactionbutton.FloatingActionButton
 
 /**
@@ -34,14 +33,10 @@
  * adds a FloatingActionButton for search functionality and manages its visibility based on the
  * immersive mode state.
  */
+@RequiresExtension(extension = Build.VERSION_CODES.S, version = 13)
 class HostFragment : PdfViewerFragment() {
     private var hostView: FrameLayout? = null
     private var search: FloatingActionButton? = null
-    private var fullScreen: FloatingActionButton? = null
-    private var isImmersiveModeEnabled = false
-
-    private var openPdfButton: MaterialButton? = null
-    private var searchButton: MaterialButton? = null
 
     override fun onCreateView(
         inflater: LayoutInflater,
@@ -53,7 +48,6 @@
         // Inflate the custom layout for this fragment.
         hostView = inflater.inflate(R.layout.fragment_host, container, false) as FrameLayout
         search = hostView?.findViewById(R.id.host_Search)
-        fullScreen = hostView?.findViewById(R.id.toggle_full_screen)
 
         // Add the default PDF viewer to the custom layout
         hostView?.addView(view)
@@ -63,41 +57,12 @@
 
         // Set up search button click listener
         search?.setOnClickListener { isTextSearchActive = true }
-        fullScreen?.setOnClickListener { toggleImmersiveMode() }
         return hostView
     }
 
-    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
-        super.onViewCreated(view, savedInstanceState)
-        openPdfButton = activity?.findViewById(R.id.open_pdf)
-        searchButton = activity?.findViewById(R.id.search_button)
-    }
-
-    private fun toggleImmersiveMode() {
-        isImmersiveModeEnabled = !isImmersiveModeEnabled
-        updateSystemUi(!isImmersiveModeEnabled)
-        updateBottomButtonsVisibility(!isImmersiveModeEnabled)
-        onRequestImmersiveMode(isImmersiveModeEnabled)
-    }
-
-    private fun updateSystemUi(showSystemUi: Boolean) {
-        val insetsController =
-            WindowCompat.getInsetsController(requireActivity().window, hostView!!)
-        if (showSystemUi) {
-            insetsController.show(WindowInsetsCompat.Type.systemBars())
-        } else {
-            insetsController.hide(WindowInsetsCompat.Type.systemBars())
-        }
-    }
-
-    private fun updateBottomButtonsVisibility(visibility: Boolean) {
-        if (visibility) {
-            openPdfButton?.visibility = View.VISIBLE
-            searchButton?.visibility = View.VISIBLE
-        } else {
-            openPdfButton?.visibility = View.GONE
-            searchButton?.visibility = View.GONE
-        }
+    override fun onRequestImmersiveMode(enterImmersive: Boolean) {
+        super.onRequestImmersiveMode(enterImmersive)
+        if (!enterImmersive) search?.show() else search?.hide()
     }
 
     override fun onLoadDocumentError(error: Throwable) {
diff --git a/pdf/integration-tests/testapp/src/main/res/drawable/ic_action_full_screen.xml b/pdf/integration-tests/testapp/src/main/res/drawable/ic_action_full_screen.xml
deleted file mode 100644
index 568be42..0000000
--- a/pdf/integration-tests/testapp/src/main/res/drawable/ic_action_full_screen.xml
+++ /dev/null
@@ -1,26 +0,0 @@
-<?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.
-  -->
-
-<vector xmlns:android="http://schemas.android.com/apk/res/android"
-    android:height="24dp"
-    android:tint="#000000"
-    android:viewportHeight="24"
-    android:viewportWidth="24"
-    android:width="24dp">
-    <path
-        android:fillColor="@android:color/white"
-        android:pathData="M7,14L5,14v5h5v-2L7,17v-3zM5,10h2L7,7h3L10,5L5,5v5zM17,17h-3v2h5v-5h-2v3zM14,5v2h3v3h2L19,5h-5z"/>
-</vector>
diff --git a/pdf/integration-tests/testapp/src/main/res/layout/fragment_host.xml b/pdf/integration-tests/testapp/src/main/res/layout/fragment_host.xml
index dd59059..e6deb4d 100644
--- a/pdf/integration-tests/testapp/src/main/res/layout/fragment_host.xml
+++ b/pdf/integration-tests/testapp/src/main/res/layout/fragment_host.xml
@@ -35,17 +35,4 @@
         app:backgroundTint="?attr/colorSecondaryContainer"
         app:tint="?attr/colorOnSecondaryContainer" />
 
-    <com.google.android.material.floatingactionbutton.FloatingActionButton
-        android:id="@+id/toggle_full_screen"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:layout_marginBottom="145dp"
-        android:layout_marginEnd="16dp"
-        android:contentDescription="@string/toggle_full_screen"
-        android:layout_gravity="bottom|end"
-        android:visibility="visible"
-        android:src="@drawable/ic_action_full_screen"
-        app:backgroundTint="?attr/colorSecondaryContainer"
-        app:tint="?attr/colorOnSecondaryContainer" />
-
 </FrameLayout>
\ No newline at end of file
diff --git a/pdf/integration-tests/testapp/src/main/res/values/strings.xml b/pdf/integration-tests/testapp/src/main/res/values/strings.xml
index 972509c..514daf6 100644
--- a/pdf/integration-tests/testapp/src/main/res/values/strings.xml
+++ b/pdf/integration-tests/testapp/src/main/res/values/strings.xml
@@ -4,5 +4,4 @@
     <string name="search_string">Search</string>
     <string name="tab_view_string">Tab View</string>
     <string name="single_pdf_string">Single Pdf</string>
-    <string name="toggle_full_screen">Full Screen</string>
 </resources>
\ No newline at end of file
diff --git a/pdf/pdf-viewer-fragment/api/current.txt b/pdf/pdf-viewer-fragment/api/current.txt
index 752ae07..bfd8c88 100644
--- a/pdf/pdf-viewer-fragment/api/current.txt
+++ b/pdf/pdf-viewer-fragment/api/current.txt
@@ -1,7 +1,7 @@
 // Signature format: 4.0
 package androidx.pdf.viewer.fragment {
 
-  public class PdfViewerFragment extends androidx.fragment.app.Fragment {
+  @RequiresExtension(extension=android.os.Build.VERSION_CODES.S, version=13) public class PdfViewerFragment extends androidx.fragment.app.Fragment {
     ctor public PdfViewerFragment();
     method public final android.net.Uri? getDocumentUri();
     method public final boolean isTextSearchActive();
diff --git a/pdf/pdf-viewer-fragment/api/restricted_current.txt b/pdf/pdf-viewer-fragment/api/restricted_current.txt
index 752ae07..bfd8c88 100644
--- a/pdf/pdf-viewer-fragment/api/restricted_current.txt
+++ b/pdf/pdf-viewer-fragment/api/restricted_current.txt
@@ -1,7 +1,7 @@
 // Signature format: 4.0
 package androidx.pdf.viewer.fragment {
 
-  public class PdfViewerFragment extends androidx.fragment.app.Fragment {
+  @RequiresExtension(extension=android.os.Build.VERSION_CODES.S, version=13) public class PdfViewerFragment extends androidx.fragment.app.Fragment {
     ctor public PdfViewerFragment();
     method public final android.net.Uri? getDocumentUri();
     method public final boolean isTextSearchActive();
diff --git a/pdf/pdf-viewer-fragment/build.gradle b/pdf/pdf-viewer-fragment/build.gradle
index 8ca6798..902d23f 100644
--- a/pdf/pdf-viewer-fragment/build.gradle
+++ b/pdf/pdf-viewer-fragment/build.gradle
@@ -42,9 +42,9 @@
     namespace "androidx.pdf.viewer.fragment"
 
     defaultConfig {
-        minSdk 35 //TODO: Set to 31 after sdk extension 13 release
+        minSdk 31
         compileSdk 35
-        targetSdk 35 //TODO: Remove after sdk extension 13 release
+        targetSdk 31
     }
 }
 
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 ecabfe5..1dcd40e 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
@@ -19,12 +19,14 @@
 import android.content.ContentResolver
 import android.content.Context
 import android.net.Uri
+import android.os.Build
 import android.os.Bundle
 import android.view.LayoutInflater
 import android.view.View
 import android.view.ViewGroup
 import android.view.WindowManager
 import android.widget.FrameLayout
+import androidx.annotation.RequiresExtension
 import androidx.annotation.RestrictTo
 import androidx.core.os.BundleCompat
 import androidx.core.view.ViewCompat
@@ -98,6 +100,7 @@
  *
  * @see documentUri
  */
+@RequiresExtension(extension = Build.VERSION_CODES.S, version = 13)
 public open class PdfViewerFragment : Fragment() {
 
     // ViewModel to manage PdfLoader state
diff --git a/pdf/pdf-viewer/build.gradle b/pdf/pdf-viewer/build.gradle
index 09f9303..7ed8998 100644
--- a/pdf/pdf-viewer/build.gradle
+++ b/pdf/pdf-viewer/build.gradle
@@ -32,7 +32,6 @@
     implementation("androidx.annotation:annotation:1.7.0")
     implementation("com.google.android.material:material:1.11.0")
     implementation("com.google.errorprone:error_prone_annotations:2.30.0")
-    implementation project(':tracing:tracing')
 
     testImplementation(project(":pdf:pdf-viewer-fragment"))
     testImplementation(libs.junit)
@@ -63,9 +62,9 @@
     namespace "androidx.pdf"
 
     defaultConfig {
-        minSdk 35 //TODO: Set to 31 after sdk extension 13 release
+        minSdk 31
         compileSdk 35
-        targetSdk 35 //TODO: Remove after sdk extension 13 release
+        targetSdk 31
     }
 
     buildFeatures {
diff --git a/pdf/pdf-viewer/src/main/java/androidx/pdf/find/FindInFileView.java b/pdf/pdf-viewer/src/main/java/androidx/pdf/find/FindInFileView.java
index 9f2641e..88d0d78a 100644
--- a/pdf/pdf-viewer/src/main/java/androidx/pdf/find/FindInFileView.java
+++ b/pdf/pdf-viewer/src/main/java/androidx/pdf/find/FindInFileView.java
@@ -37,6 +37,7 @@
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import androidx.annotation.RestrictTo;
+import androidx.core.os.BundleCompat;
 import androidx.core.view.WindowCompat;
 import androidx.core.view.WindowInsetsCompat;
 import androidx.pdf.R;
@@ -291,15 +292,13 @@
     @Override
     protected void onRestoreInstanceState(Parcelable state) {
         Bundle bundle = (Bundle) state;
-        super.onRestoreInstanceState(bundle.getParcelable(KEY_SUPER, Parcelable.class));
-        // TextView Focus State
-        mFocus = bundle.getBoolean(KEY_FOCUS);
-        // SearchModel State
+        super.onRestoreInstanceState(
+                BundleCompat.getParcelable(bundle, KEY_SUPER, Parcelable.class));
         if (bundle.getBoolean(KEY_IS_SAVED)) {
             mIsRestoring = true;
             mSelectedMatch = bundle.getInt(KEY_SELECTED_INDEX);
             mViewingPage = bundle.getInt(KEY_SELECTED_PAGE);
-            mMatches = bundle.getParcelable(KEY_MATCH_RECTS, MatchRects.class);
+            mMatches = BundleCompat.getParcelable(bundle, KEY_MATCH_RECTS, MatchRects.class);
         }
     }
 
diff --git a/pdf/pdf-viewer/src/main/java/androidx/pdf/service/PdfPageAdapter.java b/pdf/pdf-viewer/src/main/java/androidx/pdf/service/PdfPageAdapter.java
index 31a36ca..067efb7 100644
--- a/pdf/pdf-viewer/src/main/java/androidx/pdf/service/PdfPageAdapter.java
+++ b/pdf/pdf-viewer/src/main/java/androidx/pdf/service/PdfPageAdapter.java
@@ -191,6 +191,7 @@
     private static void checkAndExecute(@NonNull Runnable block) {
         if (SdkExtensions.getExtensionVersion(Build.VERSION_CODES.S) >= 13) {
             block.run();
+            return;
         }
         throw new UnsupportedOperationException("Operation support above S");
     }
diff --git a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/loader/RemotePdfDocument.kt b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/loader/RemotePdfDocument.kt
index dde1420..7a308ac 100644
--- a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/loader/RemotePdfDocument.kt
+++ b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/loader/RemotePdfDocument.kt
@@ -22,10 +22,12 @@
 import android.graphics.pdf.models.PageMatchBounds
 import android.graphics.pdf.models.selection.PageSelection
 import android.graphics.pdf.models.selection.SelectionBoundary
+import android.os.Build
 import android.os.ParcelFileDescriptor
 import android.os.RemoteException
 import android.util.Size
 import android.util.SparseArray
+import androidx.annotation.RequiresExtension
 import androidx.annotation.RestrictTo
 import androidx.pdf.loader.PdfDocument.BitmapSource
 import androidx.pdf.loader.PdfDocument.PageInfo
@@ -128,6 +130,7 @@
      * @return A SparseArray mapping page numbers to [PageSelection] objects representing the
      *   selection bounds on each page.
      */
+    @RequiresExtension(extension = Build.VERSION_CODES.S, version = 13)
     override suspend fun getSelectionBounds(
         pageNumber: Int,
         start: PointF,
diff --git a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/service/PdfPageWrapper.kt b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/service/PdfPageWrapper.kt
index c1f600b..0f2e950 100644
--- a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/service/PdfPageWrapper.kt
+++ b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/service/PdfPageWrapper.kt
@@ -26,6 +26,8 @@
 import android.graphics.pdf.models.PageMatchBounds
 import android.graphics.pdf.models.selection.PageSelection
 import android.graphics.pdf.models.selection.SelectionBoundary
+import android.os.Build
+import androidx.annotation.RequiresExtension
 import androidx.annotation.RestrictTo
 
 @RestrictTo(RestrictTo.Scope.LIBRARY)
@@ -64,6 +66,7 @@
         }
     }
 
+    @RequiresExtension(extension = Build.VERSION_CODES.S, version = 13)
     fun getRenderParams(): RenderParams {
         return RenderParams.Builder(RenderParams.RENDER_MODE_FOR_DISPLAY)
             .setRenderFlags(
diff --git a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/service/PdfPageWrapperPostV.kt b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/service/PdfPageWrapperPostV.kt
index 00dd9b4..fe8a1c4 100644
--- a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/service/PdfPageWrapperPostV.kt
+++ b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/service/PdfPageWrapperPostV.kt
@@ -25,9 +25,14 @@
 import android.graphics.pdf.models.PageMatchBounds
 import android.graphics.pdf.models.selection.PageSelection
 import android.graphics.pdf.models.selection.SelectionBoundary
+import android.os.Build
+import androidx.annotation.RequiresApi
+import androidx.annotation.RequiresExtension
 import androidx.annotation.RestrictTo
 
 @RestrictTo(RestrictTo.Scope.LIBRARY)
+@RequiresExtension(extension = Build.VERSION_CODES.S, version = 13)
+@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
 internal class PdfPageWrapperPostV(private val page: PdfRenderer.Page) : PdfPageWrapper {
 
     override val height = page.height
diff --git a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/service/PdfPageWrapperPreV.kt b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/service/PdfPageWrapperPreV.kt
index e9a578b..06429d3 100644
--- a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/service/PdfPageWrapperPreV.kt
+++ b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/service/PdfPageWrapperPreV.kt
@@ -25,9 +25,12 @@
 import android.graphics.pdf.models.PageMatchBounds
 import android.graphics.pdf.models.selection.PageSelection
 import android.graphics.pdf.models.selection.SelectionBoundary
+import android.os.Build
+import androidx.annotation.RequiresExtension
 import androidx.annotation.RestrictTo
 
 @RestrictTo(RestrictTo.Scope.LIBRARY)
+@RequiresExtension(extension = Build.VERSION_CODES.S, version = 13)
 internal class PdfPageWrapperPreV(private val page: PdfRendererPreV.Page) : PdfPageWrapper {
 
     override val height = page.height
diff --git a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/service/PdfRendererWrapperFactory.kt b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/service/PdfRendererWrapperFactory.kt
index a840c8e..b011cbf 100644
--- a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/service/PdfRendererWrapperFactory.kt
+++ b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/service/PdfRendererWrapperFactory.kt
@@ -25,7 +25,10 @@
 @Suppress("ObsoleteSdkInt")
 internal object PdfRendererWrapperFactory {
     fun create(pfd: ParcelFileDescriptor, password: String?): PdfRendererWrapper {
-        return if (Build.VERSION.SDK_INT >= 35) {
+        return if (
+            Build.VERSION.SDK_INT >= 35 &&
+                SdkExtensions.getExtensionVersion(Build.VERSION_CODES.S) >= 13
+        ) {
             PdfRendererWrapperPostV(pfd, password.orEmpty())
         } else if (SdkExtensions.getExtensionVersion(Build.VERSION_CODES.S) >= 13) {
             PdfRendererWrapperPreV(pfd, password.orEmpty())
diff --git a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/service/PdfRendererWrapperPostV.kt b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/service/PdfRendererWrapperPostV.kt
index 22b3784..f8ed38f 100644
--- a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/service/PdfRendererWrapperPostV.kt
+++ b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/service/PdfRendererWrapperPostV.kt
@@ -19,10 +19,15 @@
 
 import android.graphics.pdf.LoadParams
 import android.graphics.pdf.PdfRenderer
+import android.os.Build
 import android.os.ParcelFileDescriptor
+import androidx.annotation.RequiresApi
+import androidx.annotation.RequiresExtension
 import androidx.annotation.RestrictTo
 import java.util.concurrent.ConcurrentHashMap
 
+@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
+@RequiresExtension(extension = Build.VERSION_CODES.S, version = 13)
 @RestrictTo(RestrictTo.Scope.LIBRARY)
 internal class PdfRendererWrapperPostV(
     parcelFileDescriptor: ParcelFileDescriptor,
diff --git a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/service/PdfRendererWrapperPreV.kt b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/service/PdfRendererWrapperPreV.kt
index 6f67f048..ff6efb3 100644
--- a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/service/PdfRendererWrapperPreV.kt
+++ b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/service/PdfRendererWrapperPreV.kt
@@ -19,10 +19,13 @@
 
 import android.graphics.pdf.LoadParams
 import android.graphics.pdf.PdfRendererPreV
+import android.os.Build
 import android.os.ParcelFileDescriptor
+import androidx.annotation.RequiresExtension
 import androidx.annotation.RestrictTo
 import java.util.concurrent.ConcurrentHashMap
 
+@RequiresExtension(extension = Build.VERSION_CODES.S, version = 13)
 @RestrictTo(RestrictTo.Scope.LIBRARY)
 internal class PdfRendererWrapperPreV(
     parcelFileDescriptor: ParcelFileDescriptor,
diff --git a/pdf/pdf-viewer/src/test/java/androidx/pdf/ActivityUtils.kt b/pdf/pdf-viewer/src/test/java/androidx/pdf/ActivityUtils.kt
new file mode 100644
index 0000000..42406b8
--- /dev/null
+++ b/pdf/pdf-viewer/src/test/java/androidx/pdf/ActivityUtils.kt
@@ -0,0 +1,30 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.pdf
+
+import android.app.Activity
+import com.google.android.material.R
+import org.robolectric.Robolectric
+
+object ActivityUtils {
+    /* Provides a custom Activity with Material3 Theme, to test Views with Material 3 attributes */
+    fun getThemedActivity(): Activity {
+        val activity = Robolectric.buildActivity(Activity::class.java).create().get()
+        activity.setTheme(R.style.Theme_Material3_DayNight_NoActionBar)
+        return activity
+    }
+}
diff --git a/pdf/pdf-viewer/src/test/java/androidx/pdf/aidl/LinkRectsTest.java b/pdf/pdf-viewer/src/test/java/androidx/pdf/aidl/LinkRectsTest.java
index d8fe68f..3c3ebdb 100644
--- a/pdf/pdf-viewer/src/test/java/androidx/pdf/aidl/LinkRectsTest.java
+++ b/pdf/pdf-viewer/src/test/java/androidx/pdf/aidl/LinkRectsTest.java
@@ -21,7 +21,6 @@
 import static org.junit.Assert.assertTrue;
 
 import android.graphics.Rect;
-import android.os.Build;
 
 import androidx.pdf.models.LinkRects;
 import androidx.test.filters.SmallTest;
@@ -29,7 +28,6 @@
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.robolectric.RobolectricTestRunner;
-import org.robolectric.annotation.Config;
 
 import java.lang.reflect.Field;
 import java.util.ArrayList;
@@ -38,8 +36,6 @@
 
 @SmallTest
 @RunWith(RobolectricTestRunner.class)
-//TODO: Remove minsdk check after sdk extension 13 release
-@Config(minSdk = Build.VERSION_CODES.VANILLA_ICE_CREAM)
 public class LinkRectsTest {
 
     private LinkRects mLinkRects = createLinkRects(5, new Integer[]{0, 2, 3},
diff --git a/pdf/pdf-viewer/src/test/java/androidx/pdf/aidl/MatchRectsTest.java b/pdf/pdf-viewer/src/test/java/androidx/pdf/aidl/MatchRectsTest.java
index add2079..059e0b2 100644
--- a/pdf/pdf-viewer/src/test/java/androidx/pdf/aidl/MatchRectsTest.java
+++ b/pdf/pdf-viewer/src/test/java/androidx/pdf/aidl/MatchRectsTest.java
@@ -21,7 +21,6 @@
 import static org.junit.Assert.assertTrue;
 
 import android.graphics.Rect;
-import android.os.Build;
 
 import androidx.pdf.models.MatchRects;
 import androidx.test.filters.SmallTest;
@@ -29,7 +28,6 @@
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.robolectric.RobolectricTestRunner;
-import org.robolectric.annotation.Config;
 
 import java.lang.reflect.Field;
 import java.util.ArrayList;
@@ -38,8 +36,6 @@
 
 @SmallTest
 @RunWith(RobolectricTestRunner.class)
-//TODO: Remove minsdk check after sdk extension 13 release
-@Config(minSdk = Build.VERSION_CODES.VANILLA_ICE_CREAM)
 public class MatchRectsTest {
 
     private MatchRects mMatchRects = createMatchRects(5, 0, 2, 3);
diff --git a/pdf/pdf-viewer/src/test/java/androidx/pdf/aidl/SelectionBoundaryTest.java b/pdf/pdf-viewer/src/test/java/androidx/pdf/aidl/SelectionBoundaryTest.java
index 12822be..c1b7171 100644
--- a/pdf/pdf-viewer/src/test/java/androidx/pdf/aidl/SelectionBoundaryTest.java
+++ b/pdf/pdf-viewer/src/test/java/androidx/pdf/aidl/SelectionBoundaryTest.java
@@ -21,7 +21,6 @@
 import static org.junit.Assert.assertTrue;
 
 import android.graphics.Point;
-import android.os.Build;
 
 import androidx.pdf.models.SelectionBoundary;
 import androidx.test.filters.SmallTest;
@@ -29,7 +28,6 @@
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.robolectric.RobolectricTestRunner;
-import org.robolectric.annotation.Config;
 
 import java.lang.reflect.Field;
 import java.util.ArrayList;
@@ -37,14 +35,13 @@
 
 @SmallTest
 @RunWith(RobolectricTestRunner.class)
-//TODO: Remove minsdk check after sdk extension 13 release
-@Config(minSdk = Build.VERSION_CODES.VANILLA_ICE_CREAM)
 public class SelectionBoundaryTest {
 
     @Test
     public void testAtIndex_selectionBoundaryCreatedWithCorrectValues() {
         assertThat(SelectionBoundary.atIndex(4)).isEqualTo(new SelectionBoundary(4, -1, -1, false));
     }
+
     @Test
     public void testAtPoint_selectionBoundaryCreatedWithCorrectValues() {
         assertThat(SelectionBoundary.atPoint(new Point(3, 4))).isEqualTo(
@@ -56,6 +53,7 @@
         assertThat(SelectionBoundary.atPoint(1, 2)).isEqualTo(
                 new SelectionBoundary(-1, 1, 2, false));
     }
+
     @Test
     public void testClassFields() {
         List<String> fields = new ArrayList<>();
diff --git a/pdf/pdf-viewer/src/test/java/androidx/pdf/data/ContentOpenableTest.java b/pdf/pdf-viewer/src/test/java/androidx/pdf/data/ContentOpenableTest.java
index 0707999..bd49042 100644
--- a/pdf/pdf-viewer/src/test/java/androidx/pdf/data/ContentOpenableTest.java
+++ b/pdf/pdf-viewer/src/test/java/androidx/pdf/data/ContentOpenableTest.java
@@ -19,7 +19,6 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import android.net.Uri;
-import android.os.Build;
 import android.os.Parcel;
 
 import androidx.pdf.models.Dimensions;
@@ -28,13 +27,10 @@
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.robolectric.RobolectricTestRunner;
-import org.robolectric.annotation.Config;
 
 /** Tests for {@link ContentOpenable}. */
 @SmallTest
 @RunWith(RobolectricTestRunner.class)
-//TODO: Remove minsdk check after sdk extension 13 release
-@Config(minSdk = Build.VERSION_CODES.VANILLA_ICE_CREAM)
 public class ContentOpenableTest {
 
     private ContentOpenable mContentOpenable;
diff --git a/pdf/pdf-viewer/src/test/java/androidx/pdf/data/FileOpenableTest.java b/pdf/pdf-viewer/src/test/java/androidx/pdf/data/FileOpenableTest.java
index ddf3f1f..d0c961a 100644
--- a/pdf/pdf-viewer/src/test/java/androidx/pdf/data/FileOpenableTest.java
+++ b/pdf/pdf-viewer/src/test/java/androidx/pdf/data/FileOpenableTest.java
@@ -19,7 +19,6 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import android.net.Uri;
-import android.os.Build;
 import android.os.Parcel;
 import android.os.Parcelable;
 import android.os.Parcelable.Creator;
@@ -27,7 +26,6 @@
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.robolectric.RobolectricTestRunner;
-import org.robolectric.annotation.Config;
 
 import java.io.File;
 import java.io.FileNotFoundException;
@@ -36,8 +34,6 @@
  * Tests for {@link FileOpenable}.
  */
 @RunWith(RobolectricTestRunner.class)
-//TODO: Remove minsdk check after sdk extension 13 release
-@Config(minSdk = Build.VERSION_CODES.VANILLA_ICE_CREAM)
 public class FileOpenableTest {
 
     private static final String PDF_MIME_TYPE = "application/pdf";
diff --git a/pdf/pdf-viewer/src/test/java/androidx/pdf/data/RangeTest.java b/pdf/pdf-viewer/src/test/java/androidx/pdf/data/RangeTest.java
index b44eecb..e781107 100644
--- a/pdf/pdf-viewer/src/test/java/androidx/pdf/data/RangeTest.java
+++ b/pdf/pdf-viewer/src/test/java/androidx/pdf/data/RangeTest.java
@@ -18,20 +18,15 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import android.os.Build;
-
 import androidx.test.filters.SmallTest;
 
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.robolectric.RobolectricTestRunner;
-import org.robolectric.annotation.Config;
 
 /** Unit tests for {@link Range}. */
 @SmallTest
 @RunWith(RobolectricTestRunner.class)
-//TODO: Remove minsdk check after sdk extension 13 release
-@Config(minSdk = Build.VERSION_CODES.VANILLA_ICE_CREAM)
 public class RangeTest {
 
     @Test
diff --git a/pdf/pdf-viewer/src/test/java/androidx/pdf/find/FindInFileViewTest.java b/pdf/pdf-viewer/src/test/java/androidx/pdf/find/FindInFileViewTest.java
index 1135636..65d7dfb 100644
--- a/pdf/pdf-viewer/src/test/java/androidx/pdf/find/FindInFileViewTest.java
+++ b/pdf/pdf-viewer/src/test/java/androidx/pdf/find/FindInFileViewTest.java
@@ -24,12 +24,10 @@
 import static org.mockito.ArgumentMatchers.anyInt;
 import static org.mockito.Mockito.doNothing;
 
-import android.os.Build;
-
+import androidx.pdf.ActivityUtils;
 import androidx.pdf.viewer.ImmersiveModeRequester;
 import androidx.pdf.viewer.PaginatedView;
 import androidx.pdf.viewer.loader.PdfLoader;
-import androidx.test.core.app.ApplicationProvider;
 import androidx.test.filters.SmallTest;
 
 import com.google.android.material.floatingactionbutton.FloatingActionButton;
@@ -43,15 +41,12 @@
 import org.mockito.Mock;
 import org.mockito.MockitoAnnotations;
 import org.robolectric.RobolectricTestRunner;
-import org.robolectric.annotation.Config;
 
 /**
  * Unit tests for {@link FindInFileView}
  */
 @SmallTest
 @RunWith(RobolectricTestRunner.class)
-//TODO: Remove minsdk check after sdk extension 13 release
-@Config(minSdk = Build.VERSION_CODES.VANILLA_ICE_CREAM)
 public class FindInFileViewTest extends TestCase {
     @Mock
     private PdfLoader mPdfLoader;
@@ -67,7 +62,7 @@
     @Before
     public void setUp() throws Exception {
         mOpenMocks = MockitoAnnotations.openMocks(this);
-        mFindInFileView = new FindInFileView(ApplicationProvider.getApplicationContext());
+        mFindInFileView = new FindInFileView(ActivityUtils.INSTANCE.getThemedActivity());
         mFindInFileView.setPdfLoader(mPdfLoader);
         mFindInFileView.setPaginatedView(mPaginatedView);
         mFindInFileView.setAnnotationButton(mAnnotationButton, mImmersiveModeRequester);
diff --git a/pdf/pdf-viewer/src/test/java/androidx/pdf/find/MatchCountTest.java b/pdf/pdf-viewer/src/test/java/androidx/pdf/find/MatchCountTest.java
index 2865be0..6b25e5f 100644
--- a/pdf/pdf-viewer/src/test/java/androidx/pdf/find/MatchCountTest.java
+++ b/pdf/pdf-viewer/src/test/java/androidx/pdf/find/MatchCountTest.java
@@ -18,16 +18,11 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import android.os.Build;
-
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.robolectric.RobolectricTestRunner;
-import org.robolectric.annotation.Config;
 
 @RunWith(RobolectricTestRunner.class)
-//TODO: Remove minsdk check after sdk extension 13 release
-@Config(minSdk = Build.VERSION_CODES.VANILLA_ICE_CREAM)
 public class MatchCountTest {
     private final MatchCount mMatchCount1 =
             new MatchCount(2, 10, true);
diff --git a/pdf/pdf-viewer/src/test/java/androidx/pdf/select/SelectionActionModeTest.java b/pdf/pdf-viewer/src/test/java/androidx/pdf/select/SelectionActionModeTest.java
index 3926e1c..e3b531f 100644
--- a/pdf/pdf-viewer/src/test/java/androidx/pdf/select/SelectionActionModeTest.java
+++ b/pdf/pdf-viewer/src/test/java/androidx/pdf/select/SelectionActionModeTest.java
@@ -22,7 +22,6 @@
 import static org.mockito.Mockito.when;
 
 import android.content.Context;
-import android.os.Build;
 
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
@@ -37,10 +36,7 @@
 import org.junit.Test;
 import org.mockito.Mock;
 import org.mockito.MockitoAnnotations;
-import org.robolectric.annotation.Config;
 
-//TODO: Remove minsdk check after sdk extension 13 release
-@Config(minSdk = Build.VERSION_CODES.VANILLA_ICE_CREAM)
 public class SelectionActionModeTest {
 
     @Mock
@@ -99,6 +95,7 @@
                     }
                 };
             }
+
             @NonNull
             @Override
             public String getText() {
diff --git a/pdf/pdf-viewer/src/test/java/androidx/pdf/util/AnnotationUtilsTest.java b/pdf/pdf-viewer/src/test/java/androidx/pdf/util/AnnotationUtilsTest.java
index 8bef569..63f84b5 100644
--- a/pdf/pdf-viewer/src/test/java/androidx/pdf/util/AnnotationUtilsTest.java
+++ b/pdf/pdf-viewer/src/test/java/androidx/pdf/util/AnnotationUtilsTest.java
@@ -31,7 +31,6 @@
 import android.content.pm.PackageManager;
 import android.content.pm.ResolveInfo;
 import android.net.Uri;
-import android.os.Build;
 
 import androidx.test.core.app.ApplicationProvider;
 import androidx.test.filters.SmallTest;
@@ -40,14 +39,11 @@
 import org.junit.runner.RunWith;
 import org.mockito.Mockito;
 import org.robolectric.RobolectricTestRunner;
-import org.robolectric.annotation.Config;
 
 import java.io.File;
 
 @SmallTest
 @RunWith(RobolectricTestRunner.class)
-//TODO: Remove minsdk check after sdk extension 13 release
-@Config(minSdk = Build.VERSION_CODES.VANILLA_ICE_CREAM)
 public class AnnotationUtilsTest {
     @Test
     public void getAnnotationIntent_nonNullUri_returnsAnnotateActionIntent() {
diff --git a/pdf/pdf-viewer/src/test/java/androidx/pdf/util/BundleUtilsTest.java b/pdf/pdf-viewer/src/test/java/androidx/pdf/util/BundleUtilsTest.java
index da30eda..196a81f 100644
--- a/pdf/pdf-viewer/src/test/java/androidx/pdf/util/BundleUtilsTest.java
+++ b/pdf/pdf-viewer/src/test/java/androidx/pdf/util/BundleUtilsTest.java
@@ -18,7 +18,6 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import android.os.Build;
 import android.os.Bundle;
 
 import androidx.test.filters.SmallTest;
@@ -26,15 +25,12 @@
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.robolectric.RobolectricTestRunner;
-import org.robolectric.annotation.Config;
 
 import java.util.Map;
 
 /** Tests for {@link BundleUtils}. */
 @SmallTest
 @RunWith(RobolectricTestRunner.class)
-//TODO: Remove minsdk check after sdk extension 13 release
-@Config(minSdk = Build.VERSION_CODES.VANILLA_ICE_CREAM)
 public class BundleUtilsTest {
 
     private static final String KEY_1 = "Key1";
diff --git a/pdf/pdf-viewer/src/test/java/androidx/pdf/util/CollectUtilsTest.java b/pdf/pdf-viewer/src/test/java/androidx/pdf/util/CollectUtilsTest.java
index 18fb8f8..5c44ea4 100644
--- a/pdf/pdf-viewer/src/test/java/androidx/pdf/util/CollectUtilsTest.java
+++ b/pdf/pdf-viewer/src/test/java/androidx/pdf/util/CollectUtilsTest.java
@@ -21,7 +21,6 @@
 
 import static org.junit.Assert.fail;
 
-import android.os.Build;
 import android.util.SparseArray;
 
 import androidx.test.filters.SmallTest;
@@ -29,15 +28,12 @@
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.robolectric.RobolectricTestRunner;
-import org.robolectric.annotation.Config;
 
 import java.util.Iterator;
 import java.util.NoSuchElementException;
 
 @SmallTest
 @RunWith(RobolectricTestRunner.class)
-//TODO: Remove minsdk check after sdk extension 13 release
-@Config(minSdk = Build.VERSION_CODES.VANILLA_ICE_CREAM)
 public class CollectUtilsTest {
 
     @Test
diff --git a/pdf/pdf-viewer/src/test/java/androidx/pdf/util/CycleRangeTest.java b/pdf/pdf-viewer/src/test/java/androidx/pdf/util/CycleRangeTest.java
index c54bdee..e2210be 100644
--- a/pdf/pdf-viewer/src/test/java/androidx/pdf/util/CycleRangeTest.java
+++ b/pdf/pdf-viewer/src/test/java/androidx/pdf/util/CycleRangeTest.java
@@ -22,14 +22,11 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import android.os.Build;
-
 import androidx.test.filters.SmallTest;
 
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.robolectric.RobolectricTestRunner;
-import org.robolectric.annotation.Config;
 
 import java.util.ArrayList;
 import java.util.Arrays;
@@ -37,8 +34,6 @@
 
 @SmallTest
 @RunWith(RobolectricTestRunner.class)
-//TODO: Remove minsdk check after sdk extension 13 release
-@Config(minSdk = Build.VERSION_CODES.VANILLA_ICE_CREAM)
 public class CycleRangeTest {
 
     @Test
diff --git a/pdf/pdf-viewer/src/test/java/androidx/pdf/util/EnumKeyGeneratorTest.java b/pdf/pdf-viewer/src/test/java/androidx/pdf/util/EnumKeyGeneratorTest.java
index 95c71367..308fe09 100644
--- a/pdf/pdf-viewer/src/test/java/androidx/pdf/util/EnumKeyGeneratorTest.java
+++ b/pdf/pdf-viewer/src/test/java/androidx/pdf/util/EnumKeyGeneratorTest.java
@@ -18,8 +18,6 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import android.os.Build;
-
 import androidx.test.filters.SmallTest;
 
 import com.google.common.collect.Lists;
@@ -27,7 +25,6 @@
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.robolectric.RobolectricTestRunner;
-import org.robolectric.annotation.Config;
 
 import java.util.ArrayList;
 import java.util.List;
@@ -35,8 +32,6 @@
 /** Unit tests for {@link EnumKeyGenerator}. */
 @SmallTest
 @RunWith(RobolectricTestRunner.class)
-//TODO: Remove minsdk check after sdk extension 13 release
-@Config(minSdk = Build.VERSION_CODES.VANILLA_ICE_CREAM)
 public class EnumKeyGeneratorTest {
 
     enum Animal {
diff --git a/pdf/pdf-viewer/src/test/java/androidx/pdf/util/ExposedArrayTest.java b/pdf/pdf-viewer/src/test/java/androidx/pdf/util/ExposedArrayTest.java
index 962539f..a96cf93 100644
--- a/pdf/pdf-viewer/src/test/java/androidx/pdf/util/ExposedArrayTest.java
+++ b/pdf/pdf-viewer/src/test/java/androidx/pdf/util/ExposedArrayTest.java
@@ -21,8 +21,6 @@
 
 import static org.junit.Assert.fail;
 
-import android.os.Build;
-
 import androidx.pdf.util.ObservableArray.ArrayObserver;
 import androidx.pdf.util.Observables.ExposedArray;
 import androidx.test.filters.SmallTest;
@@ -31,12 +29,9 @@
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.robolectric.RobolectricTestRunner;
-import org.robolectric.annotation.Config;
 
 @SmallTest
 @RunWith(RobolectricTestRunner.class)
-//TODO: Remove minsdk check after sdk extension 13 release
-@Config(minSdk = Build.VERSION_CODES.VANILLA_ICE_CREAM)
 public class ExposedArrayTest {
 
     private ExposedArray<String> mArray;
diff --git a/pdf/pdf-viewer/src/test/java/androidx/pdf/util/FutureValuesTest.java b/pdf/pdf-viewer/src/test/java/androidx/pdf/util/FutureValuesTest.java
index ca6a7ac..c62fa9e 100644
--- a/pdf/pdf-viewer/src/test/java/androidx/pdf/util/FutureValuesTest.java
+++ b/pdf/pdf-viewer/src/test/java/androidx/pdf/util/FutureValuesTest.java
@@ -23,8 +23,6 @@
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.verifyNoMoreInteractions;
 
-import android.os.Build;
-
 import androidx.annotation.NonNull;
 import androidx.pdf.data.FutureValue;
 import androidx.pdf.data.FutureValue.Callback;
@@ -44,13 +42,10 @@
 import org.mockito.Mock;
 import org.mockito.MockitoAnnotations;
 import org.robolectric.RobolectricTestRunner;
-import org.robolectric.annotation.Config;
 
 
 @SmallTest
 @RunWith(RobolectricTestRunner.class)
-//TODO: Remove minsdk check after sdk extension 13 release
-@Config(minSdk = Build.VERSION_CODES.VANILLA_ICE_CREAM)
 public class FutureValuesTest {
 
     @Mock
diff --git a/pdf/pdf-viewer/src/test/java/androidx/pdf/util/GestureTrackerTest.java b/pdf/pdf-viewer/src/test/java/androidx/pdf/util/GestureTrackerTest.java
index f3d43dd..e0a2ce1 100644
--- a/pdf/pdf-viewer/src/test/java/androidx/pdf/util/GestureTrackerTest.java
+++ b/pdf/pdf-viewer/src/test/java/androidx/pdf/util/GestureTrackerTest.java
@@ -25,7 +25,6 @@
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.verifyNoMoreInteractions;
 
-import android.os.Build;
 import android.view.MotionEvent;
 
 import androidx.pdf.util.GestureTracker.Gesture;
@@ -41,15 +40,12 @@
 import org.mockito.Mock;
 import org.mockito.MockitoAnnotations;
 import org.robolectric.RobolectricTestRunner;
-import org.robolectric.annotation.Config;
 
 import java.util.AbstractList;
 import java.util.List;
 
 @SmallTest
 @RunWith(RobolectricTestRunner.class)
-//TODO: Remove minsdk check after sdk extension 13 release
-@Config(minSdk = Build.VERSION_CODES.VANILLA_ICE_CREAM)
 public class GestureTrackerTest {
 
     private static final int TAP_TIMEOUT = 200;
diff --git a/pdf/pdf-viewer/src/test/java/androidx/pdf/util/IntentsTest.java b/pdf/pdf-viewer/src/test/java/androidx/pdf/util/IntentsTest.java
index 7549d58..b079038 100644
--- a/pdf/pdf-viewer/src/test/java/androidx/pdf/util/IntentsTest.java
+++ b/pdf/pdf-viewer/src/test/java/androidx/pdf/util/IntentsTest.java
@@ -27,7 +27,6 @@
 import android.content.Intent;
 import android.content.pm.PackageInfo;
 import android.content.pm.PackageManager;
-import android.os.Build;
 
 import androidx.test.core.app.ApplicationProvider;
 import androidx.test.filters.SmallTest;
@@ -38,14 +37,11 @@
 import org.mockito.Mock;
 import org.mockito.MockitoAnnotations;
 import org.robolectric.RobolectricTestRunner;
-import org.robolectric.annotation.Config;
 
 /** Tests for {@link Intents}. */
 @SmallTest
 @RunWith(RobolectricTestRunner.class)
 @SuppressWarnings("deprecation")
-//TODO: Remove minsdk check after sdk extension 13 release
-@Config(minSdk = Build.VERSION_CODES.VANILLA_ICE_CREAM)
 public class IntentsTest {
     Context mContext;
     PackageManager mPackageManager;
diff --git a/pdf/pdf-viewer/src/test/java/androidx/pdf/util/QuickScaleBypassDeciderTest.java b/pdf/pdf-viewer/src/test/java/androidx/pdf/util/QuickScaleBypassDeciderTest.java
index 85efab1..3825104 100644
--- a/pdf/pdf-viewer/src/test/java/androidx/pdf/util/QuickScaleBypassDeciderTest.java
+++ b/pdf/pdf-viewer/src/test/java/androidx/pdf/util/QuickScaleBypassDeciderTest.java
@@ -18,7 +18,6 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import android.os.Build;
 import android.view.MotionEvent;
 
 import androidx.test.filters.SmallTest;
@@ -27,13 +26,10 @@
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.robolectric.RobolectricTestRunner;
-import org.robolectric.annotation.Config;
 
 /** Unit tests for {@link QuickScaleBypassDecider}. */
 @SmallTest
 @RunWith(RobolectricTestRunner.class)
-//TODO: Remove minsdk check after sdk extension 13 release
-@Config(minSdk = Build.VERSION_CODES.VANILLA_ICE_CREAM)
 public class QuickScaleBypassDeciderTest {
 
     private static final long FIRST_DOWN_TIME_MS = 0;
diff --git a/pdf/pdf-viewer/src/test/java/androidx/pdf/util/SettableFutureValueTest.java b/pdf/pdf-viewer/src/test/java/androidx/pdf/util/SettableFutureValueTest.java
index 0f011f0..c8a5dae 100644
--- a/pdf/pdf-viewer/src/test/java/androidx/pdf/util/SettableFutureValueTest.java
+++ b/pdf/pdf-viewer/src/test/java/androidx/pdf/util/SettableFutureValueTest.java
@@ -21,8 +21,6 @@
 
 import static org.junit.Assert.fail;
 
-import android.os.Build;
-
 import androidx.annotation.NonNull;
 import androidx.pdf.data.FutureValue.Callback;
 import androidx.pdf.data.FutureValues;
@@ -32,12 +30,9 @@
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.robolectric.RobolectricTestRunner;
-import org.robolectric.annotation.Config;
 
 @SmallTest
 @RunWith(RobolectricTestRunner.class)
-//TODO: Remove minsdk check after sdk extension 13 release
-@Config(minSdk = Build.VERSION_CODES.VANILLA_ICE_CREAM)
 public class SettableFutureValueTest {
 
     private static final class TestCallback implements Callback<String> {
diff --git a/pdf/pdf-viewer/src/test/java/androidx/pdf/util/SystemGestureExclusionHelperTest.java b/pdf/pdf-viewer/src/test/java/androidx/pdf/util/SystemGestureExclusionHelperTest.java
index 4d0ddaf..21de554 100644
--- a/pdf/pdf-viewer/src/test/java/androidx/pdf/util/SystemGestureExclusionHelperTest.java
+++ b/pdf/pdf-viewer/src/test/java/androidx/pdf/util/SystemGestureExclusionHelperTest.java
@@ -26,7 +26,6 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import android.graphics.Rect;
-import android.os.Build;
 
 import androidx.test.filters.SmallTest;
 
@@ -34,15 +33,12 @@
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.robolectric.RobolectricTestRunner;
-import org.robolectric.annotation.Config;
 
 import java.util.List;
 
 /** Unit tests for {@link SystemGestureExclusionHelper}. */
 @SmallTest
 @RunWith(RobolectricTestRunner.class)
-//TODO: Remove minsdk check after sdk extension 13 release
-@Config(minSdk = Build.VERSION_CODES.VANILLA_ICE_CREAM)
 public class SystemGestureExclusionHelperTest {
 
     private static final int BUFFER_DISTANCE_ZERO = 0;
diff --git a/pdf/pdf-viewer/src/test/java/androidx/pdf/util/TileBoardTest.java b/pdf/pdf-viewer/src/test/java/androidx/pdf/util/TileBoardTest.java
index e9677c2..61e39e0 100644
--- a/pdf/pdf-viewer/src/test/java/androidx/pdf/util/TileBoardTest.java
+++ b/pdf/pdf-viewer/src/test/java/androidx/pdf/util/TileBoardTest.java
@@ -26,7 +26,6 @@
 import android.graphics.Bitmap;
 import android.graphics.Point;
 import android.graphics.Rect;
-import android.os.Build;
 
 import androidx.pdf.models.Dimensions;
 import androidx.pdf.util.TileBoard.CancelTilesCallback;
@@ -45,7 +44,6 @@
 import org.mockito.Mock;
 import org.mockito.MockitoAnnotations;
 import org.robolectric.RobolectricTestRunner;
-import org.robolectric.annotation.Config;
 
 import java.util.ArrayList;
 import java.util.HashSet;
@@ -55,8 +53,6 @@
 /** Unit tests for {@link TileBoard}. */
 @SmallTest
 @RunWith(RobolectricTestRunner.class)
-//TODO: Remove minsdk check after sdk extension 13 release
-@Config(minSdk = Build.VERSION_CODES.VANILLA_ICE_CREAM)
 public class TileBoardTest {
 
     private static final Dimensions TILE_SIZE = new Dimensions(800, 800);
diff --git a/pdf/pdf-viewer/src/test/java/androidx/pdf/util/UrisTest.java b/pdf/pdf-viewer/src/test/java/androidx/pdf/util/UrisTest.java
index 5dc6933..b9c980e 100644
--- a/pdf/pdf-viewer/src/test/java/androidx/pdf/util/UrisTest.java
+++ b/pdf/pdf-viewer/src/test/java/androidx/pdf/util/UrisTest.java
@@ -19,20 +19,16 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import android.net.Uri;
-import android.os.Build;
 
 import androidx.test.filters.SmallTest;
 
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.robolectric.RobolectricTestRunner;
-import org.robolectric.annotation.Config;
 
 /** Unit tests for {@link Uris}. */
 @SmallTest
 @RunWith(RobolectricTestRunner.class)
-//TODO: Remove minsdk check after sdk extension 13 release
-@Config(minSdk = Build.VERSION_CODES.VANILLA_ICE_CREAM)
 public class UrisTest {
 
     @Test
diff --git a/pdf/pdf-viewer/src/test/java/androidx/pdf/util/ZoomUtilsTest.java b/pdf/pdf-viewer/src/test/java/androidx/pdf/util/ZoomUtilsTest.java
index 5b0e1d2..e385bae 100644
--- a/pdf/pdf-viewer/src/test/java/androidx/pdf/util/ZoomUtilsTest.java
+++ b/pdf/pdf-viewer/src/test/java/androidx/pdf/util/ZoomUtilsTest.java
@@ -22,20 +22,15 @@
 import static org.junit.Assert.assertThrows;
 import static org.junit.Assert.assertTrue;
 
-import android.os.Build;
-
 import androidx.test.filters.SmallTest;
 
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.robolectric.RobolectricTestRunner;
-import org.robolectric.annotation.Config;
 
 /** Tests for {@link ZoomUtils}. */
 @SmallTest
 @RunWith(RobolectricTestRunner.class)
-//TODO: Remove minsdk check after sdk extension 13 release
-@Config(minSdk = Build.VERSION_CODES.VANILLA_ICE_CREAM)
 public class ZoomUtilsTest {
 
     @Test
diff --git a/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/LoadingViewTest.kt b/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/LoadingViewTest.kt
index c208944..dd4b397 100644
--- a/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/LoadingViewTest.kt
+++ b/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/LoadingViewTest.kt
@@ -15,13 +15,11 @@
  */
 package androidx.pdf.viewer
 
-import android.content.Context
-import android.os.Build
 import android.view.View
 import android.widget.ProgressBar
 import android.widget.TextView
+import androidx.pdf.ActivityUtils
 import androidx.pdf.R
-import androidx.test.core.app.ApplicationProvider
 import androidx.test.filters.SmallTest
 import com.google.common.truth.Truth
 import org.junit.Assert
@@ -29,21 +27,16 @@
 import org.junit.Test
 import org.junit.runner.RunWith
 import org.robolectric.RobolectricTestRunner
-import org.robolectric.annotation.Config
 
 /** Tests for [LoadingView]. */
 @SmallTest
 @RunWith(RobolectricTestRunner::class)
-
-// TODO: Remove minsdk check after sdk extension 13 release
-@Config(minSdk = Build.VERSION_CODES.VANILLA_ICE_CREAM)
 class LoadingViewTest {
     private lateinit var loadingView: LoadingView
 
     @Before
     fun setUp() {
-        val context = ApplicationProvider.getApplicationContext<Context>()
-        loadingView = LoadingView(context)
+        loadingView = LoadingView(ActivityUtils.getThemedActivity())
     }
 
     @Test
diff --git a/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/PageMosaicViewTest.java b/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/PageMosaicViewTest.java
index 579b687..f0b2b622 100644
--- a/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/PageMosaicViewTest.java
+++ b/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/PageMosaicViewTest.java
@@ -26,7 +26,6 @@
 import static org.mockito.Mockito.when;
 
 import android.graphics.Rect;
-import android.os.Build;
 
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
@@ -46,15 +45,12 @@
 import org.junit.runner.RunWith;
 import org.mockito.Mock;
 import org.robolectric.RobolectricTestRunner;
-import org.robolectric.annotation.Config;
 
 import java.util.List;
 
 /** Unit tests for {@link PageMosaicView}. */
 @SmallTest
 @RunWith(RobolectricTestRunner.class)
-//TODO: Remove minsdk check after sdk extension 13 release
-@Config(minSdk = Build.VERSION_CODES.VANILLA_ICE_CREAM)
 public class PageMosaicViewTest {
     @Mock
     private MosaicView.BitmapSource mMockBitmapSource;
diff --git a/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/PageRangeHandlerTest.java b/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/PageRangeHandlerTest.java
index 8776f49..da59d113 100644
--- a/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/PageRangeHandlerTest.java
+++ b/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/PageRangeHandlerTest.java
@@ -23,8 +23,6 @@
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.when;
 
-import android.os.Build;
-
 import androidx.pdf.data.Range;
 import androidx.test.filters.SmallTest;
 
@@ -34,12 +32,9 @@
 import org.mockito.invocation.InvocationOnMock;
 import org.mockito.stubbing.Answer;
 import org.robolectric.RobolectricTestRunner;
-import org.robolectric.annotation.Config;
 
 @SmallTest
 @RunWith(RobolectricTestRunner.class)
-//TODO: Remove minsdk check after sdk extension 13 release
-@Config(minSdk = Build.VERSION_CODES.VANILLA_ICE_CREAM)
 public class PageRangeHandlerTest {
     @Test
     public void getVisiblePage_nullVisiblePageRange_returnsZero() {
diff --git a/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/PageSelectionValueObserverTest.java b/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/PageSelectionValueObserverTest.java
index 9c4b2a6..0c5ce9f 100644
--- a/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/PageSelectionValueObserverTest.java
+++ b/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/PageSelectionValueObserverTest.java
@@ -22,7 +22,6 @@
 import static org.mockito.Mockito.when;
 
 import android.content.Context;
-import android.os.Build;
 import android.util.DisplayMetrics;
 
 import androidx.pdf.data.Range;
@@ -34,13 +33,10 @@
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.robolectric.RobolectricTestRunner;
-import org.robolectric.annotation.Config;
 
 
 @SmallTest
 @RunWith(RobolectricTestRunner.class)
-//TODO: Remove minsdk check after sdk extension 13 release
-@Config(minSdk = Build.VERSION_CODES.VANILLA_ICE_CREAM)
 public class PageSelectionValueObserverTest {
     private final PaginatedView mMockPaginatedView = mock(PaginatedView.class);
     private final PaginationModel mMockPaginationModel = mock(PaginationModel.class);
@@ -71,6 +67,7 @@
                 new Dimensions(100, 100)).getPageView()).thenReturn(
                 mMockNewPageMosaicView);
         when(mMockPaginatedView.getPageRangeHandler()).thenReturn(mMockPageRangeHandler);
+        when(mMockPaginatedView.getModel()).thenReturn(mMockPaginationModel);
         when(mMockPageRangeHandler.getVisiblePages()).thenReturn(new Range(1, 2));
         DisplayMetrics displayMetrics = new DisplayMetrics();
         displayMetrics.density = 1f;
diff --git a/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/PageViewFactoryTest.java b/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/PageViewFactoryTest.java
index 097fd6e..fcd48bd 100644
--- a/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/PageViewFactoryTest.java
+++ b/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/PageViewFactoryTest.java
@@ -25,7 +25,6 @@
 import android.graphics.Color;
 import android.graphics.Rect;
 import android.graphics.drawable.ColorDrawable;
-import android.os.Build;
 import android.view.View;
 
 import androidx.pdf.find.FindInFileView;
@@ -42,13 +41,10 @@
 import org.junit.runner.RunWith;
 import org.mockito.ArgumentCaptor;
 import org.robolectric.RobolectricTestRunner;
-import org.robolectric.annotation.Config;
 
 @SuppressWarnings("unchecked")
 @SmallTest
 @RunWith(RobolectricTestRunner.class)
-//TODO: Remove minsdk check after sdk extension 13 release
-@Config(minSdk = Build.VERSION_CODES.VANILLA_ICE_CREAM)
 public class PageViewFactoryTest {
     private final PdfLoader mMockPdfLoader = mock(PdfLoader.class);
 
diff --git a/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/PaginatedViewTest.java b/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/PaginatedViewTest.java
index 311abf1..d3afdb3 100644
--- a/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/PaginatedViewTest.java
+++ b/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/PaginatedViewTest.java
@@ -19,7 +19,6 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import android.content.Context;
-import android.os.Build;
 
 import androidx.pdf.models.Dimensions;
 import androidx.pdf.util.BitmapRecycler;
@@ -37,15 +36,12 @@
 import org.mockito.Mock;
 import org.mockito.MockitoAnnotations;
 import org.robolectric.RobolectricTestRunner;
-import org.robolectric.annotation.Config;
 
 import java.util.List;
 
 /** Tests for {@link PaginatedView}. */
 @SmallTest
 @RunWith(RobolectricTestRunner.class)
-//TODO: Remove minsdk check after sdk extension 13 release
-@Config(minSdk = Build.VERSION_CODES.VANILLA_ICE_CREAM)
 public class PaginatedViewTest {
 
     PaginatedView mPaginatedView;
diff --git a/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/PaginationModelTest.java b/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/PaginationModelTest.java
index a568c6f..70fb013 100644
--- a/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/PaginationModelTest.java
+++ b/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/PaginationModelTest.java
@@ -21,7 +21,6 @@
 
 import android.content.Context;
 import android.graphics.Rect;
-import android.os.Build;
 
 import androidx.pdf.data.Range;
 import androidx.pdf.models.Dimensions;
@@ -33,12 +32,9 @@
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.robolectric.RobolectricTestRunner;
-import org.robolectric.annotation.Config;
 
 @SmallTest
 @RunWith(RobolectricTestRunner.class)
-//TODO: Remove minsdk check after sdk extension 13 release
-@Config(minSdk = Build.VERSION_CODES.VANILLA_ICE_CREAM)
 public class PaginationModelTest {
 
     private static final Dimensions ONE_HUNDRED_BY_TWO_HUNDRED = new Dimensions(100, 200);
diff --git a/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/SearchModelTest.java b/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/SearchModelTest.java
index a1a6b6d..293eeb4 100644
--- a/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/SearchModelTest.java
+++ b/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/SearchModelTest.java
@@ -21,7 +21,6 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import android.graphics.Rect;
-import android.os.Build;
 
 import androidx.pdf.find.MatchCount;
 import androidx.pdf.models.MatchRects;
@@ -34,7 +33,6 @@
 import org.mockito.Mock;
 import org.mockito.MockitoAnnotations;
 import org.robolectric.RobolectricTestRunner;
-import org.robolectric.annotation.Config;
 
 import java.util.ArrayList;
 import java.util.List;
@@ -42,8 +40,6 @@
 @SmallTest
 @RunWith(RobolectricTestRunner.class)
 @SuppressWarnings("deprecation")
-//TODO: Remove minsdk check after sdk extension 13 release
-@Config(minSdk = Build.VERSION_CODES.VANILLA_ICE_CREAM)
 public class SearchModelTest {
 
     @Mock
diff --git a/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/SearchQueryObserverTest.java b/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/SearchQueryObserverTest.java
index 622b4b9..d726676 100644
--- a/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/SearchQueryObserverTest.java
+++ b/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/SearchQueryObserverTest.java
@@ -19,20 +19,15 @@
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.verify;
 
-import android.os.Build;
-
 import androidx.test.filters.SmallTest;
 
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.robolectric.RobolectricTestRunner;
-import org.robolectric.annotation.Config;
 
 
 @SmallTest
 @RunWith(RobolectricTestRunner.class)
-//TODO: Remove minsdk check after sdk extension 13 release
-@Config(minSdk = Build.VERSION_CODES.VANILLA_ICE_CREAM)
 public class SearchQueryObserverTest {
 
     private final PaginatedView mMockPaginatedView = mock(PaginatedView.class);
diff --git a/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/SelectedMatchTest.java b/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/SelectedMatchTest.java
index d77d4b1..0619d52 100644
--- a/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/SelectedMatchTest.java
+++ b/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/SelectedMatchTest.java
@@ -22,7 +22,6 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import android.graphics.Rect;
-import android.os.Build;
 
 import androidx.pdf.models.MatchRects;
 import androidx.test.filters.SmallTest;
@@ -30,7 +29,6 @@
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.robolectric.RobolectricTestRunner;
-import org.robolectric.annotation.Config;
 
 import java.util.ArrayList;
 import java.util.Arrays;
@@ -38,8 +36,6 @@
 
 @SmallTest
 @RunWith(RobolectricTestRunner.class)
-//TODO: Remove minsdk check after sdk extension 13 release
-@Config(minSdk = Build.VERSION_CODES.VANILLA_ICE_CREAM)
 public class SelectedMatchTest {
 
     @Test
diff --git a/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/SelectedMatchValueObserverTest.java b/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/SelectedMatchValueObserverTest.java
index 1ef67d9..17fc62c 100644
--- a/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/SelectedMatchValueObserverTest.java
+++ b/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/SelectedMatchValueObserverTest.java
@@ -22,7 +22,6 @@
 
 import android.content.Context;
 import android.graphics.Rect;
-import android.os.Build;
 import android.util.DisplayMetrics;
 
 import androidx.pdf.models.Dimensions;
@@ -34,15 +33,12 @@
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.robolectric.RobolectricTestRunner;
-import org.robolectric.annotation.Config;
 
 import java.util.ArrayList;
 
 
 @SmallTest
 @RunWith(RobolectricTestRunner.class)
-//TODO: Remove minsdk check after sdk extension 13 release
-@Config(minSdk = Build.VERSION_CODES.VANILLA_ICE_CREAM)
 public class SelectedMatchValueObserverTest {
     private final PaginatedView mMockPaginatedView = mock(PaginatedView.class);
     private final PaginationModel mMockPaginationModel = mock(PaginationModel.class);
@@ -64,6 +60,7 @@
         when(mMockOldSelection.getPage()).thenReturn(1);
         when(mMockNewSelection.getPage()).thenReturn(2);
         when(mMockPaginationModel.getSize()).thenReturn(3);
+        when(mMockPaginatedView.getModel()).thenReturn(mMockPaginationModel);
         when(mMockPaginatedView.getViewAt(1)).thenReturn(mMockOldPageView);
         when(mMockPaginatedView.getViewAt(2)).thenReturn(mMockNewPageView);
         when(mMockPaginationModel.getPageSize(2)).thenReturn(new Dimensions(100, 100));
diff --git a/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/ZoomScrollValueObserverTest.java b/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/ZoomScrollValueObserverTest.java
index e9b5a39f..bbde98d 100644
--- a/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/ZoomScrollValueObserverTest.java
+++ b/pdf/pdf-viewer/src/test/java/androidx/pdf/viewer/ZoomScrollValueObserverTest.java
@@ -21,7 +21,6 @@
 import static org.mockito.Mockito.when;
 
 import android.graphics.Rect;
-import android.os.Build;
 import android.os.Handler;
 import android.os.Looper;
 import android.view.View;
@@ -45,13 +44,10 @@
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.robolectric.RobolectricTestRunner;
-import org.robolectric.annotation.Config;
 
 
 @SmallTest
 @RunWith(RobolectricTestRunner.class)
-//TODO: Remove minsdk check after sdk extension 13 release
-@Config(minSdk = Build.VERSION_CODES.VANILLA_ICE_CREAM)
 public class ZoomScrollValueObserverTest {
     private static final ObservableValue<ViewState>
             VIEW_STATE_EXPOSED_VALUE =
diff --git a/pdf/pdf-viewer/src/test/java/androidx/pdf/widget/MosaicViewTest.java b/pdf/pdf-viewer/src/test/java/androidx/pdf/widget/MosaicViewTest.java
index 3393c3a..585e06f 100644
--- a/pdf/pdf-viewer/src/test/java/androidx/pdf/widget/MosaicViewTest.java
+++ b/pdf/pdf-viewer/src/test/java/androidx/pdf/widget/MosaicViewTest.java
@@ -35,7 +35,6 @@
 import android.graphics.ColorFilter;
 import android.graphics.Rect;
 import android.graphics.drawable.Drawable;
-import android.os.Build;
 
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
@@ -60,7 +59,6 @@
 import org.mockito.Mock;
 import org.mockito.MockitoAnnotations;
 import org.robolectric.RobolectricTestRunner;
-import org.robolectric.annotation.Config;
 
 import java.util.HashSet;
 import java.util.List;
@@ -70,8 +68,6 @@
 @SmallTest
 @RunWith(RobolectricTestRunner.class)
 @SuppressWarnings("deprecation")
-//TODO: Remove minsdk check after sdk extension 13 release
-@Config(minSdk = Build.VERSION_CODES.VANILLA_ICE_CREAM)
 public class MosaicViewTest {
 
     @Mock
diff --git a/pdf/pdf-viewer/src/test/java/androidx/pdf/widget/PageIndicatorTest.java b/pdf/pdf-viewer/src/test/java/androidx/pdf/widget/PageIndicatorTest.java
index ff48a6e..c955d8a 100644
--- a/pdf/pdf-viewer/src/test/java/androidx/pdf/widget/PageIndicatorTest.java
+++ b/pdf/pdf-viewer/src/test/java/androidx/pdf/widget/PageIndicatorTest.java
@@ -24,7 +24,6 @@
 import static org.mockito.Mockito.when;
 
 import android.content.Context;
-import android.os.Build;
 import android.widget.TextView;
 
 import androidx.pdf.data.Range;
@@ -39,13 +38,10 @@
 import org.mockito.Mock;
 import org.mockito.MockitoAnnotations;
 import org.robolectric.RobolectricTestRunner;
-import org.robolectric.annotation.Config;
 
 /** Tests for {@link PageIndicator}. */
 @SmallTest
 @RunWith(RobolectricTestRunner.class)
-//TODO: Remove minsdk check after sdk extension 13 release
-@Config(minSdk = Build.VERSION_CODES.VANILLA_ICE_CREAM)
 public class PageIndicatorTest {
     @Mock
     Accessibility mAccessibility;
@@ -113,6 +109,6 @@
         mPageIndicator.setRangeAndZoom(new Range(0, 0), 4.0f, false);
         mPageIndicator.setRangeAndZoom(new Range(0, 0), 4.0f, true);
         verify(mAccessibility).announce(mContext, mPageNumberView,
-                String.format("%s\n%s", "page 1 of 10", "zoom 400 percent"));
+                String.format("%s", "zoom 400 percent"));
     }
 }
diff --git a/pdf/pdf-viewer/src/test/java/androidx/pdf/widget/ZoomViewTest.java b/pdf/pdf-viewer/src/test/java/androidx/pdf/widget/ZoomViewTest.java
index 7370be1..8fb6038 100644
--- a/pdf/pdf-viewer/src/test/java/androidx/pdf/widget/ZoomViewTest.java
+++ b/pdf/pdf-viewer/src/test/java/androidx/pdf/widget/ZoomViewTest.java
@@ -24,7 +24,6 @@
 import android.content.Context;
 import android.graphics.PointF;
 import android.graphics.Rect;
-import android.os.Build;
 import android.os.Bundle;
 import android.os.Parcelable;
 import android.util.Pair;
@@ -50,7 +49,6 @@
 import org.mockito.Mock;
 import org.mockito.MockitoAnnotations;
 import org.robolectric.RobolectricTestRunner;
-import org.robolectric.annotation.Config;
 
 import java.util.ArrayDeque;
 import java.util.Queue;
@@ -58,8 +56,6 @@
 /** Unit tests for {@link ZoomView}. */
 @SmallTest
 @RunWith(RobolectricTestRunner.class)
-//TODO: Remove minsdk check after sdk extension 13 release
-@Config(minSdk = Build.VERSION_CODES.VANILLA_ICE_CREAM)
 public class ZoomViewTest {
 
     @Mock
diff --git a/savedstate/savedstate/src/commonMain/kotlin/androidx/savedstate/serialization/SavedStateRegistryOwnerDelegates.kt b/savedstate/savedstate/src/commonMain/kotlin/androidx/savedstate/serialization/SavedStateRegistryOwnerDelegates.kt
index 44215b9..d90d1b0 100644
--- a/savedstate/savedstate/src/commonMain/kotlin/androidx/savedstate/serialization/SavedStateRegistryOwnerDelegates.kt
+++ b/savedstate/savedstate/src/commonMain/kotlin/androidx/savedstate/serialization/SavedStateRegistryOwnerDelegates.kt
@@ -129,16 +129,18 @@
         val qualifiedKey = key ?: (classNamePrefix + property.name)
 
         val restoredState = registry.consumeRestoredStateForKey(qualifiedKey)
-        val value =
+        val initialValue =
             if (restoredState != null && restoredState.read { !isEmpty() }) {
                 decodeFromSavedState(serializer, restoredState)
             } else {
                 init()
             }
 
-        registry.registerSavedStateProvider(qualifiedKey) { encodeToSavedState(serializer, value) }
+        registry.registerSavedStateProvider(qualifiedKey) {
+            encodeToSavedState(serializer, this.value)
+        }
 
-        this.value = value
+        this.value = initialValue
     }
 
     override fun getValue(thisRef: Any?, property: KProperty<*>): T {
diff --git a/savedstate/savedstate/src/commonTest/kotlin/androidx/savedstate/serialization/SavedStateRegistryOwnerDelegatesTest.kt b/savedstate/savedstate/src/commonTest/kotlin/androidx/savedstate/serialization/SavedStateRegistryOwnerDelegatesTest.kt
index 7eb2e32..223653b 100644
--- a/savedstate/savedstate/src/commonTest/kotlin/androidx/savedstate/serialization/SavedStateRegistryOwnerDelegatesTest.kt
+++ b/savedstate/savedstate/src/commonTest/kotlin/androidx/savedstate/serialization/SavedStateRegistryOwnerDelegatesTest.kt
@@ -44,8 +44,9 @@
                 lifecycleRegistry.currentState = State.CREATED
             }
 
-        val property by owner.saved(Int.serializer()) { Int.MAX_VALUE }
-        @Suppress("UNUSED_EXPRESSION") property // force access.
+        var property by owner.saved(Int.serializer()) { Int.MIN_VALUE }
+        property = Int.MAX_VALUE
+
         val actualState = savedState()
         owner.savedStateRegistryController.performSave(actualState)
 
@@ -138,8 +139,9 @@
                 lifecycleRegistry.currentState = State.CREATED
             }
 
-        val property by owner.saved(CUSTOM_KEY) { Int.MAX_VALUE }
-        @Suppress("UNUSED_EXPRESSION") property // force access.
+        var property by owner.saved(CUSTOM_KEY) { Int.MIN_VALUE }
+        property = Int.MAX_VALUE
+
         val actualState = savedState()
         owner.savedStateRegistryController.performSave(actualState)
 
diff --git a/settings.gradle b/settings.gradle
index 1bc9dce..83b599a 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -799,6 +799,7 @@
 includeProject(":lifecycle:lifecycle-viewmodel-compose-lint", [BuildType.COMPOSE])
 includeProject(":lifecycle:lifecycle-viewmodel-ktx", [BuildType.MAIN, BuildType.FLAN, BuildType.COMPOSE])
 includeProject(":lifecycle:lifecycle-viewmodel-savedstate", [BuildType.MAIN, BuildType.FLAN, BuildType.COMPOSE, BuildType.KMP])
+includeProject(":lifecycle:lifecycle-viewmodel-savedstate-samples", [BuildType.MAIN, BuildType.FLAN, BuildType.COMPOSE, BuildType.KMP])
 includeProject(":lifecycle:lifecycle-viewmodel-testing", [BuildType.MAIN, BuildType.FLAN, BuildType.COMPOSE, BuildType.INFRAROGUE, BuildType.KMP])
 includeProject(":lint:lint-gradle", [BuildType.MAIN])
 includeProject(":lint-checks")
diff --git a/webkit/webkit/api/current.txt b/webkit/webkit/api/current.txt
index f5f4673..80a5e23 100644
--- a/webkit/webkit/api/current.txt
+++ b/webkit/webkit/api/current.txt
@@ -44,6 +44,7 @@
   @SuppressCompatibility @RequiresFeature(name=androidx.webkit.WebViewFeature.PROFILE_URL_PREFETCH, enforcement="androidx.webkit.WebViewFeature#isFeatureSupported") @androidx.webkit.Profile.ExperimentalUrlPrefetch public final class PrefetchParameters {
     method public java.util.Map<java.lang.String!,java.lang.String!> getAdditionalHeaders();
     method public androidx.webkit.NoVarySearchData? getExpectedNoVarySearchData();
+    method public boolean isJavaScriptEnabled();
   }
 
   public static final class PrefetchParameters.Builder {
@@ -51,6 +52,7 @@
     method @SuppressCompatibility @androidx.webkit.Profile.ExperimentalUrlPrefetch public androidx.webkit.PrefetchParameters.Builder addAdditionalHeader(String, String);
     method @SuppressCompatibility @RequiresFeature(name=androidx.webkit.WebViewFeature.PROFILE_URL_PREFETCH, enforcement="androidx.webkit.WebViewFeature#isFeatureSupported") @androidx.webkit.Profile.ExperimentalUrlPrefetch public androidx.webkit.PrefetchParameters build();
     method @SuppressCompatibility @androidx.webkit.Profile.ExperimentalUrlPrefetch public androidx.webkit.PrefetchParameters.Builder setExpectedNoVarySearchData(androidx.webkit.NoVarySearchData);
+    method @SuppressCompatibility @androidx.webkit.Profile.ExperimentalUrlPrefetch public androidx.webkit.PrefetchParameters.Builder setJavaScriptEnabled(boolean);
   }
 
   public class ProcessGlobalConfig {
diff --git a/webkit/webkit/api/restricted_current.txt b/webkit/webkit/api/restricted_current.txt
index f5f4673..80a5e23 100644
--- a/webkit/webkit/api/restricted_current.txt
+++ b/webkit/webkit/api/restricted_current.txt
@@ -44,6 +44,7 @@
   @SuppressCompatibility @RequiresFeature(name=androidx.webkit.WebViewFeature.PROFILE_URL_PREFETCH, enforcement="androidx.webkit.WebViewFeature#isFeatureSupported") @androidx.webkit.Profile.ExperimentalUrlPrefetch public final class PrefetchParameters {
     method public java.util.Map<java.lang.String!,java.lang.String!> getAdditionalHeaders();
     method public androidx.webkit.NoVarySearchData? getExpectedNoVarySearchData();
+    method public boolean isJavaScriptEnabled();
   }
 
   public static final class PrefetchParameters.Builder {
@@ -51,6 +52,7 @@
     method @SuppressCompatibility @androidx.webkit.Profile.ExperimentalUrlPrefetch public androidx.webkit.PrefetchParameters.Builder addAdditionalHeader(String, String);
     method @SuppressCompatibility @RequiresFeature(name=androidx.webkit.WebViewFeature.PROFILE_URL_PREFETCH, enforcement="androidx.webkit.WebViewFeature#isFeatureSupported") @androidx.webkit.Profile.ExperimentalUrlPrefetch public androidx.webkit.PrefetchParameters build();
     method @SuppressCompatibility @androidx.webkit.Profile.ExperimentalUrlPrefetch public androidx.webkit.PrefetchParameters.Builder setExpectedNoVarySearchData(androidx.webkit.NoVarySearchData);
+    method @SuppressCompatibility @androidx.webkit.Profile.ExperimentalUrlPrefetch public androidx.webkit.PrefetchParameters.Builder setJavaScriptEnabled(boolean);
   }
 
   public class ProcessGlobalConfig {
diff --git a/webkit/webkit/src/main/java/androidx/webkit/PrefetchParameters.java b/webkit/webkit/src/main/java/androidx/webkit/PrefetchParameters.java
index e04a866..8938a57 100644
--- a/webkit/webkit/src/main/java/androidx/webkit/PrefetchParameters.java
+++ b/webkit/webkit/src/main/java/androidx/webkit/PrefetchParameters.java
@@ -33,11 +33,13 @@
 public final class PrefetchParameters {
     private final @NonNull Map<String, String> mAdditionalHeaders;
     private final @Nullable NoVarySearchData mExpectedNoVarySearchData;
+    private final boolean mIsJavaScriptEnabled;
 
     private PrefetchParameters(@NonNull Map<String, String> additionalHeaders,
-            @Nullable NoVarySearchData noVarySearchData) {
-        this.mAdditionalHeaders = additionalHeaders;
-        this.mExpectedNoVarySearchData = noVarySearchData;
+            @Nullable NoVarySearchData noVarySearchData, boolean isJavaScriptEnabled) {
+        mAdditionalHeaders = additionalHeaders;
+        mExpectedNoVarySearchData = noVarySearchData;
+        mIsJavaScriptEnabled = isJavaScriptEnabled;
     }
 
     /**
@@ -55,15 +57,24 @@
     }
 
     /**
+     * @return The javascript enabled setting built using {@link Builder}.
+     */
+    public boolean isJavaScriptEnabled() {
+        return mIsJavaScriptEnabled;
+    }
+
+    /**
      * A builder class to use to construct the {@link PrefetchParameters}.
      */
     public static final class Builder {
         private final @NonNull Map<String, String> mAdditionalHeaders;
         private @Nullable NoVarySearchData mExpectedNoVarySearchData;
+        private boolean mIsJavaScriptEnabled;
 
         public Builder() {
             mAdditionalHeaders = new HashMap<>();
             mExpectedNoVarySearchData = null;
+            mIsJavaScriptEnabled = false;
         }
 
         /**
@@ -76,7 +87,8 @@
         @Profile.ExperimentalUrlPrefetch
         @NonNull
         public PrefetchParameters build() {
-            return new PrefetchParameters(mAdditionalHeaders, mExpectedNoVarySearchData);
+            return new PrefetchParameters(mAdditionalHeaders, mExpectedNoVarySearchData,
+                    mIsJavaScriptEnabled);
         }
 
         /**
@@ -103,8 +115,21 @@
         @NonNull
         public Builder setExpectedNoVarySearchData(
                 @NonNull NoVarySearchData expectedNoVarySearchData) {
-            this.mExpectedNoVarySearchData = expectedNoVarySearchData;
+            mExpectedNoVarySearchData = expectedNoVarySearchData;
             return this;
         }
+
+        /**
+         * {@code true} if the WebView's that will be loading the prefetched
+         * response will have javascript enabled. This affects whether or not
+         * client hints header is sent with the prefetch request.
+         */
+        @Profile.ExperimentalUrlPrefetch
+        @NonNull
+        public Builder setJavaScriptEnabled(boolean javaScriptEnabled) {
+            mIsJavaScriptEnabled = javaScriptEnabled;
+            return this;
+        }
+
     }
 }
diff --git a/webkit/webkit/src/main/java/androidx/webkit/internal/PrefetchParametersAdapter.java b/webkit/webkit/src/main/java/androidx/webkit/internal/PrefetchParametersAdapter.java
index 7d308ea..21169dc 100644
--- a/webkit/webkit/src/main/java/androidx/webkit/internal/PrefetchParametersAdapter.java
+++ b/webkit/webkit/src/main/java/androidx/webkit/internal/PrefetchParametersAdapter.java
@@ -52,4 +52,10 @@
         return BoundaryInterfaceReflectionUtil.createInvocationHandlerFor(
                 new NoVarySearchDataAdapter(noVarySearchData));
     }
+
+    @Override
+    public boolean isJavaScriptEnabled() {
+        if (mPrefetchParameters == null) return false;
+        return mPrefetchParameters.isJavaScriptEnabled();
+    }
 }