Merge "Add ComplexDifferentTypes benchmarks for LazyColumn and RV" into androidx-main
diff --git a/activity/activity-compose-lint/build.gradle b/activity/activity-compose-lint/build.gradle
index 354b82a..7415d8d 100644
--- a/activity/activity-compose-lint/build.gradle
+++ b/activity/activity-compose-lint/build.gradle
@@ -44,7 +44,7 @@
     testImplementation(libs.kotlinStdlib)
     testRuntimeOnly(libs.kotlinReflect)
     testImplementation(libs.kotlinStdlibJdk8)
-    testImplementation(libs.androidLintPrevApi)
+    testImplementation(libs.androidLintApiPrevAnalysis)
     testImplementation(libs.androidLintTests)
     testImplementation(libs.junit)
     testImplementation(libs.truth)
diff --git a/activity/activity-lint/build.gradle b/activity/activity-lint/build.gradle
index e181339..19b682c 100644
--- a/activity/activity-lint/build.gradle
+++ b/activity/activity-lint/build.gradle
@@ -38,7 +38,7 @@
     testImplementation(libs.kotlinStdlib)
     testRuntimeOnly(libs.kotlinReflect)
     testImplementation(libs.kotlinStdlibJdk8)
-    testImplementation(libs.androidLintPrevApi)
+    testImplementation(libs.androidLintApiPrevAnalysis)
     testImplementation(libs.androidLintTests)
     testImplementation(libs.junit)
     testImplementation(libs.truth)
diff --git a/appsearch/appsearch/api/current.txt b/appsearch/appsearch/api/current.txt
index 29645b5..35ebbf46 100644
--- a/appsearch/appsearch/api/current.txt
+++ b/appsearch/appsearch/api/current.txt
@@ -946,7 +946,7 @@
     method public void setChildren(java.util.List<androidx.appsearch.ast.Node!>);
   }
 
-  @SuppressCompatibility @androidx.appsearch.app.ExperimentalAppSearchApi public class ComparatorNode implements androidx.appsearch.ast.Node {
+  @SuppressCompatibility @androidx.appsearch.app.ExperimentalAppSearchApi public final class ComparatorNode implements androidx.appsearch.ast.Node {
     ctor public ComparatorNode(int, androidx.appsearch.app.PropertyPath, long);
     method public int getComparator();
     method public androidx.appsearch.app.PropertyPath getPropertyPath();
@@ -970,7 +970,7 @@
     method public void setChildren(java.util.List<androidx.appsearch.ast.Node!>);
   }
 
-  @SuppressCompatibility @androidx.appsearch.app.ExperimentalAppSearchApi public class PropertyRestrictNode implements androidx.appsearch.ast.Node {
+  @SuppressCompatibility @androidx.appsearch.app.ExperimentalAppSearchApi public final class PropertyRestrictNode implements androidx.appsearch.ast.Node {
     ctor public PropertyRestrictNode(androidx.appsearch.app.PropertyPath, androidx.appsearch.ast.Node);
     method public androidx.appsearch.ast.Node getChild();
     method public androidx.appsearch.app.PropertyPath getProperty();
diff --git a/appsearch/appsearch/api/restricted_current.txt b/appsearch/appsearch/api/restricted_current.txt
index 29645b5..35ebbf46 100644
--- a/appsearch/appsearch/api/restricted_current.txt
+++ b/appsearch/appsearch/api/restricted_current.txt
@@ -946,7 +946,7 @@
     method public void setChildren(java.util.List<androidx.appsearch.ast.Node!>);
   }
 
-  @SuppressCompatibility @androidx.appsearch.app.ExperimentalAppSearchApi public class ComparatorNode implements androidx.appsearch.ast.Node {
+  @SuppressCompatibility @androidx.appsearch.app.ExperimentalAppSearchApi public final class ComparatorNode implements androidx.appsearch.ast.Node {
     ctor public ComparatorNode(int, androidx.appsearch.app.PropertyPath, long);
     method public int getComparator();
     method public androidx.appsearch.app.PropertyPath getPropertyPath();
@@ -970,7 +970,7 @@
     method public void setChildren(java.util.List<androidx.appsearch.ast.Node!>);
   }
 
-  @SuppressCompatibility @androidx.appsearch.app.ExperimentalAppSearchApi public class PropertyRestrictNode implements androidx.appsearch.ast.Node {
+  @SuppressCompatibility @androidx.appsearch.app.ExperimentalAppSearchApi public final class PropertyRestrictNode implements androidx.appsearch.ast.Node {
     ctor public PropertyRestrictNode(androidx.appsearch.app.PropertyPath, androidx.appsearch.ast.Node);
     method public androidx.appsearch.ast.Node getChild();
     method public androidx.appsearch.app.PropertyPath getProperty();
diff --git a/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/NegationNodeCtsTest.java b/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/NegationNodeCtsTest.java
index 0a691a7..0ed9372 100644
--- a/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/NegationNodeCtsTest.java
+++ b/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/NegationNodeCtsTest.java
@@ -39,6 +39,15 @@
     public final CheckFlagsRule mCheckFlagsRule = DeviceFlagsValueProvider.createCheckFlagsRule();
 
     @Test
+    public void testEquals_identical() {
+        NegationNode nodeOne = new NegationNode(new TextNode("foo"));
+        NegationNode nodeTwo = new NegationNode(new TextNode("foo"));
+
+        assertThat(nodeOne).isEqualTo(nodeTwo);
+        assertThat(nodeOne.hashCode()).isEqualTo(nodeTwo.hashCode());
+    }
+
+    @Test
     public void testSetChildren_throwsOnNullNode() {
         TextNode textNode = new TextNode("foo");
 
diff --git a/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/TextNodeCtsTest.java b/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/TextNodeCtsTest.java
index b727b5a..574a49f 100644
--- a/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/TextNodeCtsTest.java
+++ b/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/TextNodeCtsTest.java
@@ -35,6 +35,18 @@
     public final CheckFlagsRule mCheckFlagsRule = DeviceFlagsValueProvider.createCheckFlagsRule();
 
     @Test
+    public void testEquals_identical() {
+        TextNode nodeOne = new TextNode("foo");
+        nodeOne.setPrefix(true);
+
+        TextNode nodeTwo = new TextNode("foo");
+        nodeTwo.setPrefix(true);
+
+        assertThat(nodeOne).isEqualTo(nodeTwo);
+        assertThat(nodeOne.hashCode()).isEqualTo(nodeTwo.hashCode());
+    }
+
+    @Test
     public void testConstructor_prefixVerbatimFalseByDefault() {
         TextNode defaultTextNode = new TextNode("foo");
 
diff --git a/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/operators/AndNodeCtsTest.java b/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/operators/AndNodeCtsTest.java
index d603a06..c61a6fe 100644
--- a/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/operators/AndNodeCtsTest.java
+++ b/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/operators/AndNodeCtsTest.java
@@ -42,6 +42,20 @@
     public final CheckFlagsRule mCheckFlagsRule = DeviceFlagsValueProvider.createCheckFlagsRule();
 
     @Test
+    public void testEquals_identical() {
+        TextNode foo = new TextNode("foo");
+        TextNode bar = new TextNode("bar");
+        AndNode andNodeOne = new AndNode(List.of(foo, bar));
+
+        TextNode fooTwo = new TextNode("foo");
+        TextNode barTwo = new TextNode("bar");
+        AndNode andNodeTwo = new AndNode(List.of(fooTwo, barTwo));
+
+        assertThat(andNodeOne).isEqualTo(andNodeTwo);
+        assertThat(andNodeOne.hashCode()).isEqualTo(andNodeTwo.hashCode());
+    }
+
+    @Test
     public void testConstructor_buildsAndNode() {
         TextNode foo = new TextNode("foo");
         TextNode bar = new TextNode("bar");
diff --git a/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/operators/ComparatorNodeCtsTest.java b/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/operators/ComparatorNodeCtsTest.java
index 4098926..5c2d32c 100644
--- a/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/operators/ComparatorNodeCtsTest.java
+++ b/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/operators/ComparatorNodeCtsTest.java
@@ -38,6 +38,22 @@
     public final CheckFlagsRule mCheckFlagsRule = DeviceFlagsValueProvider.createCheckFlagsRule();
 
     @Test
+    public void testEquals_identical() {
+        PropertyPath propertyPathOne = new PropertyPath("example.property.path");
+        int valueOne = 5;
+        ComparatorNode equalsNodeOne =
+                new ComparatorNode(ComparatorNode.EQUALS, propertyPathOne, valueOne);
+
+        PropertyPath propertyPathTwo = new PropertyPath("example.property.path");
+        int valueTwo = 5;
+        ComparatorNode equalsNodeTwo =
+                new ComparatorNode(ComparatorNode.EQUALS, propertyPathTwo, valueTwo);
+
+        assertThat(equalsNodeOne).isEqualTo(equalsNodeTwo);
+        assertThat(equalsNodeOne.hashCode()).isEqualTo(equalsNodeTwo.hashCode());
+    }
+
+    @Test
     public void testConstructor_correctConstruction() {
         List<PropertyPath.PathSegment> pathSegmentList = List.of(
                 PropertyPath.PathSegment.create("example"),
diff --git a/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/operators/OrNodeCtsTest.java b/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/operators/OrNodeCtsTest.java
index e2b3341..574134e 100644
--- a/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/operators/OrNodeCtsTest.java
+++ b/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/operators/OrNodeCtsTest.java
@@ -42,6 +42,20 @@
     public final CheckFlagsRule mCheckFlagsRule = DeviceFlagsValueProvider.createCheckFlagsRule();
 
     @Test
+    public void testEquals_identical() {
+        TextNode foo = new TextNode("foo");
+        TextNode bar = new TextNode("bar");
+        OrNode orNodeOne = new OrNode(List.of(foo, bar));
+
+        TextNode fooTwo = new TextNode("foo");
+        TextNode barTwo = new TextNode("bar");
+        OrNode orNodeTwo = new OrNode(List.of(fooTwo, barTwo));
+
+        assertThat(orNodeOne).isEqualTo(orNodeTwo);
+        assertThat(orNodeOne.hashCode()).isEqualTo(orNodeTwo.hashCode());
+    }
+
+    @Test
     public void testConstructor_buildsOrNode() {
         TextNode foo = new TextNode("foo");
         TextNode bar = new TextNode("bar");
diff --git a/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/operators/PropertyRestrictNodeTest.java b/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/operators/PropertyRestrictNodeTest.java
index f78abb9..806e317 100644
--- a/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/operators/PropertyRestrictNodeTest.java
+++ b/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/operators/PropertyRestrictNodeTest.java
@@ -40,6 +40,23 @@
     public final CheckFlagsRule mCheckFlagsRule = DeviceFlagsValueProvider.createCheckFlagsRule();
 
     @Test
+    public void testEquals_identical() {
+        PropertyPath propertyPathOne = new PropertyPath("example.property.segment");
+        TextNode textNodeOne = new TextNode("foo");
+        PropertyRestrictNode propertyRestrictNodeOne =
+                new PropertyRestrictNode(propertyPathOne, textNodeOne);
+
+        PropertyPath propertyPathTwo = new PropertyPath("example.property.segment");
+        TextNode textNodeTwo = new TextNode("foo");
+        PropertyRestrictNode propertyRestrictNodeTwo =
+                new PropertyRestrictNode(propertyPathTwo, textNodeTwo);
+
+        assertThat(propertyRestrictNodeOne).isEqualTo(propertyRestrictNodeTwo);
+        assertThat(propertyRestrictNodeOne.hashCode())
+                .isEqualTo(propertyRestrictNodeTwo.hashCode());
+    }
+
+    @Test
     public void testConstructor_takesPropertyPath() {
         List<PropertyPath.PathSegment> pathSegmentList =
                 List.of(PropertyPath.PathSegment.create("example"),
diff --git a/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/query/GetSearchStringParameterNodeCtsTest.java b/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/query/GetSearchStringParameterNodeCtsTest.java
index d6c07a5..3628fe2 100644
--- a/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/query/GetSearchStringParameterNodeCtsTest.java
+++ b/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/query/GetSearchStringParameterNodeCtsTest.java
@@ -35,6 +35,18 @@
     public final CheckFlagsRule mCheckFlagsRule = DeviceFlagsValueProvider.createCheckFlagsRule();
 
     @Test
+    public void testEquals_identical() {
+        GetSearchStringParameterNode getSearchStringParameterNodeOne =
+                new GetSearchStringParameterNode(1);
+        GetSearchStringParameterNode getSearchStringParameterNodeTwo =
+                new GetSearchStringParameterNode(1);
+
+        assertThat(getSearchStringParameterNodeOne).isEqualTo(getSearchStringParameterNodeTwo);
+        assertThat(getSearchStringParameterNodeOne.hashCode())
+                .isEqualTo(getSearchStringParameterNodeTwo.hashCode());
+    }
+
+    @Test
     public void testConstructor_throwsOnNegativeIndex() {
         IllegalArgumentException thrown = assertThrows(IllegalArgumentException.class,
                 () -> new GetSearchStringParameterNode(-1));
diff --git a/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/query/HasPropertyNodeCtsTest.java b/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/query/HasPropertyNodeCtsTest.java
index 978a00e..345081f 100644
--- a/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/query/HasPropertyNodeCtsTest.java
+++ b/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/query/HasPropertyNodeCtsTest.java
@@ -38,6 +38,17 @@
     public final CheckFlagsRule mCheckFlagsRule = DeviceFlagsValueProvider.createCheckFlagsRule();
 
     @Test
+    public void testEquals_identical() {
+        HasPropertyNode hasPropertyOne =
+                new HasPropertyNode(new PropertyPath("example.property.path"));
+        HasPropertyNode hasPropertyTwo =
+                new HasPropertyNode(new PropertyPath("example.property.path"));
+
+        assertThat(hasPropertyOne).isEqualTo(hasPropertyTwo);
+        assertThat(hasPropertyOne.hashCode()).isEqualTo(hasPropertyTwo.hashCode());
+    }
+
+    @Test
     public void testConstructor_throwsOnNullPointer() {
         assertThrows(NullPointerException.class, () -> new HasPropertyNode(null));
     }
diff --git a/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/query/PropertyDefinedNodeCtsTest.java b/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/query/PropertyDefinedNodeCtsTest.java
index 6577fcc..a4cd6ba 100644
--- a/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/query/PropertyDefinedNodeCtsTest.java
+++ b/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/query/PropertyDefinedNodeCtsTest.java
@@ -38,6 +38,17 @@
     public final CheckFlagsRule mCheckFlagsRule = DeviceFlagsValueProvider.createCheckFlagsRule();
 
     @Test
+    public void testEquals_identical() {
+        PropertyDefinedNode propertyDefinedOne = new PropertyDefinedNode(
+                new PropertyPath("example.property.path"));
+        PropertyDefinedNode propertyDefinedTwo = new PropertyDefinedNode(
+                new PropertyPath("example.property.path"));
+
+        assertThat(propertyDefinedOne).isEqualTo(propertyDefinedTwo);
+        assertThat(propertyDefinedOne.hashCode()).isEqualTo(propertyDefinedTwo.hashCode());
+    }
+
+    @Test
     public void testConstructor_throwsOnNullPointer() {
         assertThrows(NullPointerException.class, () -> new PropertyDefinedNode(null));
     }
diff --git a/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/query/SearchNodeCtsTest.java b/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/query/SearchNodeCtsTest.java
index 9f9219e..041af4e 100644
--- a/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/query/SearchNodeCtsTest.java
+++ b/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/query/SearchNodeCtsTest.java
@@ -42,6 +42,17 @@
     public final CheckFlagsRule mCheckFlagsRule = DeviceFlagsValueProvider.createCheckFlagsRule();
 
     @Test
+    public void testEquals_identical() {
+        SearchNode searchNodeOne = new SearchNode(
+                new TextNode("foo"), List.of(new PropertyPath("example.property.path")));
+        SearchNode searchNodeTwo = new SearchNode(
+                new TextNode("foo"), List.of(new PropertyPath("example.property.path")));
+
+        assertThat(searchNodeOne).isEqualTo(searchNodeTwo);
+        assertThat(searchNodeOne.hashCode()).isEqualTo(searchNodeTwo.hashCode());
+    }
+
+    @Test
     public void testConstructor_defaultValues() {
         TextNode node = new TextNode("foo");
         SearchNode searchNode = new SearchNode(node);
diff --git a/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/query/SemanticSearchNodeCtsTest.java b/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/query/SemanticSearchNodeCtsTest.java
index 41c8eb3..b76f036 100644
--- a/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/query/SemanticSearchNodeCtsTest.java
+++ b/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/query/SemanticSearchNodeCtsTest.java
@@ -36,6 +36,17 @@
     public final CheckFlagsRule mCheckFlagsRule = DeviceFlagsValueProvider.createCheckFlagsRule();
 
     @Test
+    public void testEquals_identical() {
+        SemanticSearchNode semanticSearchNodeOne = new SemanticSearchNode(0, -1.5f, 3,
+                SearchSpec.EMBEDDING_SEARCH_METRIC_TYPE_DOT_PRODUCT);
+        SemanticSearchNode semanticSearchNodeTwo = new SemanticSearchNode(0, -1.5f, 3,
+                SearchSpec.EMBEDDING_SEARCH_METRIC_TYPE_DOT_PRODUCT);
+
+        assertThat(semanticSearchNodeOne).isEqualTo(semanticSearchNodeTwo);
+        assertThat(semanticSearchNodeOne.hashCode()).isEqualTo(semanticSearchNodeTwo.hashCode());
+    }
+
+    @Test
     public void testConstructor_throwsOnNegativeIndex() {
         assertThrows(IllegalArgumentException.class, () -> new SemanticSearchNode(-1));
     }
diff --git a/appsearch/appsearch/src/main/java/androidx/appsearch/ast/NegationNode.java b/appsearch/appsearch/src/main/java/androidx/appsearch/ast/NegationNode.java
index 13e9686..645d312 100644
--- a/appsearch/appsearch/src/main/java/androidx/appsearch/ast/NegationNode.java
+++ b/appsearch/appsearch/src/main/java/androidx/appsearch/ast/NegationNode.java
@@ -25,6 +25,7 @@
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
+import java.util.Objects;
 
 /**
  * {@link Node} that stores a child node to be logically negated with a negative sign ("-")
@@ -118,4 +119,17 @@
     public String toString() {
         return "NOT " + getChild();
     }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+        NegationNode that = (NegationNode) o;
+        return Objects.equals(mChildren, that.mChildren);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hashCode(mChildren);
+    }
 }
diff --git a/appsearch/appsearch/src/main/java/androidx/appsearch/ast/TextNode.java b/appsearch/appsearch/src/main/java/androidx/appsearch/ast/TextNode.java
index 3eefa56..473ee94 100644
--- a/appsearch/appsearch/src/main/java/androidx/appsearch/ast/TextNode.java
+++ b/appsearch/appsearch/src/main/java/androidx/appsearch/ast/TextNode.java
@@ -22,6 +22,8 @@
 import androidx.appsearch.flags.Flags;
 import androidx.core.util.Preconditions;
 
+import java.util.Objects;
+
 /**
  * {@link Node} that stores text.
  *
@@ -259,4 +261,18 @@
     private boolean isLatinLetter(char c) {
         return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z');
     }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+        TextNode textNode = (TextNode) o;
+        return mPrefix == textNode.mPrefix && mVerbatim == textNode.mVerbatim
+                && Objects.equals(mValue, textNode.mValue);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(mValue, mPrefix, mVerbatim);
+    }
 }
diff --git a/appsearch/appsearch/src/main/java/androidx/appsearch/ast/operators/AndNode.java b/appsearch/appsearch/src/main/java/androidx/appsearch/ast/operators/AndNode.java
index 8774fab..82f1bbd 100644
--- a/appsearch/appsearch/src/main/java/androidx/appsearch/ast/operators/AndNode.java
+++ b/appsearch/appsearch/src/main/java/androidx/appsearch/ast/operators/AndNode.java
@@ -28,6 +28,7 @@
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
+import java.util.Objects;
 
 /**
  * {@link Node} that represents logical AND of nodes.
@@ -135,4 +136,17 @@
     public String toString() {
         return "(" + TextUtils.join(" AND ", mChildren) + ")";
     }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+        AndNode andNode = (AndNode) o;
+        return Objects.equals(mChildren, andNode.mChildren);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hashCode(mChildren);
+    }
 }
diff --git a/appsearch/appsearch/src/main/java/androidx/appsearch/ast/operators/ComparatorNode.java b/appsearch/appsearch/src/main/java/androidx/appsearch/ast/operators/ComparatorNode.java
index df4c8f2..a7e032a 100644
--- a/appsearch/appsearch/src/main/java/androidx/appsearch/ast/operators/ComparatorNode.java
+++ b/appsearch/appsearch/src/main/java/androidx/appsearch/ast/operators/ComparatorNode.java
@@ -28,6 +28,7 @@
 
 import java.lang.annotation.Retention;
 import java.lang.annotation.RetentionPolicy;
+import java.util.Objects;
 
 /**
  * {@link Node} that represents a numeric search expression between a property and a numeric value.
@@ -43,7 +44,7 @@
  */
 @ExperimentalAppSearchApi
 @FlaggedApi(Flags.FLAG_ENABLE_ABSTRACT_SYNTAX_TREES)
-public class ComparatorNode implements Node {
+public final class ComparatorNode implements Node {
     /**
      * Enums representing different comparators for numeric search expressions in the query
      * language.
@@ -171,4 +172,18 @@
         // String.format("(%s %s %s)", mPropertyPath, comparatorString, mValue);
         return "(" + mPropertyPath + " " + comparatorString + " " + mValue + ")";
     }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (!(o instanceof ComparatorNode)) return false;
+        ComparatorNode that = (ComparatorNode) o;
+        return mComparator == that.mComparator && mValue == that.mValue && Objects.equals(
+                mPropertyPath, that.mPropertyPath);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(mComparator, mPropertyPath, mValue);
+    }
 }
diff --git a/appsearch/appsearch/src/main/java/androidx/appsearch/ast/operators/OrNode.java b/appsearch/appsearch/src/main/java/androidx/appsearch/ast/operators/OrNode.java
index c073122..fe87dbf 100644
--- a/appsearch/appsearch/src/main/java/androidx/appsearch/ast/operators/OrNode.java
+++ b/appsearch/appsearch/src/main/java/androidx/appsearch/ast/operators/OrNode.java
@@ -28,6 +28,7 @@
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
+import java.util.Objects;
 
 /**
  * {@link Node} that represents logical OR of nodes.
@@ -134,4 +135,17 @@
     public String toString() {
         return "(" + TextUtils.join(" OR ", mChildren) + ")";
     }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+        OrNode orNode = (OrNode) o;
+        return Objects.equals(mChildren, orNode.mChildren);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hashCode(mChildren);
+    }
 }
diff --git a/appsearch/appsearch/src/main/java/androidx/appsearch/ast/operators/PropertyRestrictNode.java b/appsearch/appsearch/src/main/java/androidx/appsearch/ast/operators/PropertyRestrictNode.java
index 42c7902..f0efef2 100644
--- a/appsearch/appsearch/src/main/java/androidx/appsearch/ast/operators/PropertyRestrictNode.java
+++ b/appsearch/appsearch/src/main/java/androidx/appsearch/ast/operators/PropertyRestrictNode.java
@@ -27,6 +27,7 @@
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
+import java.util.Objects;
 
 /**
  * {@link Node} that represents a property restrict.
@@ -42,7 +43,7 @@
  */
 @ExperimentalAppSearchApi
 @FlaggedApi(Flags.FLAG_ENABLE_ABSTRACT_SYNTAX_TREES)
-public class PropertyRestrictNode implements Node {
+public final class PropertyRestrictNode implements Node {
     private PropertyPath mProperty;
     private final List<Node> mChildren = new ArrayList<>(1);
 
@@ -115,4 +116,18 @@
     public String toString() {
         return "(" + mProperty + ":" + getChild() + ")";
     }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (!(o instanceof PropertyRestrictNode)) return false;
+        PropertyRestrictNode that = (PropertyRestrictNode) o;
+        return Objects.equals(mProperty, that.mProperty) && Objects.equals(
+                mChildren, that.mChildren);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(mProperty, mChildren);
+    }
 }
diff --git a/appsearch/appsearch/src/main/java/androidx/appsearch/ast/query/GetSearchStringParameterNode.java b/appsearch/appsearch/src/main/java/androidx/appsearch/ast/query/GetSearchStringParameterNode.java
index e427d02..c336dfa 100644
--- a/appsearch/appsearch/src/main/java/androidx/appsearch/ast/query/GetSearchStringParameterNode.java
+++ b/appsearch/appsearch/src/main/java/androidx/appsearch/ast/query/GetSearchStringParameterNode.java
@@ -104,4 +104,17 @@
         return FunctionNode.FUNCTION_NAME_GET_SEARCH_STRING_PARAMETER
                 + "(" + mSearchStringIndex + ")";
     }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+        GetSearchStringParameterNode that = (GetSearchStringParameterNode) o;
+        return mSearchStringIndex == that.mSearchStringIndex;
+    }
+
+    @Override
+    public int hashCode() {
+        return Integer.hashCode(mSearchStringIndex);
+    }
 }
diff --git a/appsearch/appsearch/src/main/java/androidx/appsearch/ast/query/HasPropertyNode.java b/appsearch/appsearch/src/main/java/androidx/appsearch/ast/query/HasPropertyNode.java
index fef437b..1207f74 100644
--- a/appsearch/appsearch/src/main/java/androidx/appsearch/ast/query/HasPropertyNode.java
+++ b/appsearch/appsearch/src/main/java/androidx/appsearch/ast/query/HasPropertyNode.java
@@ -24,6 +24,8 @@
 import androidx.appsearch.flags.Flags;
 import androidx.core.util.Preconditions;
 
+import java.util.Objects;
+
 /**
  * {@link FunctionNode} representing the `hasProperty` query function.
  *
@@ -84,4 +86,17 @@
     public String toString() {
         return FunctionNode.FUNCTION_NAME_HAS_PROPERTY + "(\"" + mProperty + "\")";
     }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+        HasPropertyNode that = (HasPropertyNode) o;
+        return Objects.equals(mProperty, that.mProperty);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hashCode(mProperty);
+    }
 }
diff --git a/appsearch/appsearch/src/main/java/androidx/appsearch/ast/query/PropertyDefinedNode.java b/appsearch/appsearch/src/main/java/androidx/appsearch/ast/query/PropertyDefinedNode.java
index 4423a38..ccfdd96 100644
--- a/appsearch/appsearch/src/main/java/androidx/appsearch/ast/query/PropertyDefinedNode.java
+++ b/appsearch/appsearch/src/main/java/androidx/appsearch/ast/query/PropertyDefinedNode.java
@@ -24,6 +24,8 @@
 import androidx.appsearch.flags.Flags;
 import androidx.core.util.Preconditions;
 
+import java.util.Objects;
+
 /**
  * {@link FunctionNode} representing the `propertyDefined` query function.
  *
@@ -85,4 +87,17 @@
     public String toString() {
         return FUNCTION_NAME_PROPERTY_DEFINED + "(\"" + mProperty + "\")";
     }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+        PropertyDefinedNode that = (PropertyDefinedNode) o;
+        return Objects.equals(mProperty, that.mProperty);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hashCode(mProperty);
+    }
 }
diff --git a/appsearch/appsearch/src/main/java/androidx/appsearch/ast/query/SearchNode.java b/appsearch/appsearch/src/main/java/androidx/appsearch/ast/query/SearchNode.java
index 6ed85b4..1bf086c 100644
--- a/appsearch/appsearch/src/main/java/androidx/appsearch/ast/query/SearchNode.java
+++ b/appsearch/appsearch/src/main/java/androidx/appsearch/ast/query/SearchNode.java
@@ -28,6 +28,7 @@
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
+import java.util.Objects;
 
 /**
  * {@link FunctionNode} that represents the search function.
@@ -219,4 +220,18 @@
         }
         return stringBuilder.toString();
     }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+        SearchNode that = (SearchNode) o;
+        return Objects.equals(mChildren, that.mChildren) && Objects.equals(
+                mProperties, that.mProperties);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(mChildren, mProperties);
+    }
 }
diff --git a/appsearch/appsearch/src/main/java/androidx/appsearch/ast/query/SemanticSearchNode.java b/appsearch/appsearch/src/main/java/androidx/appsearch/ast/query/SemanticSearchNode.java
index 7f9f07a..8fe088d 100644
--- a/appsearch/appsearch/src/main/java/androidx/appsearch/ast/query/SemanticSearchNode.java
+++ b/appsearch/appsearch/src/main/java/androidx/appsearch/ast/query/SemanticSearchNode.java
@@ -24,6 +24,8 @@
 import androidx.appsearch.flags.Flags;
 import androidx.core.util.Preconditions;
 
+import java.util.Objects;
+
 /**
  * {@link FunctionNode} that represents the semanticSearch function.
  *
@@ -297,4 +299,19 @@
         }
         throw new IllegalStateException("Invalid Metric Type");
     }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+        SemanticSearchNode that = (SemanticSearchNode) o;
+        return mVectorIndex == that.mVectorIndex && Float.compare(mLowerBound,
+                that.mLowerBound) == 0 && Float.compare(mUpperBound, that.mUpperBound) == 0
+                && mDistanceMetric == that.mDistanceMetric;
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(mVectorIndex, mLowerBound, mUpperBound, mDistanceMetric);
+    }
 }
diff --git a/benchmark/benchmark-common/src/main/java/androidx/benchmark/DeviceInfo.kt b/benchmark/benchmark-common/src/main/java/androidx/benchmark/DeviceInfo.kt
index 9cfbbd5..3083747 100644
--- a/benchmark/benchmark-common/src/main/java/androidx/benchmark/DeviceInfo.kt
+++ b/benchmark/benchmark-common/src/main/java/androidx/benchmark/DeviceInfo.kt
@@ -269,7 +269,7 @@
      *
      * See b/292294133
      */
-    const val ART_MAINLINE_MIN_VERSION_CLASS_INIT_TRACING = 341511000L
+    const val ART_MAINLINE_MIN_VERSION_CLASS_LOAD_TRACING = 341511000L
 
     /**
      * Starting with an API 34 change cherry-picked to mainline, when `verify`-compiled, ART will
@@ -324,14 +324,14 @@
         Build.VERSION.SDK_INT in 26..30 || // b/313868903
             artMainlineVersion in ART_MAINLINE_VERSIONS_AFFECTING_METHOD_TRACING // b/303660864
 
-    fun isClassInitTracingAvailable(targetApiLevel: Int, targetArtMainlineVersion: Long?): Boolean =
+    fun isClassLoadTracingAvailable(targetApiLevel: Int, targetArtMainlineVersion: Long?): Boolean =
         targetApiLevel >= 35 ||
             (targetApiLevel >= 31 &&
                 (targetArtMainlineVersion == null ||
-                    targetArtMainlineVersion >= ART_MAINLINE_MIN_VERSION_CLASS_INIT_TRACING))
+                    targetArtMainlineVersion >= ART_MAINLINE_MIN_VERSION_CLASS_LOAD_TRACING))
 
-    val supportsClassInitTracing =
-        isClassInitTracingAvailable(Build.VERSION.SDK_INT, artMainlineVersion)
+    val supportsClassLoadTracing =
+        isClassLoadTracingAvailable(Build.VERSION.SDK_INT, artMainlineVersion)
 
     val supportsRuntimeImages =
         Build.VERSION.SDK_INT >= 34 || artMainlineVersion >= ART_MAINLINE_MIN_VERSION_RUNTIME_IMAGE
diff --git a/benchmark/benchmark-common/src/main/java/androidx/benchmark/Insight.kt b/benchmark/benchmark-common/src/main/java/androidx/benchmark/Insight.kt
index 6fa030b..adaf085 100644
--- a/benchmark/benchmark-common/src/main/java/androidx/benchmark/Insight.kt
+++ b/benchmark/benchmark-common/src/main/java/androidx/benchmark/Insight.kt
@@ -19,25 +19,77 @@
 import androidx.annotation.RestrictTo
 
 /**
- * Represents an insight into performance issues detected during a benchmark.
+ * Individual case of a problem identified in a given trace (from a specific iteration).
  *
- * Provides details about the specific criterion that was violated, along with information about
- * where and how the violation was observed.
- *
- * @param criterion A description of the performance issue, including the expected behavior and any
- *   relevant thresholds.
- * @param observedV2 Specific details about when and how the violation occurred, such as the
- *   iterations where it was observed and any associated values. Uses [LinkFormat.V2].
- * @param observedV3 Specific details about when and how the violation occurred, such as the
- *   iterations where it was observed and any associated values. Uses [LinkFormat.V3] to link more
- *   precisely into traces.
- *
- * TODO(364598145): generalize
- * TODO: Defer string construction until InstrumentationResults output step
+ * Benchmark output will summarize multiple insights, for example:
+ * ```Markdown
+ * [JIT Activity](https://d.android.com/test#JIT_ACTIVITY) (expected: < 100000000ns)
+ * seen in iterations: [6](file:///path/to/trace.perfetto-trace)(328462261ns),
+ * ```
  */
 @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
 data class Insight(
-    val criterion: String,
-    val observedV2: String,
-    val observedV3: String,
-)
+    /**
+     * Category of the Insight, representing the type of problem.
+     *
+     * Every similar insight must identify equal [Category]s to enable macrobenchmark to group them
+     * across iterations.
+     */
+    val category: Category,
+
+    /** suffix after the deeplink to convey problem severity, e.g. "(100000000ns)" */
+    val observedLabel: String,
+
+    /**
+     * Link to content within a trace to highlight for the given problem.
+     *
+     * Insights should specify [TraceDeepLink.SelectionParams] so that the correct process, thread,
+     * and time range is highlighted. In addition, the optional
+     * [TraceDeepLink.SelectionParams.query] can be specified to highlight specific slices or other
+     * events of interest in the trace.
+     */
+    val deepLink: TraceDeepLink,
+
+    /** Macrobenchmark iteration in which this insight was observed. */
+    val iterationIndex: Int,
+) {
+    /**
+     * Category represents general expectations that have been violated by an insight - e.g. JIT
+     * shouldn't take longer than XX ms, or trampoline activities shouldn't be used.
+     *
+     * Every Insight that shares the same conceptual category must share an equal [Category] so that
+     * macrobenchmark can group them when summarizing observed patterns across multiple iterations.
+     *
+     * In Studio versions supporting web links, will produce an output like the following:
+     * ```Markdown
+     * [JIT compiled methods](https://d.android.com/test#JIT_COMPILED_METHODS) (expected: < 65 count)
+     * ```
+     *
+     * In the above example:
+     * - [title] is `"JIT compiled methods"`
+     * - [titleUrl] is `"https://d.android.com/test#JIT_COMPILED_METHODS"`
+     * - [postTitleLabel] is " (expected: < 65 count)"
+     *
+     * In Studio versions not supporting web links, [titleUrl] is ignored.
+     */
+    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+    data class Category(
+        /** Title of the Insight category, for example "JIT compiled methods" */
+        val title: String,
+        /** Optional web URL which, if non-null, will make the [title] a link. */
+        val titleUrl: String?,
+        /**
+         * Suffix after the title, generally specifying expected values.
+         *
+         * For example, " (expected: < 65 count)"
+         */
+        val postTitleLabel: String,
+    ) {
+        fun header(linkFormat: LinkFormat): String =
+            if (linkFormat == LinkFormat.V3 && titleUrl != null) {
+                Markdown.createLink(title, titleUrl)
+            } else {
+                title
+            } + postTitleLabel
+    }
+}
diff --git a/benchmark/benchmark-common/src/main/java/androidx/benchmark/InsightSummary.kt b/benchmark/benchmark-common/src/main/java/androidx/benchmark/InsightSummary.kt
new file mode 100644
index 0000000..ebfd7ce
--- /dev/null
+++ b/benchmark/benchmark-common/src/main/java/androidx/benchmark/InsightSummary.kt
@@ -0,0 +1,69 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.benchmark
+
+import androidx.annotation.RestrictTo
+
+private fun List<Insight>.toObserved(linkFormat: LinkFormat): String {
+    return this.joinToString(separator = " ", prefix = "seen in iterations: ") { insight ->
+        insight.deepLink.createMarkdownLink(insight.iterationIndex.toString(), linkFormat) +
+            "(${insight.observedLabel})"
+    }
+}
+
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+fun List<Insight>.createInsightSummaries(): List<InsightSummary> {
+    return this.groupBy { it.category }
+        .map { (category, insights) ->
+            InsightSummary(category, insights.sortedBy { it.iterationIndex })
+        }
+}
+
+/**
+ * Represents an insight into performance issues detected during a benchmark.
+ *
+ * Provides details about the specific criterion that was violated, along with information about
+ * where and how the violation was observed.
+ *
+ * @param category A description of the performance issue, including the expected behavior and any
+ *   relevant thresholds.
+ * @param observedV2 Specific details about when and how the violation occurred, such as the
+ *   iterations where it was observed and any associated values. Uses [LinkFormat.V2].
+ * @param observedV3 Specific details about when and how the violation occurred, such as the
+ *   iterations where it was observed and any associated values. Uses [LinkFormat.V3] to link more
+ *   precisely into traces.
+ *
+ * TODO(364598145): generalize
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+data class InsightSummary(
+    val category: String,
+    val observedV2: String,
+    val observedV3: String,
+) {
+    constructor(
+        category: Insight.Category,
+        insights: List<Insight>
+    ) : this(
+        category = category.header(LinkFormat.V3), // TODO: avoid this format hard coding!
+        observedV2 = insights.toObserved(LinkFormat.V2),
+        observedV3 = insights.toObserved(LinkFormat.V3)
+    ) {
+        require(insights.isNotEmpty())
+        require(insights.all { it.category == category })
+    }
+}
diff --git a/benchmark/benchmark-common/src/main/java/androidx/benchmark/InstrumentationResults.kt b/benchmark/benchmark-common/src/main/java/androidx/benchmark/InstrumentationResults.kt
index 0cfa638..b4ebdff 100644
--- a/benchmark/benchmark-common/src/main/java/androidx/benchmark/InstrumentationResults.kt
+++ b/benchmark/benchmark-common/src/main/java/androidx/benchmark/InstrumentationResults.kt
@@ -79,7 +79,7 @@
         measurements: Measurements? = null,
         iterationTracePaths: List<String>? = null,
         profilerResults: List<Profiler.ResultFile> = emptyList(),
-        insights: List<Insight> = emptyList(),
+        insightSummaries: List<InsightSummary> = emptyList(),
         useTreeDisplayFormat: Boolean = false
     ) {
         if (warningMessage != null) {
@@ -92,7 +92,7 @@
                 measurements = measurements,
                 iterationTracePaths = iterationTracePaths,
                 profilerResults = profilerResults,
-                insights = insights,
+                insightSummaries = insightSummaries,
                 useTreeDisplayFormat = useTreeDisplayFormat
             )
         reportIdeSummary(summaryV2 = summaryPair.summaryV2, summaryV3 = summaryPair.summaryV3)
@@ -192,7 +192,7 @@
         measurements: Measurements? = null,
         iterationTracePaths: List<String>? = null,
         profilerResults: List<Profiler.ResultFile> = emptyList(),
-        insights: List<Insight> = emptyList(),
+        insightSummaries: List<InsightSummary> = emptyList(),
         useTreeDisplayFormat: Boolean = false,
     ): IdeSummaryPair {
         val warningMessage = ideWarningPrefix.ifEmpty { null }
@@ -317,10 +317,10 @@
                             tree.append("Metrics", 0)
                             for (metric in v2metricLines) tree.append(metric, 1)
                         }
-                        if (insights.isNotEmpty()) {
+                        if (insightSummaries.isNotEmpty()) {
                             tree.append("App Startup Insights", 0)
-                            for (insight in insights) {
-                                tree.append(insight.criterion, 1)
+                            for (insight in insightSummaries) {
+                                tree.append(insight.category, 1)
                                 val observed =
                                     when (linkFormat) {
                                         LinkFormat.V2 -> insight.observedV2
diff --git a/benchmark/benchmark-common/src/main/java/androidx/benchmark/TraceDeepLink.kt b/benchmark/benchmark-common/src/main/java/androidx/benchmark/TraceDeepLink.kt
index 05e68eb..009a190 100644
--- a/benchmark/benchmark-common/src/main/java/androidx/benchmark/TraceDeepLink.kt
+++ b/benchmark/benchmark-common/src/main/java/androidx/benchmark/TraceDeepLink.kt
@@ -23,7 +23,7 @@
 import java.util.zip.Deflater
 
 @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
-class TraceDeepLink(
+data class TraceDeepLink(
     /** Output relative path of trace file */
     private val outputRelativePath: String,
     private val selectionParams: SelectionParams?
@@ -46,7 +46,7 @@
             }
         }
 
-    class SelectionParams(
+    data class SelectionParams(
         val pid: Long,
         val tid: Long?,
         val ts: Long,
diff --git a/benchmark/benchmark-macro/build.gradle b/benchmark/benchmark-macro/build.gradle
index 5dd32d4..3b9fbf5 100644
--- a/benchmark/benchmark-macro/build.gradle
+++ b/benchmark/benchmark-macro/build.gradle
@@ -150,7 +150,6 @@
     prune 'perfetto.protos.AndroidMemoryUnaggregatedMetric'
     prune 'perfetto.protos.AndroidMultiuserMetric'
     prune 'perfetto.protos.AndroidNetworkMetric'
-    prune 'perfetto.protos.AndroidOtherTracesMetric'
     prune 'perfetto.protos.AndroidPackageList'
     prune 'perfetto.protos.AndroidPowerRails'
     prune 'perfetto.protos.AndroidProcessMetadata'
@@ -159,7 +158,6 @@
     prune 'perfetto.protos.AndroidSurfaceflingerMetric'
     prune 'perfetto.protos.AndroidTaskNames'
     prune 'perfetto.protos.AndroidTraceQualityMetric'
-    prune 'perfetto.protos.AndroidTrustyWorkqueues'
     prune 'perfetto.protos.G2dMetrics'
     prune 'perfetto.protos.JavaHeapHistogram'
     prune 'perfetto.protos.JavaHeapStats'
diff --git a/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/ArtMetricTest.kt b/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/ArtMetricTest.kt
index 6c36c4f..0b31597 100644
--- a/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/ArtMetricTest.kt
+++ b/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/ArtMetricTest.kt
@@ -39,17 +39,17 @@
             apiLevel = 35,
             artMainlineVersion = null, // unknown, but not important on 35
             expectedJit = SubMetric(177, 433.488508),
-            expectedClassInit = SubMetric(2013, 147.052337),
+            expectedClassLoad = SubMetric(2013, 147.052337),
             expectedClassVerify = SubMetric(0, 0.0)
         )
 
     @Test
-    fun filterOutClassInit() =
+    fun filterOutClassLoad() =
         verifyArtMetrics(
             apiLevel = 31,
-            artMainlineVersion = DeviceInfo.ART_MAINLINE_MIN_VERSION_CLASS_INIT_TRACING - 1,
+            artMainlineVersion = DeviceInfo.ART_MAINLINE_MIN_VERSION_CLASS_LOAD_TRACING - 1,
             expectedJit = SubMetric(177, 433.488508),
-            expectedClassInit = null, // drops class init
+            expectedClassLoad = null, // drops class load
             expectedClassVerify = SubMetric(0, 0.0)
         )
 
@@ -57,9 +57,9 @@
     fun oldVersionMainline() =
         verifyArtMetrics(
             apiLevel = 31,
-            artMainlineVersion = DeviceInfo.ART_MAINLINE_MIN_VERSION_CLASS_INIT_TRACING,
+            artMainlineVersion = DeviceInfo.ART_MAINLINE_MIN_VERSION_CLASS_LOAD_TRACING,
             expectedJit = SubMetric(177, 433.488508),
-            expectedClassInit = SubMetric(2013, 147.052337),
+            expectedClassLoad = SubMetric(2013, 147.052337),
             expectedClassVerify = SubMetric(0, 0.0)
         )
 
@@ -68,7 +68,7 @@
             apiLevel: Int,
             artMainlineVersion: Long?,
             expectedJit: SubMetric,
-            expectedClassInit: SubMetric?,
+            expectedClassLoad: SubMetric?,
             expectedClassVerify: SubMetric
         ) {
             val tracePath =
@@ -107,12 +107,12 @@
                     Metric.Measurement("artVerifyClassSumMs", expectedClassVerify.sum),
                     Metric.Measurement("artVerifyClassCount", expectedClassVerify.count.toDouble()),
                 ) +
-                    if (expectedClassInit != null) {
+                    if (expectedClassLoad != null) {
                         listOf(
-                            Metric.Measurement("artClassInitSumMs", expectedClassInit.sum),
+                            Metric.Measurement("artClassLoadSumMs", expectedClassLoad.sum),
                             Metric.Measurement(
-                                "artClassInitCount",
-                                expectedClassInit.count.toDouble()
+                                "artClassLoadCount",
+                                expectedClassLoad.count.toDouble()
                             ),
                         )
                     } else {
diff --git a/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/InsightTest.kt b/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/InsightTest.kt
new file mode 100644
index 0000000..2fad674
--- /dev/null
+++ b/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/InsightTest.kt
@@ -0,0 +1,146 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.benchmark.macro
+
+import androidx.benchmark.Insight
+import androidx.benchmark.InsightSummary
+import androidx.benchmark.TraceDeepLink
+import androidx.benchmark.createInsightSummaries
+import androidx.benchmark.macro.perfetto.queryStartupInsights
+import androidx.benchmark.perfetto.PerfettoTraceProcessor
+import androidx.test.filters.MediumTest
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+
+class InsightTest {
+    private val api35ColdStart =
+        createTempFileFromAsset(prefix = "api35_startup_cold_classinit", suffix = ".perfetto-trace")
+            .absolutePath
+
+    // TODO (b/377581661) use deeplink from metric
+    private val deepLink =
+        TraceDeepLink(
+            outputRelativePath = "/fake/output/relative/path.perfetto-trace",
+            selectionParams =
+                TraceDeepLink.SelectionParams(
+                    pid = 27246,
+                    tid = 27246,
+                    ts = 351868459760497,
+                    dur = 24573541,
+                    query = "SELECT 🐲\nFROM 🐉\nWHERE \ud83d\udc09.NAME = 'ハク'"
+                )
+        )
+
+    private val canonicalTraceInsights =
+        listOf(
+            Insight(
+                observedLabel = "123305107ns",
+                deepLink = deepLink,
+                iterationIndex = 6,
+                category =
+                    Insight.Category(
+                        titleUrl =
+                            "https://d.android.com/test#POTENTIAL_CPU_CONTENTION_WITH_ANOTHER_PROCESS",
+                        title = "Potential CPU contention with another process",
+                        postTitleLabel = " (expected: < 100000000ns)"
+                    )
+            ),
+            Insight(
+                observedLabel = "328462261ns",
+                deepLink = deepLink,
+                iterationIndex = 6,
+                category =
+                    Insight.Category(
+                        titleUrl = "https://d.android.com/test#JIT_ACTIVITY",
+                        title = "JIT Activity",
+                        postTitleLabel = " (expected: < 100000000ns)"
+                    )
+            ),
+            Insight(
+                observedLabel = "150 count",
+                deepLink = deepLink,
+                iterationIndex = 6,
+                category =
+                    Insight.Category(
+                        titleUrl = "https://d.android.com/test#JIT_COMPILED_METHODS",
+                        title = "JIT compiled methods",
+                        postTitleLabel = " (expected: < 65 count)"
+                    )
+            )
+        )
+
+    private val canonicalTraceInsightSummary =
+        listOf(
+            InsightSummary(
+                category =
+                    "[Potential CPU contention with another process](https://d.android.com/test#POTENTIAL_CPU_CONTENTION_WITH_ANOTHER_PROCESS) (expected: < 100000000ns)",
+                observedV2 =
+                    "seen in iterations: [6](file:///fake/output/relative/path.perfetto-trace)(123305107ns)",
+                observedV3 =
+                    "seen in iterations: [6](uri:///fake/output/relative/path.perfetto-trace?selectionParams=eNoryEyxNTI3MjFTK0Gwim2NTQ0tzCxMTC3NzQxMLM3VUkqLbI1MTM2NTU0M1QpLU4sqbYNdfVydQ7RV3QxULd1ULQ1UnYxUDRzdgvx9kcQsLIFi4R6uQa4ognp-jr5AEWMXbVUjc1VXY1ULIHIDM4xUHd2AggBTViPI)(123305107ns)"
+            ),
+            InsightSummary(
+                category =
+                    "[JIT Activity](https://d.android.com/test#JIT_ACTIVITY) (expected: < 100000000ns)",
+                observedV2 =
+                    "seen in iterations: [6](file:///fake/output/relative/path.perfetto-trace)(328462261ns)",
+                observedV3 =
+                    "seen in iterations: [6](uri:///fake/output/relative/path.perfetto-trace?selectionParams=eNoryEyxNTI3MjFTK0Gwim2NTQ0tzCxMTC3NzQxMLM3VUkqLbI1MTM2NTU0M1QpLU4sqbYNdfVydQ7RV3QxULd1ULQ1UnYxUDRzdgvx9kcQsLIFi4R6uQa4ognp-jr5AEWMXbVUjc1VXY1ULIHIDM4xUHd2AggBTViPI)(328462261ns)"
+            ),
+            InsightSummary(
+                category =
+                    "[JIT compiled methods](https://d.android.com/test#JIT_COMPILED_METHODS) (expected: < 65 count)",
+                observedV2 =
+                    "seen in iterations: [6](file:///fake/output/relative/path.perfetto-trace)(150 count)",
+                observedV3 =
+                    "seen in iterations: [6](uri:///fake/output/relative/path.perfetto-trace?selectionParams=eNoryEyxNTI3MjFTK0Gwim2NTQ0tzCxMTC3NzQxMLM3VUkqLbI1MTM2NTU0M1QpLU4sqbYNdfVydQ7RV3QxULd1ULQ1UnYxUDRzdgvx9kcQsLIFi4R6uQa4ognp-jr5AEWMXbVUjc1VXY1ULIHIDM4xUHd2AggBTViPI)(150 count)"
+            ),
+        )
+
+    @MediumTest
+    @Test
+    fun queryStartupInsights() {
+        PerfettoTraceProcessor.runSingleSessionServer(api35ColdStart) {
+            assertThat(
+                    queryStartupInsights(
+                        helpUrlBase = "https://d.android.com/test#",
+                        traceOutputRelativePath = "/fake/output/relative/path.perfetto-trace",
+                        iteration = 6,
+                        packageName = Packages.MISSING
+                    )
+                )
+                .isEmpty()
+
+            assertThat(
+                    queryStartupInsights(
+                        helpUrlBase = "https://d.android.com/test#",
+                        traceOutputRelativePath = "/fake/output/relative/path.perfetto-trace",
+                        iteration = 6,
+                        packageName = "androidx.compose.integration.hero.macrobenchmark.target"
+                    )
+                )
+                .isEqualTo(canonicalTraceInsights)
+        }
+    }
+
+    @MediumTest
+    @Test
+    fun createInsightSummaries() {
+        assertThat(canonicalTraceInsights.createInsightSummaries())
+            .isEqualTo(canonicalTraceInsightSummary)
+    }
+}
diff --git a/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/RuntimeImageTest.kt b/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/RuntimeImageTest.kt
index 86fb1c3..5251431 100644
--- a/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/RuntimeImageTest.kt
+++ b/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/RuntimeImageTest.kt
@@ -58,19 +58,19 @@
 
     @LargeTest
     @Test
-    fun classInitCount() {
+    fun classLoadCount() {
         assumeTrue("Test requires runtime image support", DeviceInfo.supportsRuntimeImages)
-        assumeTrue("Test requires class init tracing", DeviceInfo.supportsClassInitTracing)
+        assumeTrue("Test requires class load tracing", DeviceInfo.supportsClassLoadTracing)
 
-        val testName = RuntimeImageTest::classInitCount.name
+        val testName = RuntimeImageTest::classLoadCount.name
         val results = captureRecyclerViewListStartupMetrics(testName)
 
-        val classInitCount = results["artClassInitCount"]!!.runs
+        val classLoadCount = results["artClassLoadCount"]!!.runs
 
         // observed >700 in practice, lower threshold used to be resilient
         assertTrue(
-            classInitCount.all { it > 500 },
-            "too few class inits seen, observed: $classInitCount"
+            classLoadCount.all { it > 500 },
+            "too few class loads seen, observed: $classLoadCount"
         )
     }
 }
diff --git a/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/InsightExtensions.kt b/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/InsightExtensions.kt
deleted file mode 100644
index 6c7ca50..0000000
--- a/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/InsightExtensions.kt
+++ /dev/null
@@ -1,198 +0,0 @@
-/*
- * Copyright 2024 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package androidx.benchmark.macro
-
-import androidx.benchmark.Insight
-import androidx.benchmark.LinkFormat
-import androidx.benchmark.Markdown
-import androidx.benchmark.Outputs
-import androidx.benchmark.StartupInsightsConfig
-import androidx.benchmark.TraceDeepLink
-import androidx.benchmark.inMemoryTrace
-import androidx.benchmark.perfetto.PerfettoTraceProcessor
-import androidx.benchmark.perfetto.Row
-import java.net.URLEncoder
-import perfetto.protos.AndroidStartupMetric.SlowStartReason
-import perfetto.protos.AndroidStartupMetric.ThresholdValue.ThresholdUnit
-
-/**
- * Aggregates raw SlowStartReason results into a list of [Insight]s - in a format easier to display
- * in the IDE as a summary.
- *
- * TODO(353692849): add unit tests
- */
-internal fun createInsightsIdeSummary(
-    startupInsightsConfig: StartupInsightsConfig?,
-    iterationResults: List<IterationResult>
-): List<Insight> {
-    fun createInsightString(
-        criterion: SlowStartReason,
-        observed: List<IndexedValue<SlowStartReason>>
-    ): Insight {
-        observed.forEach {
-            require(it.value.reason_id == criterion.reason_id)
-            require(it.value.expected_value == criterion.expected_value)
-        }
-
-        val expectedValue = requireNotNull(criterion.expected_value)
-        val thresholdUnit = requireNotNull(expectedValue.unit)
-        require(thresholdUnit != ThresholdUnit.THRESHOLD_UNIT_UNSPECIFIED)
-        val unitSuffix =
-            when (thresholdUnit) {
-                ThresholdUnit.NS -> "ns"
-                ThresholdUnit.PERCENTAGE -> "%"
-                ThresholdUnit.COUNT -> " count"
-                ThresholdUnit.TRUE_OR_FALSE -> ""
-                else -> " ${thresholdUnit.toString().lowercase()}"
-            }
-
-        val criterionString = buildString {
-            val reasonHelpUrlBase = startupInsightsConfig?.reasonHelpUrlBase
-            if (reasonHelpUrlBase != null) {
-                append("[")
-                append(requireNotNull(criterion.reason).replace("]", "\\]"))
-                append("]")
-                append("(")
-                append(reasonHelpUrlBase.replace(")", "\\)")) // base url
-                val reasonId = requireNotNull(criterion.reason_id).name
-                append(URLEncoder.encode(reasonId, Charsets.UTF_8.name())) // reason id as a suffix
-                append(")")
-            } else {
-                append(requireNotNull(criterion.reason))
-            }
-
-            val thresholdValue = requireNotNull(expectedValue.value_)
-            append(" (expected: ")
-            if (thresholdUnit == ThresholdUnit.TRUE_OR_FALSE) {
-                require(thresholdValue in 0L..1L)
-                if (thresholdValue == 0L) append("false")
-                if (thresholdValue == 1L) append("true")
-            } else {
-                if (expectedValue.higher_expected == true) append("> ")
-                if (expectedValue.higher_expected == false) append("< ")
-                append(thresholdValue)
-                append(unitSuffix)
-            }
-            append(")")
-        }
-
-        val observedMap =
-            listOf(LinkFormat.V2, LinkFormat.V3).associate { linkFormat ->
-                val observedString =
-                    observed.joinToString(" ", "seen in iterations: ") {
-                        val observedValue = requireNotNull(it.value.actual_value?.value_)
-                        val observedString: String =
-                            if (thresholdUnit == ThresholdUnit.TRUE_OR_FALSE) {
-                                require(observedValue in 0L..1L)
-                                if (observedValue == 0L) "false" else "true"
-                            } else {
-                                "$observedValue$unitSuffix"
-                            }
-
-                        // TODO(364590575): implement zoom-in on relevant parts of the trace and
-                        // then make
-                        //  the 'actualString' also part of the link.
-                        val tracePath = iterationResults.getOrNull(it.index)?.tracePath
-                        if (tracePath == null) "${it.index}($observedString)"
-                        else
-                            when (linkFormat) {
-                                LinkFormat.V2 -> {
-                                    val relativePath = Outputs.relativePathFor(tracePath)
-                                    val link = Markdown.createFileLink("${it.index}", relativePath)
-                                    "$link($observedString)"
-                                }
-                                LinkFormat.V3 -> {
-                                    TraceDeepLink(
-                                            outputRelativePath = Outputs.relativePathFor(tracePath),
-                                            selectionParams =
-                                                iterationResults[it.index]
-                                                    .defaultStartupInsightSelectionParams
-                                        )
-                                        .createMarkdownLink(
-                                            label = "${it.index}($observedString)",
-                                            linkFormat = LinkFormat.V3
-                                        )
-                                }
-                            }
-                    }
-                Pair(linkFormat, observedString)
-            }
-
-        return Insight(
-            criterion = criterionString,
-            observedV2 = observedMap[LinkFormat.V2]!!,
-            observedV3 = observedMap[LinkFormat.V3]!!
-        )
-    }
-
-    // Pivot from List<iteration_id -> insight_list> to List<insight -> iteration_list>
-    // and convert to a format expected in Studio text output.
-    return iterationResults
-        .map { it.insights }
-        .flatMapIndexed { iterationId, insights -> insights.map { IndexedValue(iterationId, it) } }
-        .groupBy { it.value.reason_id }
-        .values
-        .map { createInsightString(it.first().value, it) }
-}
-
-/**
- * Sets [TraceDeepLink.SelectionParams] based on the last `Startup` slice. Temporary hack until we
- * get this information directly from [SlowStartReason].
- */
-internal fun PerfettoTraceProcessor.Session.extractStartupSliceSelectionParams(
-    packageName: String
-): TraceDeepLink.SelectionParams? {
-    inMemoryTrace("extractStartupSliceSelectionParams") {
-        // note: not using utid, upid as not stable between trace processor releases
-        // also note: https://perfetto.dev/docs/analysis/sql-tables#process
-        // also note: https://perfetto.dev/docs/analysis/sql-tables#thread
-        val query =
-            """
-                    select
-                        process.pid as pid,
-                        thread.tid as tid,
-                        slice.ts,
-                        slice.dur,
-                        slice.name, -- left for debugging, can be removed
-                        process.name as process_name -- left for debugging, can be removed
-                    from slice
-                        join thread_track on thread_track.id = slice.track_id
-                        join thread using(utid)
-                        join process using(upid)
-                    where slice.name = 'Startup' and process.name like '${packageName}%'
-                    """
-                .trimIndent()
-
-        val events = query(query).toList()
-        if (events.isEmpty()) {
-            return null
-        } else {
-            val queryResult: Row = events.first()
-            return TraceDeepLink.SelectionParams(
-                pid = queryResult.long("pid"),
-                tid = queryResult.long("tid"),
-                ts = queryResult.long("ts"),
-                dur = queryResult.long("dur"),
-                // query belongs in the [SlowStartReason] object (to be added there)
-                // we can't do anything here now, so setting it to something that will test
-                // that we're handling Unicode correctly
-                // see: http://shortn/_yWn9yR2OHr
-                query = "SELECT 🐲\nFROM 🐉\nWHERE \ud83d\udc09.NAME = 'ハク'"
-            )
-        }
-    }
-}
diff --git a/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/Macrobenchmark.kt b/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/Macrobenchmark.kt
index 249431aa..2f1aa74 100644
--- a/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/Macrobenchmark.kt
+++ b/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/Macrobenchmark.kt
@@ -34,6 +34,7 @@
 import androidx.benchmark.Shell
 import androidx.benchmark.checkAndGetSuppressionState
 import androidx.benchmark.conditionalError
+import androidx.benchmark.createInsightSummaries
 import androidx.benchmark.inMemoryTrace
 import androidx.benchmark.json.BenchmarkData
 import androidx.benchmark.macro.MacrobenchmarkScope.KillFlushMode
@@ -308,20 +309,6 @@
             }
         }
     }
-    /*
-    val tracePaths = mutableListOf<String>()
-    val profilerResults = mutableListOf<Profiler.ResultFile>()
-    val measurementsList = mutableListOf<List<Metric.Measurement>>()
-    val insightsList = mutableListOf<List<AndroidStartupMetric.SlowStartReason>>()
-
-    iterationResults.forEach {
-        tracePaths += it.tracePaths
-        profilerResults += it.profilerResults
-        measurementsList += it.measurements
-        insightsList += it.insights
-    }
-
-     */
 
     // Merge measurements
     val measurements = iterationResults.map { it.measurements }.mergeMultiIterResults()
@@ -342,11 +329,7 @@
             warningMessage = warningMessage,
             testName = uniqueName,
             measurements = measurements,
-            insights =
-                createInsightsIdeSummary(
-                    experimentalConfig?.startupInsightsConfig,
-                    iterationResults
-                ),
+            insightSummaries = iterationResults.flatMap { it.insights }.createInsightSummaries(),
             iterationTracePaths = iterationTracePaths,
             profilerResults = profilerResults,
             useTreeDisplayFormat = experimentalConfig?.startupInsightsConfig?.isEnabled == true
diff --git a/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/MacrobenchmarkPhase.kt b/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/MacrobenchmarkPhase.kt
index c36c6d2..c146870 100644
--- a/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/MacrobenchmarkPhase.kt
+++ b/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/MacrobenchmarkPhase.kt
@@ -18,11 +18,14 @@
 
 import android.os.Build
 import android.util.Log
+import androidx.benchmark.Arguments
 import androidx.benchmark.ExperimentalBenchmarkConfigApi
 import androidx.benchmark.ExperimentalConfig
+import androidx.benchmark.Insight
+import androidx.benchmark.Outputs
 import androidx.benchmark.Profiler
-import androidx.benchmark.TraceDeepLink
 import androidx.benchmark.inMemoryTrace
+import androidx.benchmark.macro.perfetto.queryStartupInsights
 import androidx.benchmark.perfetto.PerfettoCapture
 import androidx.benchmark.perfetto.PerfettoCaptureWrapper
 import androidx.benchmark.perfetto.PerfettoConfig
@@ -32,8 +35,6 @@
 import androidx.benchmark.perfetto.appendUiState
 import androidx.tracing.trace
 import java.io.File
-import perfetto.protos.AndroidStartupMetric
-import perfetto.protos.TraceMetrics
 
 /** A Profiler being used during a Macro Benchmark Phase. */
 internal interface PhaseProfiler {
@@ -59,8 +60,7 @@
     val tracePath: String,
     val profilerResultFiles: List<Profiler.ResultFile>,
     val measurements: List<Metric.Measurement>,
-    val insights: List<AndroidStartupMetric.SlowStartReason>,
-    val defaultStartupInsightSelectionParams: TraceDeepLink.SelectionParams?,
+    val insights: List<Insight>
 )
 
 /** Run a Macrobenchmark Phase and collect a list of [IterationResult]. */
@@ -90,7 +90,7 @@
     try {
         // Configure metrics in the Phase.
         metrics.forEach { it.configure(captureInfo) }
-        return List<IterationResult>(iterations) { iteration ->
+        return List(iterations) { iteration ->
             // Wake the device to ensure it stays awake with large iteration count
             inMemoryTrace("wake device") { scope.device.wakeUp() }
 
@@ -162,8 +162,6 @@
                 IterationResult(
                     tracePath = tracePath,
                     profilerResultFiles = profilerResultFiles,
-
-                    // Extracts the metrics using the perfetto trace processor
                     measurements =
                         inMemoryTrace("extract metrics") {
                             metrics
@@ -173,24 +171,17 @@
                                 .reduceOrNull() { sum, element -> sum.merge(element) }
                                 ?: emptyList()
                         },
-
-                    // Extracts the insights using the perfetto trace processor
                     insights =
                         if (experimentalConfig?.startupInsightsConfig?.isEnabled == true) {
-                            inMemoryTrace("extract insights") {
-                                TraceMetrics.ADAPTER.decode(
-                                        queryMetricsProtoBinary(listOf("android_startup"))
-                                    )
-                                    .android_startup
-                                    ?.startup
-                                    ?.flatMap { it.slow_start_reason_with_details } ?: emptyList()
-                            }
-                        } else emptyList(),
-
-                    // Extracts a default startup selection param for deep link construction
-                    // Eventually, this should be removed in favor of extracting info from insights
-                    defaultStartupInsightSelectionParams =
-                        extractStartupSliceSelectionParams(packageName = packageName)
+                            queryStartupInsights(
+                                helpUrlBase = Arguments.startupInsightsHelpUrlBase,
+                                traceOutputRelativePath = Outputs.relativePathFor(tracePath),
+                                iteration = iteration,
+                                packageName = packageName
+                            )
+                        } else {
+                            emptyList()
+                        }
                 )
             }
         }
diff --git a/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/Metric.kt b/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/Metric.kt
index 05f8a1c..762383a 100644
--- a/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/Metric.kt
+++ b/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/Metric.kt
@@ -77,13 +77,10 @@
         init {
             val expectedArtMainlineVersion = expectedArtMainlineVersion(apiLevel)
             if (expectedArtMainlineVersion != null) {
+                // require exact match
                 require(artMainlineVersion == expectedArtMainlineVersion) {
                     "For API level $apiLevel, expected artMainlineVersion to be $expectedArtMainlineVersion, observed $artMainlineVersion"
                 }
-            } else if (artMainlineVersion != null) {
-                require(artMainlineVersion > 1L) {
-                    "For API level $apiLevel, expected artMainlineVersion to be > 1, observed $artMainlineVersion"
-                }
             }
         }
 
@@ -92,6 +89,8 @@
                 when {
                     apiLevel == 30 -> 1L
                     apiLevel < 30 -> -1
+                    // can't reason about other levels, since low ram go devices
+                    // may not have mainline updates enabled at all, e.g. wembley
                     else -> null
                 }
 
@@ -638,15 +637,15 @@
 /**
  * Captures metrics about ART method/class compilation and initialization.
  *
- * JIT Compilation, Class Verification, and (on supported devices) Class Initialization.
+ * JIT Compilation, Class Verification, and (on supported devices) Class Loading.
  *
  * For more information on how ART compilation works, see
  * [ART Runtime docs](https://source.android.com/docs/core/runtime/configure).
  *
  * ## JIT Compilation
- * As interpreted (uncompiled) dex code from your APK is run, methods will be Just-In-Time (JIT)
- * compiled, and this compilation is traced by ART. This does not apply to code AOT compiled either
- * from Baseline Profiles, Warmup Profiles, or Full AOT.
+ * As interpreted (uncompiled) dex code from the APK is run, some methods will be Just-In-Time (JIT)
+ * compiled, and this compilation is traced by ART. This does not apply to methods AOT compiled
+ * either from Baseline Profiles, Warmup Profiles, or Full AOT.
  *
  * The number of traces and total duration (reported as `artJitCount` and `artJitSumMs`) indicate
  * how many uncompiled methods were considered hot by the runtime, and were JITted during
@@ -655,38 +654,45 @@
  * Note that framework code on the system image that is not AOT compiled on the system image may
  * also be JITted, and will also show up in this metric. If you see this metric reporting non-zero
  * values when compiled with [CompilationMode.Full] or [CompilationMode.Partial], this may be the
- * reason. See also "Class Verification" below.
+ * reason.
  *
- * ## Class Initialization
- * Class Initialization tracing requires either API 35, or API 31+ with ART mainline version >=
+ * Some methods can't be AOTed or JIT compiled. Generally these are either methods too large for the
+ * Android runtime compiler, or due to a malformed class definition.
+ *
+ * ## Class Loading
+ * Class Loading tracing requires either API 35, or API 31+ with ART mainline version >=
  * `341511000`. If a device doesn't support these tracepoints, the measurements will not be reported
  * in Studio UI or in JSON results. You can check your device's ART mainline version with:
  * ```
  * adb shell cmd package list packages --show-versioncode --apex-only art
  * ```
  *
- * Classes must be initialized by ART in order to be used at runtime. In [CompilationMode.None]
- * (with `warmupRuntimeImageEnabled=false`) and [CompilationMode.Full], this is deferred until
- * runtime, and the cost of this can significantly slow down scenarios where code is run for the
- * first time, such as startup. In [CompilationMode.Partial], this is done at compile time if the
- * class is `trivial` (that is, has no static initializers).
+ * Classes must be loaded by ART in order to be used at runtime. In [CompilationMode.None] and
+ * [CompilationMode.Full], this is deferred until runtime, and the cost of this can significantly
+ * slow down scenarios where code is run for the first time, such as startup.
  *
- * The number of traces and total duration (reported as `artClassInitCount` and `artClassInitSumMs`)
- * indicate how many classes were initialized during measurement, at runtime, without
- * pre-initialization at compile time (or in the case of `CompilationMode.None(true), a previous app
- * launch)`.
+ * In `CompilationMode.Partial(warmupIterations=...)` classes captured in the warmup profile (used
+ * during the warmup iterations) are persisted into the `.art` file at compile time to allow them to
+ * be preloaded during app start, before app code begins to execute. If a class is preloaded by the
+ * runtime, it will not appear in traces.
+ *
+ * Even if a class is captured in the warmup profile, it will not be persisted at compile time if
+ * any of the superclasses are not in the app's profile (extremely unlikely) or the Boot Image
+ * profile (for Boot Image classes).
+ *
+ * The number of traces and total duration (reported as `artClassLoadCount` and `artClassLoadSumMs`)
+ * indicate how many classes were loaded during measurement, at runtime, without preloading at
+ * compile time.
  *
  * These tracepoints are slices of the form `Lcom/example/MyClassName;` for a class named
  * `com.example.MyClassName`.
  *
- * Even using `CompilationMode.Partial(warmupIterations=...)`, this number will often be non-zero,
- * even if every class is captured in the profile. This can be caused by a static initializer in the
- * class, preventing it from being compile-time initialized.
+ * Class loading is not affected by class verification.
  *
  * ## Class Verification
- *
- * Before initialization, classes must be verified by the runtime. Typically all classes in a
- * release APK are verified at install time, regardless of [CompilationMode].
+ * Most usages of a class require classes to be verified by the runtime (some usage only require
+ * loading). Typically all classes in a release APK are verified at install time, regardless of
+ * [CompilationMode].
  *
  * The number of traces and total duration (reported as `artVerifyClass` and `artVerifyClassSumMs`)
  * indicate how many classes were verified during measurement, at runtime.
@@ -695,14 +701,13 @@
  * 1) If install-time verification fails for a class, it will remain unverified, and be verified at
  *    runtime.
  * 2) Debuggable=true apps are not verified at install time, to save on iteration speed at the cost
- *    of runtime performance. This results in runtime verification of each class as its loaded which
- *    is the source of much of the slowdown between a debug app and a release app (assuming you're
- *    not using a compile-time optimizing dexer, like R8). This is only relevant in macrobenchmark
- *    if suppressing warnings about measuring debug app performance.
+ *    of runtime performance. This results in runtime verification of each class as it's loaded
+ *    which is the source of much of the slowdown between a debug app and a release app. As
+ *    Macrobenchmark treats `debuggable=true` as a measurement error, this won't be the case for
+ *    `ArtMetric` measurements unless you suppress that error.
  *
- * Class Verification at runtime prevents both install-time class initialization, and install-time
- * method compilation. If you see JIT from classes in your apk despite using [CompilationMode.Full],
- * install-time verification failures could be the cause, and would show up in this metric.
+ * Some classes will be verified at runtime rather than install time due to limitations in the
+ * compiler and runtime or due to being malformed.
  */
 @RequiresApi(24)
 class ArtMetric : Metric() {
@@ -717,14 +722,14 @@
                 .querySlices("VerifyClass %", packageName = captureInfo.targetPackageName)
                 .asMeasurements("artVerifyClass") +
             if (
-                DeviceInfo.isClassInitTracingAvailable(
+                DeviceInfo.isClassLoadTracingAvailable(
                     targetApiLevel = captureInfo.apiLevel,
                     targetArtMainlineVersion = captureInfo.artMainlineVersion
                 )
             ) {
                 traceSession
-                    .querySlices("L%;", packageName = captureInfo.targetPackageName)
-                    .asMeasurements("artClassInit")
+                    .querySlices("L%/%;", packageName = captureInfo.targetPackageName)
+                    .asMeasurements("artClassLoad")
             } else emptyList()
     }
 
diff --git a/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/perfetto/StartupInsights.kt b/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/perfetto/StartupInsights.kt
new file mode 100644
index 0000000..601b5c3
--- /dev/null
+++ b/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/perfetto/StartupInsights.kt
@@ -0,0 +1,171 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.benchmark.macro.perfetto
+
+import androidx.benchmark.Insight
+import androidx.benchmark.TraceDeepLink
+import androidx.benchmark.inMemoryTrace
+import androidx.benchmark.perfetto.PerfettoTraceProcessor
+import androidx.benchmark.perfetto.Row
+import perfetto.protos.AndroidStartupMetric.SlowStartReason
+import perfetto.protos.AndroidStartupMetric.ThresholdValue.ThresholdUnit
+import perfetto.protos.TraceMetrics
+
+/**
+ * Convert the SlowStartReason concept from Perfetto's android_startup metric to the Macrobenchmark
+ * generic Insight format
+ */
+private fun SlowStartReason.toInsight(
+    helpUrlBase: String?,
+    traceOutputRelativePath: String,
+    iterationIndex: Int,
+    defaultStartupInsightSelectionParams: TraceDeepLink.SelectionParams?
+): Insight {
+    val thresholdUnit = expected_value!!.unit!!
+    val unitSuffix =
+        when (thresholdUnit) {
+            ThresholdUnit.NS -> "ns"
+            ThresholdUnit.PERCENTAGE -> "%"
+            ThresholdUnit.COUNT -> " count"
+            ThresholdUnit.TRUE_OR_FALSE -> ""
+            else -> " ${thresholdUnit.toString().lowercase()}"
+        }
+
+    val thresholdValue = expected_value.value_!!
+
+    val thresholdString =
+        StringBuilder()
+            .apply {
+                append(" (expected: ")
+                if (thresholdUnit == ThresholdUnit.TRUE_OR_FALSE) {
+                    when (thresholdValue) {
+                        0L -> append("false")
+                        1L -> append("true")
+                        else ->
+                            throw IllegalArgumentException(
+                                "Unexpected boolean value $thresholdValue"
+                            )
+                    }
+                } else {
+                    if (expected_value.higher_expected == true) append("> ")
+                    if (expected_value.higher_expected == false) append("< ")
+                    append(thresholdValue)
+                    append(unitSuffix)
+                }
+                append(")")
+            }
+            .toString()
+
+    val category =
+        Insight.Category(
+            titleUrl = helpUrlBase?.plus(reason_id!!.name),
+            title = reason!!,
+            postTitleLabel = thresholdString
+        )
+
+    val observedValue = requireNotNull(actual_value?.value_)
+    return Insight(
+        observedLabel =
+            if (thresholdUnit == ThresholdUnit.TRUE_OR_FALSE) {
+                require(observedValue in 0L..1L)
+                if (observedValue == 0L) "false" else "true"
+            } else {
+                "$observedValue$unitSuffix"
+            },
+        deepLink =
+            TraceDeepLink(
+                outputRelativePath = traceOutputRelativePath,
+                selectionParams = defaultStartupInsightSelectionParams
+            ),
+        iterationIndex = iterationIndex,
+        category = category,
+    )
+}
+
+internal fun PerfettoTraceProcessor.Session.queryStartupInsights(
+    helpUrlBase: String?,
+    traceOutputRelativePath: String,
+    iteration: Int,
+    packageName: String
+): List<Insight> =
+    inMemoryTrace("extract insights") {
+        val defaultStartupInsightSelectionParams =
+            extractStartupSliceSelectionParams(packageName = packageName)
+        TraceMetrics.ADAPTER.decode(queryMetricsProtoBinary(listOf("android_startup")))
+            .android_startup
+            ?.startup
+            ?.filter { it.package_name == packageName } // TODO: fuzzy match?
+            ?.flatMap { it.slow_start_reason_with_details }
+            ?.map {
+                it.toInsight(
+                    helpUrlBase = helpUrlBase,
+                    traceOutputRelativePath = traceOutputRelativePath,
+                    defaultStartupInsightSelectionParams = defaultStartupInsightSelectionParams,
+                    iterationIndex = iteration
+                )
+            } ?: emptyList()
+    }
+
+/**
+ * Construct's [TraceDeepLink.SelectionParams] based on the last `Startup` slice. Temporary hack
+ * until we get this information directly from [SlowStartReason].
+ *
+ * TODO (b/377581661) remove, and instead construct deeplink from [SlowStartReason]
+ */
+internal fun PerfettoTraceProcessor.Session.extractStartupSliceSelectionParams(
+    packageName: String
+): TraceDeepLink.SelectionParams? {
+    inMemoryTrace("extractStartupSliceSelectionParams") {
+        // note: not using utid, upid as not stable between trace processor releases
+        // also note: https://perfetto.dev/docs/analysis/sql-tables#process
+        // also note: https://perfetto.dev/docs/analysis/sql-tables#thread
+        val query =
+            """
+                    select
+                        process.pid as pid,
+                        thread.tid as tid,
+                        slice.ts,
+                        slice.dur,
+                        slice.name, -- left for debugging, can be removed
+                        process.name as process_name -- left for debugging, can be removed
+                    from slice
+                        join thread_track on thread_track.id = slice.track_id
+                        join thread using(utid)
+                        join process using(upid)
+                    where slice.name = 'Startup' and process.name like '${packageName}%'
+                    """
+                .trimIndent()
+
+        val events = query(query).toList()
+        if (events.isEmpty()) {
+            return null
+        } else {
+            val queryResult: Row = events.first()
+            return TraceDeepLink.SelectionParams(
+                pid = queryResult.long("pid"),
+                tid = queryResult.long("tid"),
+                ts = queryResult.long("ts"),
+                dur = queryResult.long("dur"),
+                // query belongs in the [SlowStartReason] object (to be added there)
+                // we can't do anything here now, so setting it to something that will test
+                // that we're handling Unicode correctly
+                // see: http://shortn/_yWn9yR2OHr
+                query = "SELECT 🐲\nFROM 🐉\nWHERE \ud83d\udc09.NAME = 'ハク'"
+            )
+        }
+    }
+}
diff --git a/browser/browser/api/current.txt b/browser/browser/api/current.txt
index 8e2c2e1..96677b3 100644
--- a/browser/browser/api/current.txt
+++ b/browser/browser/api/current.txt
@@ -1,7 +1,22 @@
 // Signature format: 4.0
 package androidx.browser.auth {
 
+  public final class AuthTabColorSchemeParams {
+    method @ColorInt public Integer? getNavigationBarColor();
+    method @ColorInt public Integer? getNavigationBarDividerColor();
+    method @ColorInt public Integer? getToolbarColor();
+  }
+
+  public static final class AuthTabColorSchemeParams.Builder {
+    ctor public AuthTabColorSchemeParams.Builder();
+    method public androidx.browser.auth.AuthTabColorSchemeParams build();
+    method public androidx.browser.auth.AuthTabColorSchemeParams.Builder setNavigationBarColor(@ColorInt int);
+    method public androidx.browser.auth.AuthTabColorSchemeParams.Builder setNavigationBarDividerColor(@ColorInt int);
+    method public androidx.browser.auth.AuthTabColorSchemeParams.Builder setToolbarColor(@ColorInt int);
+  }
+
   @SuppressCompatibility @androidx.browser.auth.ExperimentalAuthTab public class AuthTabIntent {
+    method public static androidx.browser.auth.AuthTabColorSchemeParams getColorSchemeParams(android.content.Intent, @IntRange(from=androidx.browser.customtabs.CustomTabsIntent.COLOR_SCHEME_LIGHT, to=androidx.browser.customtabs.CustomTabsIntent.COLOR_SCHEME_DARK) int);
     method @SuppressCompatibility @androidx.browser.customtabs.ExperimentalEphemeralBrowsing public boolean isEphemeralBrowsingEnabled();
     method public void launch(androidx.activity.result.ActivityResultLauncher<android.content.Intent!>, android.net.Uri, String);
     method public void launch(androidx.activity.result.ActivityResultLauncher<android.content.Intent!>, android.net.Uri, String, String);
@@ -26,6 +41,9 @@
   public static final class AuthTabIntent.Builder {
     ctor public AuthTabIntent.Builder();
     method public androidx.browser.auth.AuthTabIntent build();
+    method public androidx.browser.auth.AuthTabIntent.Builder setColorScheme(@IntRange(from=androidx.browser.customtabs.CustomTabsIntent.COLOR_SCHEME_SYSTEM, to=androidx.browser.customtabs.CustomTabsIntent.COLOR_SCHEME_DARK) int);
+    method public androidx.browser.auth.AuthTabIntent.Builder setColorSchemeParams(@IntRange(from=androidx.browser.customtabs.CustomTabsIntent.COLOR_SCHEME_LIGHT, to=androidx.browser.customtabs.CustomTabsIntent.COLOR_SCHEME_DARK) int, androidx.browser.auth.AuthTabColorSchemeParams);
+    method public androidx.browser.auth.AuthTabIntent.Builder setDefaultColorSchemeParams(androidx.browser.auth.AuthTabColorSchemeParams);
     method @SuppressCompatibility @androidx.browser.customtabs.ExperimentalEphemeralBrowsing public androidx.browser.auth.AuthTabIntent.Builder setEphemeralBrowsingEnabled(boolean);
   }
 
diff --git a/browser/browser/api/restricted_current.txt b/browser/browser/api/restricted_current.txt
index 90363d2..0ead376 100644
--- a/browser/browser/api/restricted_current.txt
+++ b/browser/browser/api/restricted_current.txt
@@ -1,7 +1,22 @@
 // Signature format: 4.0
 package androidx.browser.auth {
 
+  public final class AuthTabColorSchemeParams {
+    method @ColorInt public Integer? getNavigationBarColor();
+    method @ColorInt public Integer? getNavigationBarDividerColor();
+    method @ColorInt public Integer? getToolbarColor();
+  }
+
+  public static final class AuthTabColorSchemeParams.Builder {
+    ctor public AuthTabColorSchemeParams.Builder();
+    method public androidx.browser.auth.AuthTabColorSchemeParams build();
+    method public androidx.browser.auth.AuthTabColorSchemeParams.Builder setNavigationBarColor(@ColorInt int);
+    method public androidx.browser.auth.AuthTabColorSchemeParams.Builder setNavigationBarDividerColor(@ColorInt int);
+    method public androidx.browser.auth.AuthTabColorSchemeParams.Builder setToolbarColor(@ColorInt int);
+  }
+
   @SuppressCompatibility @androidx.browser.auth.ExperimentalAuthTab public class AuthTabIntent {
+    method public static androidx.browser.auth.AuthTabColorSchemeParams getColorSchemeParams(android.content.Intent, @IntRange(from=androidx.browser.customtabs.CustomTabsIntent.COLOR_SCHEME_LIGHT, to=androidx.browser.customtabs.CustomTabsIntent.COLOR_SCHEME_DARK) int);
     method @SuppressCompatibility @androidx.browser.customtabs.ExperimentalEphemeralBrowsing public boolean isEphemeralBrowsingEnabled();
     method public void launch(androidx.activity.result.ActivityResultLauncher<android.content.Intent!>, android.net.Uri, String);
     method public void launch(androidx.activity.result.ActivityResultLauncher<android.content.Intent!>, android.net.Uri, String, String);
@@ -26,6 +41,9 @@
   public static final class AuthTabIntent.Builder {
     ctor public AuthTabIntent.Builder();
     method public androidx.browser.auth.AuthTabIntent build();
+    method public androidx.browser.auth.AuthTabIntent.Builder setColorScheme(@IntRange(from=androidx.browser.customtabs.CustomTabsIntent.COLOR_SCHEME_SYSTEM, to=androidx.browser.customtabs.CustomTabsIntent.COLOR_SCHEME_DARK) int);
+    method public androidx.browser.auth.AuthTabIntent.Builder setColorSchemeParams(@IntRange(from=androidx.browser.customtabs.CustomTabsIntent.COLOR_SCHEME_LIGHT, to=androidx.browser.customtabs.CustomTabsIntent.COLOR_SCHEME_DARK) int, androidx.browser.auth.AuthTabColorSchemeParams);
+    method public androidx.browser.auth.AuthTabIntent.Builder setDefaultColorSchemeParams(androidx.browser.auth.AuthTabColorSchemeParams);
     method @SuppressCompatibility @androidx.browser.customtabs.ExperimentalEphemeralBrowsing public androidx.browser.auth.AuthTabIntent.Builder setEphemeralBrowsingEnabled(boolean);
   }
 
diff --git a/browser/browser/src/main/java/androidx/browser/auth/AuthTabColorSchemeParams.java b/browser/browser/src/main/java/androidx/browser/auth/AuthTabColorSchemeParams.java
new file mode 100644
index 0000000..14df0bb
--- /dev/null
+++ b/browser/browser/src/main/java/androidx/browser/auth/AuthTabColorSchemeParams.java
@@ -0,0 +1,194 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.browser.auth;
+
+import static androidx.browser.customtabs.CustomTabsIntent.EXTRA_NAVIGATION_BAR_COLOR;
+import static androidx.browser.customtabs.CustomTabsIntent.EXTRA_NAVIGATION_BAR_DIVIDER_COLOR;
+import static androidx.browser.customtabs.CustomTabsIntent.EXTRA_TOOLBAR_COLOR;
+
+import android.os.Bundle;
+
+import androidx.annotation.ColorInt;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+/**
+ * Contains visual parameters of an Auth Tab that may depend on the color scheme.
+ *
+ * @see AuthTabIntent.Builder#setColorSchemeParams(int, AuthTabColorSchemeParams)
+ */
+public final class AuthTabColorSchemeParams {
+    /** Toolbar color. */
+    @Nullable
+    @ColorInt
+    private final Integer mToolbarColor;
+
+    /** Navigation bar color. */
+    @Nullable
+    @ColorInt
+    private final Integer mNavigationBarColor;
+
+    /** Navigation bar divider color. */
+    @Nullable
+    @ColorInt
+    private final Integer mNavigationBarDividerColor;
+
+    private AuthTabColorSchemeParams(@Nullable @ColorInt Integer toolbarColor,
+            @Nullable @ColorInt Integer navigationBarColor,
+            @Nullable @ColorInt Integer navigationBarDividerColor) {
+        mToolbarColor = toolbarColor;
+        mNavigationBarColor = navigationBarColor;
+        mNavigationBarDividerColor = navigationBarDividerColor;
+    }
+
+    @SuppressWarnings("AutoBoxing")
+    @Nullable
+    @ColorInt
+    public Integer getToolbarColor() {
+        return mToolbarColor;
+    }
+
+    @SuppressWarnings("AutoBoxing")
+    @Nullable
+    @ColorInt
+    public Integer getNavigationBarColor() {
+        return mNavigationBarColor;
+    }
+
+    @SuppressWarnings("AutoBoxing")
+    @Nullable
+    @ColorInt
+    public Integer getNavigationBarDividerColor() {
+        return mNavigationBarDividerColor;
+    }
+
+    /**
+     * Packs the parameters into a {@link Bundle}.
+     * For backward compatibility and ease of use, the names of keys and the structure of the Bundle
+     * are the same as that of Intent extras in {@link CustomTabsIntent}.
+     */
+    @NonNull
+    Bundle toBundle() {
+        Bundle bundle = new Bundle();
+        if (mToolbarColor != null) {
+            bundle.putInt(EXTRA_TOOLBAR_COLOR, mToolbarColor);
+        }
+        if (mNavigationBarColor != null) {
+            bundle.putInt(EXTRA_NAVIGATION_BAR_COLOR, mNavigationBarColor);
+        }
+        if (mNavigationBarDividerColor != null) {
+            bundle.putInt(EXTRA_NAVIGATION_BAR_DIVIDER_COLOR, mNavigationBarDividerColor);
+        }
+        return bundle;
+    }
+
+    /**
+     * Unpacks parameters from a {@link Bundle}. Sets all parameters to null if provided bundle is
+     * null.
+     */
+    @NonNull
+    @SuppressWarnings("deprecation")
+    static AuthTabColorSchemeParams fromBundle(@Nullable Bundle bundle) {
+        if (bundle == null) {
+            bundle = new Bundle(0);
+        }
+        // Using bundle.get() instead of bundle.getInt() to default to null without calling
+        // bundle.containsKey().
+        return new AuthTabColorSchemeParams((Integer) bundle.get(EXTRA_TOOLBAR_COLOR),
+                (Integer) bundle.get(EXTRA_NAVIGATION_BAR_COLOR),
+                (Integer) bundle.get(EXTRA_NAVIGATION_BAR_DIVIDER_COLOR));
+    }
+
+    /**
+     * Returns a new {@link AuthTabColorSchemeParams} with the null fields replaced with the
+     * provided defaults.
+     */
+    @NonNull
+    AuthTabColorSchemeParams withDefaults(@NonNull AuthTabColorSchemeParams defaults) {
+        return new AuthTabColorSchemeParams(
+                mToolbarColor == null ? defaults.mToolbarColor : mToolbarColor,
+                mNavigationBarColor == null ? defaults.mNavigationBarColor : mNavigationBarColor,
+                mNavigationBarDividerColor == null ? defaults.mNavigationBarDividerColor
+                        : mNavigationBarDividerColor);
+    }
+
+    /**
+     * Builder class for {@link AuthTabColorSchemeParams} objects.
+     * The browser's default colors will be used for any unset value.
+     */
+    public static final class Builder {
+        @Nullable
+        @ColorInt
+        private Integer mToolbarColor;
+        @Nullable
+        @ColorInt
+        private Integer mNavigationBarColor;
+        @Nullable
+        @ColorInt
+        private Integer mNavigationBarDividerColor;
+
+        /**
+         * Sets the toolbar color.
+         *
+         * This color is also applied to the status bar. To ensure good contrast between status bar
+         * icons and the background, Auth Tab implementations may use
+         * {@link WindowInsetsController#APPEARANCE_LIGHT_STATUS_BARS}.
+         *
+         * @param color The color integer. The alpha value will be ignored.
+         */
+        @NonNull
+        public Builder setToolbarColor(@ColorInt int color) {
+            mToolbarColor = color | 0xff000000;
+            return this;
+        }
+
+        /**
+         * Sets the navigation bar color.
+         *
+         * To ensure good contrast between navigation bar icons and the background, Auth Tab
+         * implementations may use {@link WindowInsetsController#APPEARANCE_LIGHT_NAVIGATION_BARS}.
+         *
+         * @param color The color integer. The alpha value will be ignored.
+         */
+        @NonNull
+        public Builder setNavigationBarColor(@ColorInt int color) {
+            mNavigationBarColor = color | 0xff000000;
+            return this;
+        }
+
+        /**
+         * Sets the navigation bar divider color.
+         *
+         * @param color The color integer.
+         */
+        @NonNull
+        public Builder setNavigationBarDividerColor(@ColorInt int color) {
+            mNavigationBarDividerColor = color;
+            return this;
+        }
+
+        /**
+         * Combines all the options that have been set and returns a new
+         * {@link AuthTabColorSchemeParams} object.
+         */
+        @NonNull
+        public AuthTabColorSchemeParams build() {
+            return new AuthTabColorSchemeParams(mToolbarColor, mNavigationBarColor,
+                    mNavigationBarDividerColor);
+        }
+    }
+}
diff --git a/browser/browser/src/main/java/androidx/browser/auth/AuthTabIntent.java b/browser/browser/src/main/java/androidx/browser/auth/AuthTabIntent.java
index d1c4338..cd20b6e 100644
--- a/browser/browser/src/main/java/androidx/browser/auth/AuthTabIntent.java
+++ b/browser/browser/src/main/java/androidx/browser/auth/AuthTabIntent.java
@@ -16,6 +16,11 @@
 
 package androidx.browser.auth;
 
+import static androidx.browser.customtabs.CustomTabsIntent.COLOR_SCHEME_DARK;
+import static androidx.browser.customtabs.CustomTabsIntent.COLOR_SCHEME_LIGHT;
+import static androidx.browser.customtabs.CustomTabsIntent.COLOR_SCHEME_SYSTEM;
+import static androidx.browser.customtabs.CustomTabsIntent.EXTRA_COLOR_SCHEME;
+import static androidx.browser.customtabs.CustomTabsIntent.EXTRA_COLOR_SCHEME_PARAMS;
 import static androidx.browser.customtabs.CustomTabsIntent.EXTRA_ENABLE_EPHEMERAL_BROWSING;
 import static androidx.browser.customtabs.CustomTabsIntent.EXTRA_SESSION;
 
@@ -24,17 +29,20 @@
 import android.content.Intent;
 import android.net.Uri;
 import android.os.Bundle;
+import android.util.SparseArray;
 
 import androidx.activity.result.ActivityResultCallback;
 import androidx.activity.result.ActivityResultCaller;
 import androidx.activity.result.ActivityResultLauncher;
 import androidx.activity.result.contract.ActivityResultContract;
 import androidx.annotation.IntDef;
+import androidx.annotation.IntRange;
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import androidx.annotation.RestrictTo;
 import androidx.browser.customtabs.CustomTabsIntent;
 import androidx.browser.customtabs.ExperimentalEphemeralBrowsing;
+import androidx.core.os.BundleCompat;
 
 import java.lang.annotation.Retention;
 import java.lang.annotation.RetentionPolicy;
@@ -136,7 +144,8 @@
     public static final int RESULT_UNKNOWN_CODE = -2;
 
     /** An {@link Intent} used to start the Auth Tab Activity. */
-    @NonNull public final Intent intent;
+    @NonNull
+    public final Intent intent;
 
     /**
      * Launches an Auth Tab Activity. Must be used for flows that result in a redirect with a custom
@@ -182,6 +191,36 @@
         return intent.getBooleanExtra(EXTRA_ENABLE_EPHEMERAL_BROWSING, false);
     }
 
+    /**
+     * Retrieves the instance of {@link AuthTabColorSchemeParams} from an {@link Intent} for a given
+     * color scheme.
+     *
+     * @param intent      {@link Intent} to retrieve the color scheme params from.
+     * @param colorScheme A constant representing a color scheme. Must not be
+     *                    {@link #COLOR_SCHEME_SYSTEM}.
+     * @return An instance of {@link AuthTabColorSchemeParams} with retrieved params.
+     */
+    @NonNull
+    public static AuthTabColorSchemeParams getColorSchemeParams(@NonNull Intent intent,
+            @CustomTabsIntent.ColorScheme @IntRange(from = COLOR_SCHEME_LIGHT, to =
+                    COLOR_SCHEME_DARK) int colorScheme) {
+        Bundle extras = intent.getExtras();
+        if (extras == null) {
+            return AuthTabColorSchemeParams.fromBundle(null);
+        }
+
+        AuthTabColorSchemeParams defaults = AuthTabColorSchemeParams.fromBundle(extras);
+        SparseArray<Bundle> paramBundles = BundleCompat.getSparseParcelableArray(extras,
+                EXTRA_COLOR_SCHEME_PARAMS, Bundle.class);
+        if (paramBundles != null) {
+            Bundle bundleForScheme = paramBundles.get(colorScheme);
+            if (bundleForScheme != null) {
+                return AuthTabColorSchemeParams.fromBundle(bundleForScheme).withDefaults(defaults);
+            }
+        }
+        return defaults;
+    }
+
     private AuthTabIntent(@NonNull Intent intent) {
         this.intent = intent;
     }
@@ -191,6 +230,12 @@
      */
     public static final class Builder {
         private final Intent mIntent = new Intent(Intent.ACTION_VIEW);
+        private final AuthTabColorSchemeParams.Builder mDefaultColorSchemeBuilder =
+                new AuthTabColorSchemeParams.Builder();
+        @Nullable
+        private SparseArray<Bundle> mColorSchemeParamBundles;
+        @Nullable
+        private Bundle mDefaultColorSchemeBundle;
 
         public Builder() {
         }
@@ -211,6 +256,94 @@
         }
 
         /**
+         * Sets the color scheme that should be applied to the user interface in the Auth Tab.
+         *
+         * @param colorScheme Desired color scheme.
+         * @see CustomTabsIntent#COLOR_SCHEME_SYSTEM
+         * @see CustomTabsIntent#COLOR_SCHEME_LIGHT
+         * @see CustomTabsIntent#COLOR_SCHEME_DARK
+         */
+        @SuppressWarnings("MissingGetterMatchingBuilder")
+        @NonNull
+        public Builder setColorScheme(
+                @CustomTabsIntent.ColorScheme @IntRange(from = COLOR_SCHEME_SYSTEM, to =
+                        COLOR_SCHEME_DARK) int colorScheme) {
+            mIntent.putExtra(EXTRA_COLOR_SCHEME, colorScheme);
+            return this;
+        }
+
+        /**
+         * Sets {@link AuthTabColorSchemeParams} for the given color scheme.
+         *
+         * This allows specifying two different toolbar colors for light and dark schemes.
+         * It can be useful if {@link CustomTabsIntent#COLOR_SCHEME_SYSTEM} is set: the Auth Tab
+         * will follow the system settings and apply the corresponding
+         * {@link AuthTabColorSchemeParams} "on the fly" when the settings change.
+         *
+         * If there is no {@link AuthTabColorSchemeParams} for the current scheme, or a particular
+         * field of it is null, the Auth Tab will fall back to the defaults provided via
+         * {@link #setDefaultColorSchemeParams}.
+         *
+         * Example:
+         * <pre><code>
+         *     AuthTabColorSchemeParams darkParams = new AuthTabColorSchemeParams.Builder()
+         *             .setToolbarColor(darkColor)
+         *             .build();
+         *     AuthTabColorSchemeParams otherParams = new AuthTabColorSchemeParams.Builder()
+         *             .setNavigationBarColor(otherColor)
+         *             .build();
+         *     AuthTabIntent intent = new AuthTabIntent.Builder()
+         *             .setColorScheme(COLOR_SCHEME_SYSTEM)
+         *             .setColorSchemeParams(COLOR_SCHEME_DARK, darkParams)
+         *             .setDefaultColorSchemeParams(otherParams)
+         *             .build();
+         *
+         *     // Setting colors independently of color scheme
+         *     AuthTabColorSchemeParams params = new AuthTabColorSchemeParams.Builder()
+         *             .setToolbarColor(color)
+         *             .setNavigationBarColor(color)
+         *             .build();
+         *     AuthTabIntent intent = new AuthTabIntent.Builder()
+         *             .setDefaultColorSchemeParams(params)
+         *             .build();
+         * </code></pre>
+         *
+         * @param colorScheme A constant representing a color scheme (see {@link #setColorScheme}).
+         *                    It should not be {@link #COLOR_SCHEME_SYSTEM}, because that represents
+         *                    a behavior rather than a particular color scheme.
+         * @param params      An instance of {@link AuthTabColorSchemeParams}.
+         */
+        @SuppressWarnings("MissingGetterMatchingBuilder")
+        @NonNull
+        public AuthTabIntent.Builder setColorSchemeParams(
+                @CustomTabsIntent.ColorScheme @IntRange(from = COLOR_SCHEME_LIGHT, to =
+                        COLOR_SCHEME_DARK) int colorScheme,
+                @NonNull AuthTabColorSchemeParams params) {
+            if (mColorSchemeParamBundles == null) {
+                mColorSchemeParamBundles = new SparseArray<>();
+            }
+            mColorSchemeParamBundles.put(colorScheme, params.toBundle());
+            return this;
+        }
+
+        /**
+         * Sets the default {@link AuthTabColorSchemeParams}.
+         *
+         * This will set a default color scheme that applies when no
+         * {@link AuthTabColorSchemeParams} specified for current color scheme via
+         * {@link #setColorSchemeParams}.
+         *
+         * @param params An instance of {@link AuthTabColorSchemeParams}.
+         */
+        @SuppressWarnings("MissingGetterMatchingBuilder")
+        @NonNull
+        public AuthTabIntent.Builder setDefaultColorSchemeParams(
+                @NonNull AuthTabColorSchemeParams params) {
+            mDefaultColorSchemeBundle = params.toBundle();
+            return this;
+        }
+
+        /**
          * Combines all the options that have been set and returns a new {@link AuthTabIntent}
          * object.
          */
@@ -220,9 +353,23 @@
 
             // Put a null EXTRA_SESSION as a fallback so that this is interpreted as a Custom Tab
             // intent by browser implementations that don't support Auth Tab.
-            Bundle bundle = new Bundle();
-            bundle.putBinder(EXTRA_SESSION, null);
-            mIntent.putExtras(bundle);
+            {
+                Bundle bundle = new Bundle();
+                bundle.putBinder(EXTRA_SESSION, null);
+                mIntent.putExtras(bundle);
+            }
+
+            mIntent.putExtras(mDefaultColorSchemeBuilder.build().toBundle());
+            if (mDefaultColorSchemeBundle != null) {
+                mIntent.putExtras(mDefaultColorSchemeBundle);
+            }
+
+            if (mColorSchemeParamBundles != null) {
+                Bundle bundle = new Bundle();
+                bundle.putSparseParcelableArray(EXTRA_COLOR_SCHEME_PARAMS,
+                        mColorSchemeParamBundles);
+                mIntent.putExtras(bundle);
+            }
 
             return new AuthTabIntent(mIntent);
         }
diff --git a/browser/browser/src/test/java/androidx/browser/auth/AuthTabColorSchemeParamsTest.java b/browser/browser/src/test/java/androidx/browser/auth/AuthTabColorSchemeParamsTest.java
new file mode 100644
index 0000000..90e084a
--- /dev/null
+++ b/browser/browser/src/test/java/androidx/browser/auth/AuthTabColorSchemeParamsTest.java
@@ -0,0 +1,145 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.browser.auth;
+
+import static androidx.browser.customtabs.CustomTabsIntent.COLOR_SCHEME_DARK;
+import static androidx.browser.customtabs.CustomTabsIntent.COLOR_SCHEME_LIGHT;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+
+import android.content.Intent;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.internal.DoNotInstrument;
+
+/** Tests for {@link AuthTabColorSchemeParams}. */
+@RunWith(RobolectricTestRunner.class)
+@DoNotInstrument
+public class AuthTabColorSchemeParamsTest {
+    @Test
+    public void testParamsForBothSchemes() {
+        AuthTabColorSchemeParams lightParams = new AuthTabColorSchemeParams.Builder()
+                .setToolbarColor(0x0000ff)
+                .setNavigationBarColor(0xff0000)
+                .setNavigationBarDividerColor(0x00ff00)
+                .build();
+
+        AuthTabColorSchemeParams darkParams = new AuthTabColorSchemeParams.Builder()
+                .setToolbarColor(0xff0000)
+                .setNavigationBarColor(0xffaa00)
+                .setNavigationBarDividerColor(0xffffff)
+                .build();
+
+        Intent intent = new AuthTabIntent.Builder()
+                .setColorSchemeParams(COLOR_SCHEME_LIGHT, lightParams)
+                .setColorSchemeParams(COLOR_SCHEME_DARK, darkParams)
+                .build().intent;
+
+        AuthTabColorSchemeParams lightParamsFromIntent = AuthTabIntent.getColorSchemeParams(intent,
+                COLOR_SCHEME_LIGHT);
+
+        AuthTabColorSchemeParams darkParamsFromIntent = AuthTabIntent.getColorSchemeParams(intent,
+                COLOR_SCHEME_DARK);
+
+        assertSchemeParamsEqual(lightParams, lightParamsFromIntent);
+        assertSchemeParamsEqual(darkParams, darkParamsFromIntent);
+    }
+
+    @Test
+    public void testWithDefaultsForOneScheme() {
+        int defaultToolbarColor = 0x0000ff;
+        int defaultNavigationBarColor = 0xaabbcc;
+        int defaultNavigationBarDividerColor = 0xdddddd;
+
+        AuthTabColorSchemeParams darkParams = new AuthTabColorSchemeParams.Builder()
+                .setToolbarColor(0xff0000)
+                .setNavigationBarColor(0xccbbaa)
+                .setNavigationBarDividerColor(0xffffff)
+                .build();
+
+        AuthTabColorSchemeParams defaultParams = new AuthTabColorSchemeParams.Builder()
+                .setToolbarColor(defaultToolbarColor)
+                .setNavigationBarColor(defaultNavigationBarColor)
+                .setNavigationBarDividerColor(defaultNavigationBarDividerColor)
+                .build();
+
+        Intent intent = new AuthTabIntent.Builder()
+                .setDefaultColorSchemeParams(defaultParams)
+                .setColorSchemeParams(COLOR_SCHEME_DARK, darkParams)
+                .build()
+                .intent;
+
+        AuthTabColorSchemeParams lightParamsFromIntent = AuthTabIntent.getColorSchemeParams(intent,
+                COLOR_SCHEME_LIGHT);
+
+        AuthTabColorSchemeParams darkParamsFromIntent = AuthTabIntent.getColorSchemeParams(intent,
+                COLOR_SCHEME_DARK);
+
+        assertSchemeParamsEqual(defaultParams, lightParamsFromIntent);
+        assertSchemeParamsEqual(darkParams, darkParamsFromIntent);
+    }
+
+    @Test
+    public void testParamsNotProvided() {
+        Intent intent = new AuthTabIntent.Builder().build().intent;
+
+        AuthTabColorSchemeParams lightParamsFromIntent = AuthTabIntent.getColorSchemeParams(intent,
+                COLOR_SCHEME_LIGHT);
+
+        AuthTabColorSchemeParams darkParamsFromIntent = AuthTabIntent.getColorSchemeParams(intent,
+                COLOR_SCHEME_DARK);
+
+        assertNull(lightParamsFromIntent.getToolbarColor());
+        assertNull(lightParamsFromIntent.getNavigationBarColor());
+        assertNull(lightParamsFromIntent.getNavigationBarDividerColor());
+
+        assertNull(darkParamsFromIntent.getToolbarColor());
+        assertNull(darkParamsFromIntent.getNavigationBarColor());
+        assertNull(darkParamsFromIntent.getNavigationBarDividerColor());
+    }
+
+    @Test
+    public void testColorsAreSolid() {
+        AuthTabColorSchemeParams params = new AuthTabColorSchemeParams.Builder()
+                .setToolbarColor(0x610000ff)
+                .setNavigationBarColor(0x88ff0000)
+                .setNavigationBarDividerColor(0x00ff00)
+                .build();
+
+        Intent intent = new AuthTabIntent.Builder()
+                .setDefaultColorSchemeParams(params)
+                .build()
+                .intent;
+
+        AuthTabColorSchemeParams paramsFromIntent = AuthTabIntent.getColorSchemeParams(intent,
+                COLOR_SCHEME_LIGHT);
+
+        assertEquals(0xff0000ff, paramsFromIntent.getToolbarColor().intValue());
+        assertEquals(0xffff0000, paramsFromIntent.getNavigationBarColor().intValue());
+    }
+
+    private void assertSchemeParamsEqual(AuthTabColorSchemeParams params1,
+            AuthTabColorSchemeParams params2) {
+        assertEquals(params1.getToolbarColor(), params2.getToolbarColor());
+        assertEquals(params1.getNavigationBarColor(), params2.getNavigationBarColor());
+        assertEquals(params1.getNavigationBarDividerColor(),
+                params2.getNavigationBarDividerColor());
+    }
+}
diff --git a/buildSrc/private/src/main/kotlin/androidx/build/AndroidXComposeImplPlugin.kt b/buildSrc/private/src/main/kotlin/androidx/build/AndroidXComposeImplPlugin.kt
index e8ecf60..2d2a654 100644
--- a/buildSrc/private/src/main/kotlin/androidx/build/AndroidXComposeImplPlugin.kt
+++ b/buildSrc/private/src/main/kotlin/androidx/build/AndroidXComposeImplPlugin.kt
@@ -113,11 +113,6 @@
         // If a project has opted-out of Compose compiler plugin, don't add it
         if (!extension.composeCompilerPluginEnabled) return@afterEvaluate
 
-        val androidXExtension =
-            project.extensions.findByType(AndroidXExtension::class.java)
-                ?: throw Exception("You have applied AndroidXComposePlugin without AndroidXPlugin")
-        val shouldPublish = androidXExtension.shouldPublish()
-
         // Create configuration that we'll use to load Compose compiler plugin
         val configuration =
             project.configurations.create(COMPILER_PLUGIN_CONFIGURATION) {
@@ -200,9 +195,8 @@
                 compile.enableFeatureFlag(ComposeFeatureFlag.OptimizeNonSkippingGroups)
                 compile.enableFeatureFlag(ComposeFeatureFlag.PausableComposition)
             }
-            if (shouldPublish) {
-                compile.addPluginOption(ComposeCompileOptions.SourceOption, "true")
-            }
+
+            compile.addPluginOption(ComposeCompileOptions.SourceOption, "true")
         }
 
         if (enableMetrics) {
diff --git a/buildSrc/private/src/main/kotlin/androidx/build/AndroidXImplPlugin.kt b/buildSrc/private/src/main/kotlin/androidx/build/AndroidXImplPlugin.kt
index caddc82..fe6521f 100644
--- a/buildSrc/private/src/main/kotlin/androidx/build/AndroidXImplPlugin.kt
+++ b/buildSrc/private/src/main/kotlin/androidx/build/AndroidXImplPlugin.kt
@@ -28,6 +28,7 @@
 import androidx.build.docs.CheckTipOfTreeDocsTask.Companion.setUpCheckDocsTask
 import androidx.build.gitclient.getHeadShaProvider
 import androidx.build.gradle.isRoot
+import androidx.build.kythe.configureProjectForKzipTasks
 import androidx.build.license.addLicensesToPublishedArtifacts
 import androidx.build.resources.CopyPublicResourcesDirTask
 import androidx.build.resources.configurePublicResourcesStub
@@ -694,6 +695,7 @@
         project.disableStrictVersionConstraints()
 
         project.configureProjectForApiTasks(AndroidMultiplatformApiTaskConfig, androidXExtension)
+        project.configureProjectForKzipTasks(AndroidMultiplatformApiTaskConfig, androidXExtension)
 
         kotlinMultiplatformAndroidComponentsExtension.onVariant { it.configureTests() }
 
@@ -933,6 +935,10 @@
                     LibraryApiTaskConfig(variant),
                     androidXExtension
                 )
+                project.configureProjectForKzipTasks(
+                    LibraryApiTaskConfig(variant),
+                    androidXExtension
+                )
             }
             if (variant.name == DEFAULT_PUBLISH_CONFIG) {
                 project.configureSourceJarForAndroid(variant, androidXExtension.samplesProjects)
@@ -1019,6 +1025,7 @@
             }
 
         project.configureProjectForApiTasks(apiTaskConfig, androidXExtension)
+        project.configureProjectForKzipTasks(apiTaskConfig, androidXExtension)
         project.setUpCheckDocsTask(androidXExtension)
 
         if (project.multiplatformExtension == null) {
diff --git a/buildSrc/private/src/main/kotlin/androidx/build/checkapi/ApiTasks.kt b/buildSrc/private/src/main/kotlin/androidx/build/checkapi/ApiTasks.kt
index 7d70637..17d960d 100644
--- a/buildSrc/private/src/main/kotlin/androidx/build/checkapi/ApiTasks.kt
+++ b/buildSrc/private/src/main/kotlin/androidx/build/checkapi/ApiTasks.kt
@@ -21,11 +21,9 @@
 import androidx.build.RunApiTasks
 import androidx.build.Version
 import androidx.build.binarycompatibilityvalidator.BinaryCompatibilityValidation
-import androidx.build.getDefaultTargetJavaVersion
 import androidx.build.getSupportRootFolder
 import androidx.build.isWriteVersionedApiFilesEnabled
 import androidx.build.java.JavaCompileInputs
-import androidx.build.kythe.GenerateKotlinKzipTask
 import androidx.build.metalava.MetalavaTasks
 import androidx.build.multiplatformExtension
 import androidx.build.resources.ResourceTasks
@@ -37,6 +35,7 @@
 import java.io.File
 import org.gradle.api.GradleException
 import org.gradle.api.Project
+import org.gradle.api.artifacts.Configuration
 import org.gradle.api.artifacts.type.ArtifactTypeDefinition
 import org.gradle.api.attributes.Usage
 import org.gradle.api.file.RegularFile
@@ -163,61 +162,10 @@
                 listOf(currentApiLocation)
             }
 
-        val javaInputs: JavaCompileInputs
-        val androidManifest: Provider<RegularFile>?
-        when (config) {
-            is LibraryApiTaskConfig -> {
-                if (config.variant.name != Release.DEFAULT_PUBLISH_CONFIG) {
-                    return@afterEvaluate
-                }
-                javaInputs = JavaCompileInputs.fromLibraryVariant(config.variant, project)
-                androidManifest = config.variant.artifacts.get(SingleArtifact.MERGED_MANIFEST)
-            }
-            is AndroidMultiplatformApiTaskConfig -> {
-                javaInputs = JavaCompileInputs.fromKmpAndroidTarget(project)
-                androidManifest = null
-            }
-            is KmpApiTaskConfig -> {
-                javaInputs = JavaCompileInputs.fromKmpJvmTarget(project)
-                androidManifest = null
-            }
-            is JavaApiTaskConfig -> {
-                val javaExtension = extensions.getByType<JavaPluginExtension>()
-                val mainSourceSet = javaExtension.sourceSets.getByName("main")
-                javaInputs = JavaCompileInputs.fromSourceSet(mainSourceSet, this)
-                androidManifest = null
-            }
-        }
-
+        val (javaInputs, androidManifest) =
+            configureJavaInputsAndManifest(config) ?: return@afterEvaluate
         val baselinesApiLocation = ApiBaselinesLocation.fromApiLocation(currentApiLocation)
-
-        val generateApiDependencies =
-            configurations.create("GenerateApiDependencies") {
-                it.isCanBeConsumed = false
-                it.isTransitive = false
-                it.attributes.attribute(
-                    BuildTypeAttr.ATTRIBUTE,
-                    project.objects.named(BuildTypeAttr::class.java, "release")
-                )
-                it.attributes.attribute(
-                    Usage.USAGE_ATTRIBUTE,
-                    objects.named(Usage::class.java, Usage.JAVA_API)
-                )
-                it.attributes.attribute(
-                    ArtifactTypeDefinition.ARTIFACT_TYPE_ATTRIBUTE,
-                    ArtifactTypeDefinition.JAR_TYPE
-                )
-            }
-
-        dependencies.add(generateApiDependencies.name, project.project(project.path))
-
-        GenerateKotlinKzipTask.setupProject(
-            project,
-            javaInputs,
-            generateApiDependencies,
-            extension.kotlinTarget,
-            getDefaultTargetJavaVersion(extension.type, project.name)
-        )
+        val generateApiDependencies = createReleaseApiConfiguration()
 
         MetalavaTasks.setupProject(
             project,
@@ -256,6 +204,53 @@
     }
 }
 
+internal fun Project.configureJavaInputsAndManifest(
+    config: ApiTaskConfig
+): Pair<JavaCompileInputs, Provider<RegularFile>?>? {
+    return when (config) {
+        is LibraryApiTaskConfig -> {
+            if (config.variant.name != Release.DEFAULT_PUBLISH_CONFIG) {
+                return null
+            }
+            JavaCompileInputs.fromLibraryVariant(config.variant, project) to
+                config.variant.artifacts.get(SingleArtifact.MERGED_MANIFEST)
+        }
+        is AndroidMultiplatformApiTaskConfig -> {
+            JavaCompileInputs.fromKmpAndroidTarget(project) to null
+        }
+        is KmpApiTaskConfig -> {
+            JavaCompileInputs.fromKmpJvmTarget(project) to null
+        }
+        is JavaApiTaskConfig -> {
+            val javaExtension = extensions.getByType<JavaPluginExtension>()
+            val mainSourceSet = javaExtension.sourceSets.getByName("main")
+            JavaCompileInputs.fromSourceSet(mainSourceSet, this) to null
+        }
+    }
+}
+
+internal fun Project.createReleaseApiConfiguration(): Configuration {
+    return configurations.findByName("ReleaseApiDependencies")
+        ?: configurations
+            .create("ReleaseApiDependencies") {
+                it.isCanBeConsumed = false
+                it.isTransitive = false
+                it.attributes.attribute(
+                    BuildTypeAttr.ATTRIBUTE,
+                    project.objects.named(BuildTypeAttr::class.java, "release")
+                )
+                it.attributes.attribute(
+                    Usage.USAGE_ATTRIBUTE,
+                    objects.named(Usage::class.java, Usage.JAVA_API)
+                )
+                it.attributes.attribute(
+                    ArtifactTypeDefinition.ARTIFACT_TYPE_ATTRIBUTE,
+                    ArtifactTypeDefinition.JAR_TYPE
+                )
+            }
+            .apply { project.dependencies.add(name, project.project(path)) }
+}
+
 internal class BlankApiRegularFile(project: Project) : RegularFile {
     val file = File(project.getSupportRootFolder(), "buildSrc/blank-res-api/public.txt")
 
diff --git a/buildSrc/private/src/main/kotlin/androidx/build/kythe/GenerateKotlinKzipTask.kt b/buildSrc/private/src/main/kotlin/androidx/build/kythe/GenerateKotlinKzipTask.kt
index d97d94f..5a60bf6 100644
--- a/buildSrc/private/src/main/kotlin/androidx/build/kythe/GenerateKotlinKzipTask.kt
+++ b/buildSrc/private/src/main/kotlin/androidx/build/kythe/GenerateKotlinKzipTask.kt
@@ -99,14 +99,18 @@
             listOf(
                 "-jvm-target",
                 jvmTarget.get().target,
+                "-Xjdk-release",
+                jvmTarget.get().target,
+                "-Xjvm-default=all",
                 "-language-version",
                 kotlinTarget.get().apiVersion.version,
                 "-api-version",
                 kotlinTarget.get().apiVersion.version,
                 "-no-reflect",
                 "-no-stdlib",
-                "-Xjvm-default=all",
-                "-opt-in=kotlin.contracts.ExperimentalContracts"
+                "-opt-in=androidx.room.compiler.processing.ExperimentalProcessingApi",
+                "-opt-in=com.squareup.kotlinpoet.javapoet.KotlinPoetJavaPoetPreview",
+                "-opt-in=kotlin.contracts.ExperimentalContracts",
             )
 
         val commonSourceFiles =
diff --git a/buildSrc/private/src/main/kotlin/androidx/build/kythe/KzipTasks.kt b/buildSrc/private/src/main/kotlin/androidx/build/kythe/KzipTasks.kt
new file mode 100644
index 0000000..e9a90d0
--- /dev/null
+++ b/buildSrc/private/src/main/kotlin/androidx/build/kythe/KzipTasks.kt
@@ -0,0 +1,39 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.build.kythe
+
+import androidx.build.AndroidXExtension
+import androidx.build.checkapi.ApiTaskConfig
+import androidx.build.checkapi.configureJavaInputsAndManifest
+import androidx.build.checkapi.createReleaseApiConfiguration
+import androidx.build.getDefaultTargetJavaVersion
+import org.gradle.api.Project
+
+fun Project.configureProjectForKzipTasks(config: ApiTaskConfig, extension: AndroidXExtension) =
+    // afterEvaluate required to read extension properties
+    afterEvaluate {
+        val (javaInputs, _) = configureJavaInputsAndManifest(config) ?: return@afterEvaluate
+        val generateApiDependencies = createReleaseApiConfiguration()
+
+        GenerateKotlinKzipTask.setupProject(
+            project,
+            javaInputs,
+            generateApiDependencies,
+            extension.kotlinTarget,
+            getDefaultTargetJavaVersion(extension.type, project.name)
+        )
+    }
diff --git a/busytown/impl/merge-kzips.sh b/busytown/impl/merge-kzips.sh
index cf65f9d..36825097 100755
--- a/busytown/impl/merge-kzips.sh
+++ b/busytown/impl/merge-kzips.sh
@@ -39,17 +39,27 @@
 mkdir -p "$DIST_DIR"
 export DIST_DIR="$DIST_DIR"
 
+REVISION=$(grep 'path="frameworks/support"' "$MANIFEST" | sed -n 's/.*revision="\([^"]*\).*/\1/p')
 
-# If the SUPERPROJECT_REVISION is defined as a sha, use this as the default value
-if [[ ${SUPERPROJECT_REVISION:-} =~ [0-9a-f]{40} ]]; then
-  : ${KZIP_NAME:=${SUPERPROJECT_REVISION:-}}
-fi
+# Default KZIP_NAME to the revision value from the XML file
+: ${KZIP_NAME:=$REVISION}
 
-: ${KZIP_NAME:=${BUILD_NUMBER:-}}
+# Fallback to the latest Git commit hash if revision is not found
+: ${KZIP_NAME:=$(git rev-parse HEAD)}
+
+# Fallback to a UUID if both the revision and Git commit hash are not there
 : ${KZIP_NAME:=$(uuidgen)}
 
 
 rm -rf $DIST_DIR/*.kzip
 declare -r allkzip="$KZIP_NAME.kzip"
 echo "Merging Kzips..."
-"$PREBUILTS_DIR/build-tools/linux-x86/bin/merge_zips" "$DIST_DIR/$allkzip" @<(find "$OUT_DIR/androidx" -name '*.kzip')
+
+# Determine the directory based on OS
+if [[ "$(uname)" == "Darwin" ]]; then
+  BUILD_TOOLS_DIR="$PREBUILTS_DIR/build-tools/darwin-x86/bin"
+else
+  BUILD_TOOLS_DIR="$PREBUILTS_DIR/build-tools/linux-x86/bin"
+fi
+
+"$BUILD_TOOLS_DIR/merge_zips" "$DIST_DIR/$allkzip" @<(find "$OUT_DIR/androidx" -name '*.kzip')
diff --git a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/compat/quirk/CloseCameraDeviceOnCameraGraphCloseQuirk.kt b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/compat/quirk/CloseCameraDeviceOnCameraGraphCloseQuirk.kt
index 1d037c7..d63e7df 100644
--- a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/compat/quirk/CloseCameraDeviceOnCameraGraphCloseQuirk.kt
+++ b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/compat/quirk/CloseCameraDeviceOnCameraGraphCloseQuirk.kt
@@ -21,13 +21,13 @@
 import androidx.camera.core.impl.Quirk
 
 /**
- * Quirk needed on devices where not closing the camera device before creating a new capture session
- * can lead to undesirable behaviors, such as native camera HAL crashes. On Exynos7870 platforms for
- * example, once their 3A pipeline times out, recreating a capture session has a high chance of
- * triggering use-after-free crashes.
+ * Quirk needed on devices where not closing the camera device can lead to undesirable behaviors,
+ * such as switching to a new session without closing the camera device may cause native camera HAL
+ * crashes, or the app getting "frozen" while CameraPipe awaits on a 1s cooldown to finally close
+ * the camera device.
  *
  * QuirkSummary
- * - Bug Id: 282871038
+ * - Bug Id: 282871038, 369300443
  * - Description: Instructs CameraPipe to close the camera device before creating a new capture
  *   session to avoid undesirable behaviors
  *
@@ -38,7 +38,21 @@
     public companion object {
         @JvmStatic
         public fun isEnabled(): Boolean {
-            return Build.HARDWARE == "samsungexynos7870"
+            if (Build.HARDWARE == "samsungexynos7870") {
+                // On Exynos7870 platforms, when their 3A pipeline times out, recreating a capture
+                // session has a high chance of triggering use-after-free crashes. Closing the
+                // camera device helps reduce the likelihood of this happening.
+                return true
+            } else if (
+                Build.VERSION.SDK_INT in Build.VERSION_CODES.R..Build.VERSION_CODES.TIRAMISU &&
+                    (Device.isOppoDevice() || Device.isOnePlusDevice() || Device.isRealmeDevice())
+            ) {
+                // On Oppo-family devices from Android 11 to Android 13, a process called
+                // OplusHansManager actively "freezes" app processes, which means we cannot delay
+                // closing the camera device for any amount of time.
+                return true
+            }
+            return false
         }
     }
 }
diff --git a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/compat/quirk/Device.kt b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/compat/quirk/Device.kt
index 908088f..457881a 100644
--- a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/compat/quirk/Device.kt
+++ b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/compat/quirk/Device.kt
@@ -39,10 +39,14 @@
 
     public fun isPositivoDevice(): Boolean = isDeviceFrom("Positivo")
 
+    public fun isRealmeDevice(): Boolean = isDeviceFrom("Realme")
+
     public fun isRedmiDevice(): Boolean = isDeviceFrom("Redmi")
 
     public fun isSamsungDevice(): Boolean = isDeviceFrom("Samsung")
 
+    public fun isTecnoDevice(): Boolean = isDeviceFrom("Tecno") || isDeviceFrom("Tecno-mobile")
+
     public fun isXiaomiDevice(): Boolean = isDeviceFrom("Xiaomi")
 
     public fun isVivoDevice(): Boolean = isDeviceFrom("Vivo")
diff --git a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/compat/quirk/DeviceQuirksLoader.kt b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/compat/quirk/DeviceQuirksLoader.kt
index 7a1560f..425db10 100644
--- a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/compat/quirk/DeviceQuirksLoader.kt
+++ b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/compat/quirk/DeviceQuirksLoader.kt
@@ -64,6 +64,14 @@
         }
         if (
             quirkSettings.shouldEnableQuirk(
+                DisableAbortCapturesOnStopQuirk::class.java,
+                DisableAbortCapturesOnStopQuirk.isEnabled()
+            )
+        ) {
+            quirks.add(DisableAbortCapturesOnStopQuirk())
+        }
+        if (
+            quirkSettings.shouldEnableQuirk(
                 DisableAbortCapturesOnStopWithSessionProcessorQuirk::class.java,
                 DisableAbortCapturesOnStopWithSessionProcessorQuirk.isEnabled()
             )
diff --git a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/compat/quirk/DisableAbortCapturesOnStopQuirk.kt b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/compat/quirk/DisableAbortCapturesOnStopQuirk.kt
new file mode 100644
index 0000000..7579a63
--- /dev/null
+++ b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/compat/quirk/DisableAbortCapturesOnStopQuirk.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.integration.compat.quirk
+
+import android.annotation.SuppressLint
+import androidx.camera.core.impl.Quirk
+
+/**
+ * Quirk needed on devices where faulty implementations of abortCaptures can lead to undesirable
+ * behaviors such as camera HAL crashing.
+ *
+ * QuirkSummary
+ * - Bug Id: 356792947
+ * - Description: Instructs CameraPipe to not abort captures when stopping.
+ *
+ * TODO(b/270421716): enable CameraXQuirksClassDetector lint check when kotlin is supported.
+ */
+@SuppressLint("CameraXQuirksClassDetector")
+public class DisableAbortCapturesOnStopQuirk : Quirk {
+    public companion object {
+        @JvmStatic
+        public fun isEnabled(): Boolean {
+            return Device.isTecnoDevice()
+        }
+    }
+}
diff --git a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/compat/workaround/SizeFilteredEncoderProfilesProvider.kt b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/compat/workaround/SizeFilteredEncoderProfilesProvider.kt
new file mode 100644
index 0000000..255a475
--- /dev/null
+++ b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/compat/workaround/SizeFilteredEncoderProfilesProvider.kt
@@ -0,0 +1,105 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.camera.camera2.pipe.integration.compat.workaround
+
+import android.media.CamcorderProfile.QUALITY_HIGH
+import android.media.CamcorderProfile.QUALITY_LOW
+import android.util.Size
+import androidx.camera.core.impl.EncoderProfilesProvider
+import androidx.camera.core.impl.EncoderProfilesProvider.QUALITY_HIGH_TO_LOW
+import androidx.camera.core.impl.EncoderProfilesProxy
+
+/**
+ * An [EncoderProfilesProvider] that filters profiles based on supported sizes.
+ *
+ * This class wraps another [EncoderProfilesProvider] and filters its output to only include
+ * profiles with resolutions that are supported by the camera, as indicated by the provided list of
+ * [supportedSizes].
+ */
+public class SizeFilteredEncoderProfilesProvider(
+    /** The original [EncoderProfilesProvider] to wrap. */
+    private val provider: EncoderProfilesProvider,
+
+    /** The list of supported sizes. */
+    private val supportedSizes: List<Size>
+) : EncoderProfilesProvider {
+
+    private val encoderProfilesCache = mutableMapOf<Int, EncoderProfilesProxy?>()
+
+    override fun hasProfile(quality: Int): Boolean {
+        return getAll(quality) != null
+    }
+
+    override fun getAll(quality: Int): EncoderProfilesProxy? {
+        if (!provider.hasProfile(quality)) {
+            return null
+        }
+
+        if (encoderProfilesCache.containsKey(quality)) {
+            return encoderProfilesCache[quality]
+        }
+
+        var profiles = provider.getAll(quality)
+        if (profiles != null && !isResolutionSupported(profiles)) {
+            profiles =
+                when (quality) {
+                    QUALITY_HIGH -> findFirstAvailableProfile(QUALITY_HIGH_TO_LOW)
+                    QUALITY_LOW -> findFirstAvailableProfile(QUALITY_HIGH_TO_LOW.reversed())
+                    else -> null
+                }
+        }
+
+        encoderProfilesCache[quality] = profiles
+        return profiles
+    }
+
+    /**
+     * Checks if the resolution of the given [EncoderProfilesProxy] is supported.
+     *
+     * @param profiles The [EncoderProfilesProxy] to check.
+     * @return `true` if the resolution is supported, `false` otherwise.
+     */
+    private fun isResolutionSupported(profiles: EncoderProfilesProxy): Boolean {
+        if (supportedSizes.isEmpty() || profiles.videoProfiles.isEmpty()) {
+            return false
+        }
+
+        // cts/CamcorderProfileTest.java ensures all video profiles have the same size so we just
+        // need to check the first video profile.
+        val videoProfile = profiles.videoProfiles[0]
+        return supportedSizes.contains(Size(videoProfile.width, videoProfile.height))
+    }
+
+    /**
+     * Finds the first available profile based on the given quality order.
+     *
+     * This method iterates through the provided [qualityOrder] and returns the first profile that
+     * is available.
+     *
+     * @param qualityOrder The order of qualities to search.
+     * @return The first available [EncoderProfilesProxy], or `null` if no suitable profile is
+     *   found.
+     */
+    private fun findFirstAvailableProfile(qualityOrder: List<Int>): EncoderProfilesProxy? {
+        for (quality in qualityOrder) {
+            getAll(quality)?.let {
+                return it
+            }
+        }
+        return null
+    }
+}
diff --git a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/impl/UseCaseManager.kt b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/impl/UseCaseManager.kt
index fa61309..c5fd744 100644
--- a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/impl/UseCaseManager.kt
+++ b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/impl/UseCaseManager.kt
@@ -52,6 +52,7 @@
 import androidx.camera.camera2.pipe.integration.compat.quirk.CloseCaptureSessionOnDisconnectQuirk
 import androidx.camera.camera2.pipe.integration.compat.quirk.CloseCaptureSessionOnVideoQuirk
 import androidx.camera.camera2.pipe.integration.compat.quirk.DeviceQuirks
+import androidx.camera.camera2.pipe.integration.compat.quirk.DisableAbortCapturesOnStopQuirk
 import androidx.camera.camera2.pipe.integration.compat.quirk.DisableAbortCapturesOnStopWithSessionProcessorQuirk
 import androidx.camera.camera2.pipe.integration.compat.quirk.FinalizeSessionOnCloseQuirk
 import androidx.camera.camera2.pipe.integration.compat.quirk.QuickSuccessiveImageCaptureFailsRepeatingRequestQuirk
@@ -1102,6 +1103,7 @@
                         DeviceQuirks[
                             DisableAbortCapturesOnStopWithSessionProcessorQuirk::class.java] !=
                             null -> false
+                    DeviceQuirks[DisableAbortCapturesOnStopQuirk::class.java] != null -> false
                     /** @see [CameraGraph.Flags.abortCapturesOnStop] */
                     Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> true
                     else -> false
diff --git a/camera/camera-camera2-pipe-integration/src/test/java/androidx/camera/camera2/pipe/integration/compat/workaround/SizeFilteredEncoderProfilesProviderTest.kt b/camera/camera-camera2-pipe-integration/src/test/java/androidx/camera/camera2/pipe/integration/compat/workaround/SizeFilteredEncoderProfilesProviderTest.kt
new file mode 100644
index 0000000..ceccd91
--- /dev/null
+++ b/camera/camera-camera2-pipe-integration/src/test/java/androidx/camera/camera2/pipe/integration/compat/workaround/SizeFilteredEncoderProfilesProviderTest.kt
@@ -0,0 +1,83 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.camera.camera2.pipe.integration.compat.workaround
+
+import android.media.CamcorderProfile.QUALITY_1080P
+import android.media.CamcorderProfile.QUALITY_2160P
+import android.media.CamcorderProfile.QUALITY_480P
+import android.media.CamcorderProfile.QUALITY_720P
+import android.media.CamcorderProfile.QUALITY_HIGH
+import android.media.CamcorderProfile.QUALITY_LOW
+import android.os.Build
+import androidx.camera.testing.impl.EncoderProfilesUtil.PROFILES_1080P
+import androidx.camera.testing.impl.EncoderProfilesUtil.PROFILES_2160P
+import androidx.camera.testing.impl.EncoderProfilesUtil.PROFILES_480P
+import androidx.camera.testing.impl.EncoderProfilesUtil.PROFILES_720P
+import androidx.camera.testing.impl.EncoderProfilesUtil.RESOLUTION_1080P
+import androidx.camera.testing.impl.EncoderProfilesUtil.RESOLUTION_720P
+import androidx.camera.testing.impl.fakes.FakeEncoderProfilesProvider
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import org.robolectric.annotation.internal.DoNotInstrument
+
+@RunWith(RobolectricTestRunner::class)
+@DoNotInstrument
+@Config(minSdk = Build.VERSION_CODES.LOLLIPOP)
+class SizeFilteredEncoderProfilesProviderTest {
+
+    private val profilesProvider =
+        FakeEncoderProfilesProvider.Builder()
+            .add(QUALITY_HIGH, PROFILES_2160P)
+            .add(QUALITY_2160P, PROFILES_2160P)
+            .add(QUALITY_1080P, PROFILES_1080P)
+            .add(QUALITY_720P, PROFILES_720P)
+            .add(QUALITY_480P, PROFILES_480P)
+            .add(QUALITY_LOW, PROFILES_480P)
+            .build()
+
+    private val supportedSizes = listOf(RESOLUTION_1080P, RESOLUTION_720P)
+
+    private val sizeFilteredProvider =
+        SizeFilteredEncoderProfilesProvider(profilesProvider, supportedSizes)
+
+    @Test
+    fun quality_shouldBeFilteredBySupportedSizes() {
+        assertThat(sizeFilteredProvider.hasProfile(QUALITY_2160P)).isFalse()
+        assertThat(sizeFilteredProvider.getAll(QUALITY_2160P)).isNull()
+
+        assertThat(sizeFilteredProvider.hasProfile(QUALITY_2160P)).isFalse()
+        assertThat(sizeFilteredProvider.getAll(QUALITY_2160P)).isNull()
+        assertThat(sizeFilteredProvider.hasProfile(QUALITY_1080P)).isTrue()
+        assertThat(sizeFilteredProvider.getAll(QUALITY_1080P)).isSameInstanceAs(PROFILES_1080P)
+        assertThat(sizeFilteredProvider.hasProfile(QUALITY_720P)).isTrue()
+        assertThat(sizeFilteredProvider.getAll(QUALITY_720P)).isSameInstanceAs(PROFILES_720P)
+        assertThat(sizeFilteredProvider.hasProfile(QUALITY_480P)).isFalse()
+        assertThat(sizeFilteredProvider.getAll(QUALITY_480P)).isNull()
+    }
+
+    @Test
+    fun qualityHighLow_shouldMapToCorrectProfiles() {
+        assertThat(sizeFilteredProvider.hasProfile(QUALITY_HIGH)).isTrue()
+        assertThat(sizeFilteredProvider.getAll(QUALITY_HIGH)).isSameInstanceAs(PROFILES_1080P)
+
+        assertThat(sizeFilteredProvider.hasProfile(QUALITY_LOW)).isTrue()
+        assertThat(sizeFilteredProvider.getAll(QUALITY_LOW)).isSameInstanceAs(PROFILES_720P)
+    }
+}
diff --git a/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/compat/workaround/SizeFilteredEncoderProfilesProvider.kt b/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/compat/workaround/SizeFilteredEncoderProfilesProvider.kt
new file mode 100644
index 0000000..1380215
--- /dev/null
+++ b/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/compat/workaround/SizeFilteredEncoderProfilesProvider.kt
@@ -0,0 +1,105 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.camera.camera2.internal.compat.workaround
+
+import android.media.CamcorderProfile.QUALITY_HIGH
+import android.media.CamcorderProfile.QUALITY_LOW
+import android.util.Size
+import androidx.camera.core.impl.EncoderProfilesProvider
+import androidx.camera.core.impl.EncoderProfilesProvider.QUALITY_HIGH_TO_LOW
+import androidx.camera.core.impl.EncoderProfilesProxy
+
+/**
+ * An [EncoderProfilesProvider] that filters profiles based on supported sizes.
+ *
+ * This class wraps another [EncoderProfilesProvider] and filters its output to only include
+ * profiles with resolutions that are supported by the camera, as indicated by the provided list of
+ * [supportedSizes].
+ */
+public class SizeFilteredEncoderProfilesProvider(
+    /** The original [EncoderProfilesProvider] to wrap. */
+    private val provider: EncoderProfilesProvider,
+
+    /** The list of supported sizes. */
+    private val supportedSizes: List<Size>
+) : EncoderProfilesProvider {
+
+    private val encoderProfilesCache = mutableMapOf<Int, EncoderProfilesProxy?>()
+
+    override fun hasProfile(quality: Int): Boolean {
+        return getAll(quality) != null
+    }
+
+    override fun getAll(quality: Int): EncoderProfilesProxy? {
+        if (!provider.hasProfile(quality)) {
+            return null
+        }
+
+        if (encoderProfilesCache.containsKey(quality)) {
+            return encoderProfilesCache[quality]
+        }
+
+        var profiles = provider.getAll(quality)
+        if (profiles != null && !isResolutionSupported(profiles)) {
+            profiles =
+                when (quality) {
+                    QUALITY_HIGH -> findFirstAvailableProfile(QUALITY_HIGH_TO_LOW)
+                    QUALITY_LOW -> findFirstAvailableProfile(QUALITY_HIGH_TO_LOW.reversed())
+                    else -> null
+                }
+        }
+
+        encoderProfilesCache[quality] = profiles
+        return profiles
+    }
+
+    /**
+     * Checks if the resolution of the given [EncoderProfilesProxy] is supported.
+     *
+     * @param profiles The [EncoderProfilesProxy] to check.
+     * @return `true` if the resolution is supported, `false` otherwise.
+     */
+    private fun isResolutionSupported(profiles: EncoderProfilesProxy): Boolean {
+        if (supportedSizes.isEmpty() || profiles.videoProfiles.isEmpty()) {
+            return false
+        }
+
+        // cts/CamcorderProfileTest.java ensures all video profiles have the same size so we just
+        // need to check the first video profile.
+        val videoProfile = profiles.videoProfiles[0]
+        return supportedSizes.contains(Size(videoProfile.width, videoProfile.height))
+    }
+
+    /**
+     * Finds the first available profile based on the given quality order.
+     *
+     * This method iterates through the provided [qualityOrder] and returns the first profile that
+     * is available.
+     *
+     * @param qualityOrder The order of qualities to search.
+     * @return The first available [EncoderProfilesProxy], or `null` if no suitable profile is
+     *   found.
+     */
+    private fun findFirstAvailableProfile(qualityOrder: List<Int>): EncoderProfilesProxy? {
+        for (quality in qualityOrder) {
+            getAll(quality)?.let {
+                return it
+            }
+        }
+        return null
+    }
+}
diff --git a/camera/camera-camera2/src/test/java/androidx/camera/camera2/internal/compat/workaround/SizeFilteredEncoderProfilesProviderTest.kt b/camera/camera-camera2/src/test/java/androidx/camera/camera2/internal/compat/workaround/SizeFilteredEncoderProfilesProviderTest.kt
new file mode 100644
index 0000000..7f7ae02
--- /dev/null
+++ b/camera/camera-camera2/src/test/java/androidx/camera/camera2/internal/compat/workaround/SizeFilteredEncoderProfilesProviderTest.kt
@@ -0,0 +1,83 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.camera.camera2.internal.compat.workaround
+
+import android.media.CamcorderProfile.QUALITY_1080P
+import android.media.CamcorderProfile.QUALITY_2160P
+import android.media.CamcorderProfile.QUALITY_480P
+import android.media.CamcorderProfile.QUALITY_720P
+import android.media.CamcorderProfile.QUALITY_HIGH
+import android.media.CamcorderProfile.QUALITY_LOW
+import android.os.Build
+import androidx.camera.testing.impl.EncoderProfilesUtil.PROFILES_1080P
+import androidx.camera.testing.impl.EncoderProfilesUtil.PROFILES_2160P
+import androidx.camera.testing.impl.EncoderProfilesUtil.PROFILES_480P
+import androidx.camera.testing.impl.EncoderProfilesUtil.PROFILES_720P
+import androidx.camera.testing.impl.EncoderProfilesUtil.RESOLUTION_1080P
+import androidx.camera.testing.impl.EncoderProfilesUtil.RESOLUTION_720P
+import androidx.camera.testing.impl.fakes.FakeEncoderProfilesProvider
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import org.robolectric.annotation.internal.DoNotInstrument
+
+@RunWith(RobolectricTestRunner::class)
+@DoNotInstrument
+@Config(minSdk = Build.VERSION_CODES.LOLLIPOP)
+class SizeFilteredEncoderProfilesProviderTest {
+
+    private val profilesProvider =
+        FakeEncoderProfilesProvider.Builder()
+            .add(QUALITY_HIGH, PROFILES_2160P)
+            .add(QUALITY_2160P, PROFILES_2160P)
+            .add(QUALITY_1080P, PROFILES_1080P)
+            .add(QUALITY_720P, PROFILES_720P)
+            .add(QUALITY_480P, PROFILES_480P)
+            .add(QUALITY_LOW, PROFILES_480P)
+            .build()
+
+    private val supportedSizes = listOf(RESOLUTION_1080P, RESOLUTION_720P)
+
+    private val sizeFilteredProvider =
+        SizeFilteredEncoderProfilesProvider(profilesProvider, supportedSizes)
+
+    @Test
+    fun quality_shouldBeFilteredBySupportedSizes() {
+        assertThat(sizeFilteredProvider.hasProfile(QUALITY_2160P)).isFalse()
+        assertThat(sizeFilteredProvider.getAll(QUALITY_2160P)).isNull()
+
+        assertThat(sizeFilteredProvider.hasProfile(QUALITY_2160P)).isFalse()
+        assertThat(sizeFilteredProvider.getAll(QUALITY_2160P)).isNull()
+        assertThat(sizeFilteredProvider.hasProfile(QUALITY_1080P)).isTrue()
+        assertThat(sizeFilteredProvider.getAll(QUALITY_1080P)).isSameInstanceAs(PROFILES_1080P)
+        assertThat(sizeFilteredProvider.hasProfile(QUALITY_720P)).isTrue()
+        assertThat(sizeFilteredProvider.getAll(QUALITY_720P)).isSameInstanceAs(PROFILES_720P)
+        assertThat(sizeFilteredProvider.hasProfile(QUALITY_480P)).isFalse()
+        assertThat(sizeFilteredProvider.getAll(QUALITY_480P)).isNull()
+    }
+
+    @Test
+    fun qualityHighLow_shouldMapToCorrectProfiles() {
+        assertThat(sizeFilteredProvider.hasProfile(QUALITY_HIGH)).isTrue()
+        assertThat(sizeFilteredProvider.getAll(QUALITY_HIGH)).isSameInstanceAs(PROFILES_1080P)
+
+        assertThat(sizeFilteredProvider.hasProfile(QUALITY_LOW)).isTrue()
+        assertThat(sizeFilteredProvider.getAll(QUALITY_LOW)).isSameInstanceAs(PROFILES_720P)
+    }
+}
diff --git a/camera/integration-tests/coretestapp/src/androidTest/java/androidx/camera/integration/core/FocusMeteringDeviceTest.kt b/camera/integration-tests/coretestapp/src/androidTest/java/androidx/camera/integration/core/FocusMeteringDeviceTest.kt
index cd0524c..126ca11 100644
--- a/camera/integration-tests/coretestapp/src/androidTest/java/androidx/camera/integration/core/FocusMeteringDeviceTest.kt
+++ b/camera/integration-tests/coretestapp/src/androidTest/java/androidx/camera/integration/core/FocusMeteringDeviceTest.kt
@@ -36,6 +36,7 @@
 import androidx.camera.testing.impl.CameraPipeConfigTestRule
 import androidx.camera.testing.impl.CameraUtil
 import androidx.camera.testing.impl.LabTestRule.Companion.isLensFacingEnabledInLabTest
+import androidx.camera.testing.impl.WakelockEmptyActivityRule
 import androidx.camera.testing.impl.fakes.FakeLifecycleOwner
 import androidx.test.core.app.ApplicationProvider
 import androidx.test.filters.LargeTest
@@ -77,6 +78,8 @@
             CameraUtil.PreTestCameraIdList(cameraXConfig)
         )
 
+    @get:Rule val wakelockEmptyActivityRule = WakelockEmptyActivityRule()
+
     companion object {
         @JvmStatic
         @Parameterized.Parameters(name = "selector={0},config={2}")
diff --git a/camera/integration-tests/coretestapp/src/androidTest/java/androidx/camera/integration/core/ImageAnalysisTest.kt b/camera/integration-tests/coretestapp/src/androidTest/java/androidx/camera/integration/core/ImageAnalysisTest.kt
index f022a29..3742fc9 100644
--- a/camera/integration-tests/coretestapp/src/androidTest/java/androidx/camera/integration/core/ImageAnalysisTest.kt
+++ b/camera/integration-tests/coretestapp/src/androidTest/java/androidx/camera/integration/core/ImageAnalysisTest.kt
@@ -49,6 +49,7 @@
 import androidx.camera.testing.impl.CameraUtil
 import androidx.camera.testing.impl.CameraUtil.PreTestCameraIdList
 import androidx.camera.testing.impl.LabTestRule
+import androidx.camera.testing.impl.WakelockEmptyActivityRule
 import androidx.camera.testing.impl.fakes.FakeLifecycleOwner
 import androidx.test.core.app.ApplicationProvider
 import androidx.test.filters.LargeTest
@@ -87,6 +88,8 @@
 
     @get:Rule val labTest: LabTestRule = LabTestRule()
 
+    @get:Rule val wakelockEmptyActivityRule = WakelockEmptyActivityRule()
+
     companion object {
         private val DEFAULT_RESOLUTION = Size(640, 480)
 
diff --git a/camera/integration-tests/coretestapp/src/androidTest/java/androidx/camera/integration/core/ImageCaptureLatencyTest.kt b/camera/integration-tests/coretestapp/src/androidTest/java/androidx/camera/integration/core/ImageCaptureLatencyTest.kt
index 4821840..621b8c3 100644
--- a/camera/integration-tests/coretestapp/src/androidTest/java/androidx/camera/integration/core/ImageCaptureLatencyTest.kt
+++ b/camera/integration-tests/coretestapp/src/androidTest/java/androidx/camera/integration/core/ImageCaptureLatencyTest.kt
@@ -29,6 +29,7 @@
 import androidx.camera.testing.impl.CameraPipeConfigTestRule
 import androidx.camera.testing.impl.CameraUtil
 import androidx.camera.testing.impl.LabTestRule
+import androidx.camera.testing.impl.WakelockEmptyActivityRule
 import androidx.camera.testing.impl.fakes.FakeLifecycleOwner
 import androidx.test.core.app.ApplicationProvider
 import androidx.test.filters.LargeTest
@@ -78,6 +79,8 @@
 
     @get:Rule val labTest = LabTestRule()
 
+    @get:Rule val wakelockEmptyActivityRule = WakelockEmptyActivityRule()
+
     private val context = ApplicationProvider.getApplicationContext<Context>()
     private lateinit var camera: CameraUseCaseAdapter
     private lateinit var cameraProvider: ProcessCameraProvider
diff --git a/camera/integration-tests/coretestapp/src/androidTest/java/androidx/camera/integration/core/OpenCloseCameraStressTest.kt b/camera/integration-tests/coretestapp/src/androidTest/java/androidx/camera/integration/core/OpenCloseCameraStressTest.kt
index fc3e660..94a9abb 100644
--- a/camera/integration-tests/coretestapp/src/androidTest/java/androidx/camera/integration/core/OpenCloseCameraStressTest.kt
+++ b/camera/integration-tests/coretestapp/src/androidTest/java/androidx/camera/integration/core/OpenCloseCameraStressTest.kt
@@ -38,6 +38,7 @@
 import androidx.camera.testing.impl.LabTestRule
 import androidx.camera.testing.impl.StressTestRule
 import androidx.camera.testing.impl.SurfaceTextureProvider
+import androidx.camera.testing.impl.WakelockEmptyActivityRule
 import androidx.camera.testing.impl.fakes.FakeLifecycleOwner
 import androidx.camera.video.Recorder
 import androidx.camera.video.VideoCapture
@@ -82,6 +83,8 @@
 
     @get:Rule val repeatRule = RepeatRule()
 
+    @get:Rule val wakelockEmptyActivityRule = WakelockEmptyActivityRule()
+
     private val context = ApplicationProvider.getApplicationContext<Context>()
 
     private lateinit var cameraProvider: ProcessCameraProvider
@@ -109,7 +112,7 @@
 
         preview = createPreviewWithDeviceStateMonitor(implName, cameraDeviceStateMonitor)
         withContext(Dispatchers.Main) {
-            preview.setSurfaceProvider(SurfaceTextureProvider.createSurfaceTextureProvider())
+            preview.surfaceProvider = SurfaceTextureProvider.createSurfaceTextureProvider()
         }
         imageCapture = ImageCapture.Builder().build()
     }
diff --git a/camera/integration-tests/coretestapp/src/main/java/androidx/camera/integration/core/CameraXActivity.java b/camera/integration-tests/coretestapp/src/main/java/androidx/camera/integration/core/CameraXActivity.java
index 0dc50be..46ef6e5 100644
--- a/camera/integration-tests/coretestapp/src/main/java/androidx/camera/integration/core/CameraXActivity.java
+++ b/camera/integration-tests/coretestapp/src/main/java/androidx/camera/integration/core/CameraXActivity.java
@@ -71,12 +71,12 @@
 import android.provider.MediaStore;
 import android.util.DisplayMetrics;
 import android.util.Log;
-import android.util.Pair;
 import android.util.Range;
 import android.util.Rational;
 import android.view.Display;
 import android.view.GestureDetector;
 import android.view.Menu;
+import android.view.MenuItem;
 import android.view.MotionEvent;
 import android.view.View;
 import android.view.ViewGroup;
@@ -130,9 +130,11 @@
 import androidx.camera.core.ViewPort;
 import androidx.camera.core.impl.CameraInfoInternal;
 import androidx.camera.core.impl.Quirks;
+import androidx.camera.core.impl.StreamSpec;
 import androidx.camera.core.impl.utils.AspectRatioUtil;
 import androidx.camera.core.impl.utils.executor.CameraXExecutors;
 import androidx.camera.lifecycle.ProcessCameraProvider;
+import androidx.camera.testing.impl.StreamSharingForceEnabledEffect;
 import androidx.camera.video.ExperimentalPersistentRecording;
 import androidx.camera.video.FileOutputOptions;
 import androidx.camera.video.MediaStoreOutputOptions;
@@ -166,10 +168,12 @@
 import java.util.ArrayList;
 import java.util.Calendar;
 import java.util.Collections;
+import java.util.HashMap;
 import java.util.HashSet;
 import java.util.Iterator;
 import java.util.List;
 import java.util.Locale;
+import java.util.Map;
 import java.util.Objects;
 import java.util.Set;
 import java.util.concurrent.ExecutorService;
@@ -192,7 +196,12 @@
     private static final String TAG = "CameraXActivity";
     private static final String[] REQUIRED_PERMISSIONS;
     private static final List<DynamicRangeUiData> DYNAMIC_RANGE_UI_DATA = new ArrayList<>();
-    private static final List<Pair<Range<Integer>, String>> FPS_OPTIONS = new ArrayList<>();
+
+    // StreamSpec.FRAME_RATE_RANGE_UNSPECIFIED is not public
+    @SuppressLint("RestrictedApiAndroidX")
+    private static final Range<Integer> FPS_UNSPECIFIED = StreamSpec.FRAME_RATE_RANGE_UNSPECIFIED;
+    private static final Map<Integer, Range<Integer>> ID_TO_FPS_RANGE_MAP = new HashMap<>();
+    private static final Map<Integer, Integer> ID_TO_ASPECT_RATIO_MAP = new HashMap<>();
 
     static {
         // From Android T, skips the permission check of WRITE_EXTERNAL_STORAGE since it won't be
@@ -243,10 +252,14 @@
         //  `CameraInfo.getSupportedFrameRateRanges()`, but we may want to try unsupported cases too
         //  sometimes for testing, so the unsupported ones still should be options (perhaps greyed
         //  out or struck-through).
-        FPS_OPTIONS.add(new Pair<>(new Range<>(0, 0), "Unspecified"));
-        FPS_OPTIONS.add(new Pair<>(new Range<>(15, 15), "15"));
-        FPS_OPTIONS.add(new Pair<>(new Range<>(30, 30), "30"));
-        FPS_OPTIONS.add(new Pair<>(new Range<>(60, 60), "60"));
+        ID_TO_FPS_RANGE_MAP.put(R.id.fps_unspecified, FPS_UNSPECIFIED);
+        ID_TO_FPS_RANGE_MAP.put(R.id.fps_15, new Range<>(15, 15));
+        ID_TO_FPS_RANGE_MAP.put(R.id.fps_30, new Range<>(30, 30));
+        ID_TO_FPS_RANGE_MAP.put(R.id.fps_60, new Range<>(60, 60));
+
+        ID_TO_ASPECT_RATIO_MAP.put(R.id.aspect_ratio_default, AspectRatio.RATIO_DEFAULT);
+        ID_TO_ASPECT_RATIO_MAP.put(R.id.aspect_ratio_4_3, AspectRatio.RATIO_4_3);
+        ID_TO_ASPECT_RATIO_MAP.put(R.id.aspect_ratio_16_9, AspectRatio.RATIO_16_9);
     }
 
     //Use this activity title when Camera Pipe configuration is used by core test app
@@ -364,7 +377,6 @@
     private Button mZoomIn2XToggle;
     private Button mZoomResetToggle;
     private Button mButtonImageOutputFormat;
-    private Button mButtonFps;
     private Toast mEvToast = null;
     private Toast mPSToast = null;
     private ToggleButton mPreviewStabilizationToggle;
@@ -381,7 +393,9 @@
     private final Set<DynamicRange> mSelectableDynamicRanges = new HashSet<>();
     private int mVideoMirrorMode = MIRROR_MODE_ON_FRONT_ONLY;
     private boolean mIsPreviewStabilizationOn = false;
-    private int mFpsMenuId = 0;
+    private Range<Integer> mFpsRange = FPS_UNSPECIFIED;
+    private boolean mForceEnableStreamSharing;
+    private boolean mDisableViewPort;
 
     SessionMediaUriSet mSessionImagesUriSet = new SessionMediaUriSet();
     SessionMediaUriSet mSessionVideosUriSet = new SessionMediaUriSet();
@@ -1288,7 +1302,6 @@
         mZoomSeekBar.setVisibility(View.GONE);
         mZoomRatioLabel.setVisibility(View.GONE);
         mTextView.setVisibility(View.GONE);
-        mButtonFps.setVisibility(View.GONE);
 
         if (testCase.equals(PREVIEW_TEST_CASE) || testCase.equals(SWITCH_TEST_CASE)) {
             mTorchButton.setVisibility(View.GONE);
@@ -1401,10 +1414,11 @@
         mPlusEV.setEnabled(isExposureCompensationSupported());
         mDecEV.setEnabled(isExposureCompensationSupported());
         mZoomIn2XToggle.setEnabled(is2XZoomSupported());
-        mButtonFps.setEnabled(mPreviewToggle.isChecked() || mVideoToggle.isChecked());
 
         // this function may make some view visible again, so need to update for E2E tests again
         updateAppUIForE2ETest();
+
+        invalidateOptionsMenu();
     }
 
     // Set or reset content description for e2e testing.
@@ -1605,42 +1619,6 @@
                 findViewById(R.id.video_mute),
                 (newState) -> updateDynamicRangeUiState()
         );
-        mButtonFps = findViewById(R.id.fps);
-        if (mFpsMenuId == 0) {
-            mButtonFps.setText("FPS\nUnsp.");
-        } else {
-            mButtonFps.setText("FPS\n" + FPS_OPTIONS.get(mFpsMenuId).second);
-        }
-        mButtonFps.setOnClickListener(view -> {
-            PopupMenu popup = new PopupMenu(this, view);
-            Menu menu = popup.getMenu();
-
-            for (int i = 0; i < FPS_OPTIONS.size(); i++) {
-                menu.add(0, i, Menu.NONE, FPS_OPTIONS.get(i).second);
-            }
-
-            menu.findItem(mFpsMenuId).setChecked(true);
-
-            // Make menu single checkable
-            menu.setGroupCheckable(0, true, true);
-
-            popup.setOnMenuItemClickListener(item -> {
-                int itemId = item.getItemId();
-                if (itemId != mFpsMenuId) {
-                    mFpsMenuId = itemId;
-                    if (mFpsMenuId == 0) {
-                        mButtonFps.setText("FPS\nUnsp.");
-                    } else {
-                        mButtonFps.setText("FPS\n" + FPS_OPTIONS.get(mFpsMenuId).second);
-                    }
-                    // FPS changed, rebind UseCases
-                    tryBindUseCases();
-                }
-                return true;
-            });
-
-            popup.show();
-        });
 
         setUpButtonEvents();
         setupViewFinderGestureControls();
@@ -1758,6 +1736,83 @@
         setupPermissions();
     }
 
+    @Override
+    public boolean onCreateOptionsMenu(Menu menu) {
+        getMenuInflater().inflate(R.menu.actionbar_menu, menu);
+        return true;
+    }
+
+    @Override
+    public boolean onPrepareOptionsMenu(Menu menu) {
+        updateMenuItems(menu);
+        return true;
+    }
+
+    private void updateMenuItems(Menu menu) {
+        menu.findItem(requireNonNull(getKeyByValue(ID_TO_FPS_RANGE_MAP, mFpsRange))).setChecked(
+                true);
+        menu.findItem(R.id.fps).setEnabled(mPreviewToggle.isChecked() || mVideoToggle.isChecked());
+
+        menu.findItem(requireNonNull(
+                getKeyByValue(ID_TO_ASPECT_RATIO_MAP, mTargetAspectRatio))).setChecked(true);
+
+        menu.findItem(R.id.stream_sharing).setChecked(mForceEnableStreamSharing);
+        // StreamSharing requires both Preview & VideoCapture use cases in core-test-app
+        // (since ImageCapture can't be added due to lack of effect)
+        menu.findItem(R.id.stream_sharing).setEnabled(
+                mPreviewToggle.isChecked() && mVideoToggle.isChecked());
+
+        menu.findItem(R.id.view_port).setChecked(mDisableViewPort);
+    }
+
+    private static <T, E> T getKeyByValue(Map<T, E> map, E value) {
+        for (Map.Entry<T, E> entry : map.entrySet()) {
+            if (Objects.equals(value, entry.getValue())) {
+                return entry.getKey();
+            }
+        }
+        return null; // No key found for the given value
+    }
+
+    @Override
+    public boolean onOptionsItemSelected(@NonNull MenuItem item) {
+        // Handle item selection.
+        Log.d(TAG, "onOptionsItemSelected: item = " + item);
+
+        int groupId = item.getGroupId();
+        int itemId = item.getItemId();
+
+        if (groupId == R.id.fps_group) {
+            if (ID_TO_FPS_RANGE_MAP.containsKey(itemId)) {
+                mFpsRange = ID_TO_FPS_RANGE_MAP.get(itemId);
+            } else {
+                Log.e(TAG, "Unknown item " + item.getTitle());
+                return super.onOptionsItemSelected(item);
+            }
+        } else if (groupId == R.id.aspect_ratio_group) {
+            if (ID_TO_ASPECT_RATIO_MAP.containsKey(itemId)) {
+                mTargetAspectRatio = requireNonNull(ID_TO_ASPECT_RATIO_MAP.get(itemId));
+            } else {
+                Log.e(TAG, "Unknown item " + item.getTitle());
+                return super.onOptionsItemSelected(item);
+            }
+        } else if (itemId == R.id.stream_sharing) {
+            mForceEnableStreamSharing = !mForceEnableStreamSharing;
+        } else if (itemId == R.id.view_port) {
+            mDisableViewPort = !mDisableViewPort;
+        } else {
+            Log.d(TAG, "Not handling item " + item.getTitle());
+            return super.onOptionsItemSelected(item);
+        }
+
+        item.setChecked(!item.isChecked());
+
+        // Some configuration option may be changed, rebind UseCases
+        tryBindUseCases();
+
+        return super.onOptionsItemSelected(item);
+    }
+
     /**
      * Writes text data to a file in public external directory for reading during tests.
      */
@@ -1960,7 +2015,7 @@
                     .setPreviewStabilizationEnabled(mIsPreviewStabilizationOn)
                     .setDynamicRange(
                             mVideoToggle.isChecked() ? DynamicRange.UNSPECIFIED : mDynamicRange)
-                    .setTargetFrameRate(FPS_OPTIONS.get(mFpsMenuId).first)
+                    .setTargetFrameRate(mFpsRange)
                     .build();
             resetViewIdlingResource();
             // Use the listener of the future to make sure the Preview setup the new surface.
@@ -1987,6 +2042,7 @@
         if (mAnalysisToggle.isChecked()) {
             ImageAnalysis imageAnalysis = new ImageAnalysis.Builder()
                     .setTargetName("ImageAnalysis")
+                    .setTargetAspectRatio(mTargetAspectRatio)
                     .build();
             useCases.add(imageAnalysis);
             // Make the analysis idling resource non-idle, until the required frames received.
@@ -2005,11 +2061,11 @@
                 if (mVideoQuality != QUALITY_AUTO) {
                     builder.setQualitySelector(QualitySelector.from(mVideoQuality));
                 }
-                mRecorder = builder.build();
+                mRecorder = builder.setAspectRatio(mTargetAspectRatio).build();
                 mVideoCapture = new VideoCapture.Builder<>(mRecorder)
                         .setMirrorMode(mVideoMirrorMode)
                         .setDynamicRange(mDynamicRange)
-                        .setTargetFrameRate(FPS_OPTIONS.get(mFpsMenuId).first)
+                        .setTargetFrameRate(mFpsRange)
                         .build();
             }
             useCases.add(mVideoCapture);
@@ -2122,15 +2178,29 @@
      * Binds use cases to the current lifecycle.
      */
     private Camera bindToLifecycleSafely(List<UseCase> useCases) {
-        ViewPort viewPort = new ViewPort.Builder(new Rational(mViewFinder.getWidth(),
-                mViewFinder.getHeight()),
-                mViewFinder.getDisplay().getRotation())
-                .setScaleType(ViewPort.FILL_CENTER).build();
-        UseCaseGroup.Builder useCaseGroupBuilder = new UseCaseGroup.Builder().setViewPort(
-                viewPort);
+        Log.d(TAG, "bindToLifecycleSafely: mDisableViewPort = " + mDisableViewPort
+                + ", mForceEnableStreamSharing = " + mForceEnableStreamSharing);
+
+        UseCaseGroup.Builder useCaseGroupBuilder = new UseCaseGroup.Builder();
         for (UseCase useCase : useCases) {
             useCaseGroupBuilder.addUseCase(useCase);
         }
+
+        if (!mDisableViewPort) {
+            ViewPort viewPort = new ViewPort.Builder(new Rational(mViewFinder.getWidth(),
+                    mViewFinder.getHeight()),
+                    mViewFinder.getDisplay().getRotation())
+                    .setScaleType(ViewPort.FILL_CENTER).build();
+            useCaseGroupBuilder.setViewPort(viewPort);
+        }
+
+        // Force-enable stream sharing
+        if (mForceEnableStreamSharing) {
+            @SuppressLint("RestrictedApiAndroidX")
+            StreamSharingForceEnabledEffect effect = new StreamSharingForceEnabledEffect();
+            useCaseGroupBuilder.addEffect(effect);
+        }
+
         mCamera = mCameraProvider.bindToLifecycle(this, mCurrentCameraSelector,
                 useCaseGroupBuilder.build());
         setupZoomSeeker();
diff --git a/camera/integration-tests/coretestapp/src/main/res/layout/activity_camera_xmain.xml b/camera/integration-tests/coretestapp/src/main/res/layout/activity_camera_xmain.xml
index bc7e3db..c65182e 100644
--- a/camera/integration-tests/coretestapp/src/main/res/layout/activity_camera_xmain.xml
+++ b/camera/integration-tests/coretestapp/src/main/res/layout/activity_camera_xmain.xml
@@ -437,20 +437,6 @@
         app:layout_constraintStart_toEndOf="@+id/seekBar"
         app:layout_constraintTop_toTopOf="@+id/seekBar" />
 
-    <Button
-        android:id="@+id/fps"
-        android:layout_width="46dp"
-        android:layout_height="wrap_content"
-        android:layout_marginStart="5dp"
-        android:layout_marginTop="1dp"
-        android:background="@android:drawable/btn_default"
-        android:text="FPS"
-        android:textSize="7dp"
-        android:translationZ="1dp"
-        app:layout_constraintTop_toBottomOf="@id/preview_stabilization"
-        app:layout_constraintLeft_toLeftOf="parent"
-        />
-
     <androidx.camera.view.ScreenFlashView
         android:id="@+id/screen_flash_view"
         android:layout_width="match_parent"
diff --git a/camera/integration-tests/coretestapp/src/main/res/menu/actionbar_menu.xml b/camera/integration-tests/coretestapp/src/main/res/menu/actionbar_menu.xml
new file mode 100644
index 0000000..6e2c5fb
--- /dev/null
+++ b/camera/integration-tests/coretestapp/src/main/res/menu/actionbar_menu.xml
@@ -0,0 +1,82 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+  Copyright 2019 The Android Open Source Project
+
+  Licensed under the Apache License, Version 2.0 (the "License");
+  you may not use this file except in compliance with the License.
+  You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+  -->
+
+<menu xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto">
+    <item
+        android:id="@+id/fps"
+        android:title="@string/fps"
+        app:showAsAction="never">
+        <menu>
+            <group
+                android:id="@+id/fps_group"
+                android:checkableBehavior="single">
+                <item
+                    android:id="@+id/fps_unspecified"
+                    android:checked="true"
+                    android:title="Unspecified"
+                    app:showAsAction="never" />
+                <item
+                    android:id="@+id/fps_15"
+                    android:title="15"
+                    app:showAsAction="never" />
+                <item
+                    android:id="@+id/fps_30"
+                    android:title="30"
+                    app:showAsAction="never" />
+                <item
+                    android:id="@+id/fps_60"
+                    android:title="60"
+                    app:showAsAction="never" />
+            </group>
+        </menu>
+    </item>
+    <item
+        android:id="@+id/aspect_ratio"
+        android:title="@string/aspect_ratio"
+        app:showAsAction="never">
+        <menu>
+            <group
+                android:id="@+id/aspect_ratio_group"
+                android:checkableBehavior="single">
+                <item
+                    android:id="@+id/aspect_ratio_default"
+                    android:checked="true"
+                    android:title="Default"
+                    app:showAsAction="never" />
+                <item
+                    android:id="@+id/aspect_ratio_4_3"
+                    android:title="4:3"
+                    app:showAsAction="never" />
+                <item
+                    android:id="@+id/aspect_ratio_16_9"
+                    android:title="16:9"
+                    app:showAsAction="never" />
+            </group>
+        </menu>
+    </item>
+    <item
+        android:id="@+id/stream_sharing"
+        android:checkable="true"
+        android:title="@string/force_stream_sharing"
+        app:showAsAction="never" />
+    <item
+        android:id="@+id/view_port"
+        android:checkable="true"
+        android:title="@string/disable_view_port"
+        app:showAsAction="never" />
+
+</menu>
diff --git a/camera/integration-tests/coretestapp/src/main/res/values/donottranslate-strings.xml b/camera/integration-tests/coretestapp/src/main/res/values/donottranslate-strings.xml
index 949bfd4..34828bd 100644
--- a/camera/integration-tests/coretestapp/src/main/res/values/donottranslate-strings.xml
+++ b/camera/integration-tests/coretestapp/src/main/res/values/donottranslate-strings.xml
@@ -47,5 +47,9 @@
     <string name="toggle_video_dyn_rng_hdr_dolby_vision_10">Dlby\n10bit</string>
     <string name="toggle_video_dyn_rng_hdr_dolby_vision_8">Dlby\n8bit</string>
     <string name="toggle_video_dyn_rng_unknown">\?</string>
+    <string name="fps">FPS</string>
+    <string name="aspect_ratio">Aspect Ratio</string>
+    <string name="force_stream_sharing">Force Stream Sharing</string>
+    <string name="disable_view_port">Disable View Port</string>
 
 </resources>
\ No newline at end of file
diff --git a/car/app/app-samples/showcase/common/src/main/java/androidx/car/app/sample/showcase/common/screens/templatelayouts/listtemplates/SecondaryActionsAndDecorationDemoScreen.java b/car/app/app-samples/showcase/common/src/main/java/androidx/car/app/sample/showcase/common/screens/templatelayouts/listtemplates/SecondaryActionsAndDecorationDemoScreen.java
index 87d420b..5d57c7c 100644
--- a/car/app/app-samples/showcase/common/src/main/java/androidx/car/app/sample/showcase/common/screens/templatelayouts/listtemplates/SecondaryActionsAndDecorationDemoScreen.java
+++ b/car/app/app-samples/showcase/common/src/main/java/androidx/car/app/sample/showcase/common/screens/templatelayouts/listtemplates/SecondaryActionsAndDecorationDemoScreen.java
@@ -58,6 +58,11 @@
                 12,
                 action));
 
+        listBuilder.addItem(buildRowForTemplate(
+                R.string.secondary_actions_decoration_test_title_long,
+                9,
+                action));
+
         return new ListTemplate.Builder()
                 .setSingleList(listBuilder.build())
                 .setHeader(new Header.Builder()
diff --git a/car/app/app-samples/showcase/common/src/main/res/values/strings.xml b/car/app/app-samples/showcase/common/src/main/res/values/strings.xml
index f8c1adf3..5ea9f40 100644
--- a/car/app/app-samples/showcase/common/src/main/res/values/strings.xml
+++ b/car/app/app-samples/showcase/common/src/main/res/values/strings.xml
@@ -408,6 +408,7 @@
   <string name="secondary_actions_test_subtitle">Only the secondary action can be selected</string>
   <string name="decoration_test_title">Decoration Test</string>
   <string name="secondary_actions_decoration_test_title">Secondary Actions and Decoration</string>
+  <string name="secondary_actions_decoration_test_title_long">Row with Secondary Actions and Decoration with a really long title</string>
   <string name="secondary_actions_decoration_test_subtitle">The row can also be selected</string>
   <string name="secondary_action_toast">Secondary Action is selected</string>
   <string name="row_primary_action_toast">Row primary action is selected</string>
diff --git a/car/app/app/api/1.7.0-beta03.txt b/car/app/app/api/1.7.0-beta03.txt
index 79be8d8..d578a0c 100644
--- a/car/app/app/api/1.7.0-beta03.txt
+++ b/car/app/app/api/1.7.0-beta03.txt
@@ -921,6 +921,7 @@
     field public static final String KEY_EXCLUDE_MEDIA_ITEM_FROM_MIXED_APP_LIST = "androidx.car.app.mediaextensions.KEY_EXCLUDE_MEDIA_ITEM_FROM_MIXED_APP_LIST";
     field public static final String KEY_IMMERSIVE_AUDIO = "androidx.car.app.mediaextensions.KEY_IMMERSIVE_AUDIO";
     field public static final String KEY_SUBTITLE_LINK_MEDIA_ID = "androidx.car.app.mediaextensions.KEY_SUBTITLE_LINK_MEDIA_ID";
+    field public static final String KEY_TINTABLE_INDICATOR_ICON_URI_LIST = "androidx.car.app.mediaextensions.KEY_TINTABLE_INDICATOR_ICON_URI_LIST";
   }
 
 }
diff --git a/car/app/app/api/current.ignore b/car/app/app/api/current.ignore
index 3ef9479..e089e19 100644
--- a/car/app/app/api/current.ignore
+++ b/car/app/app/api/current.ignore
@@ -1,3 +1,3 @@
 // Baseline format: 1.0
-AddedMethod: androidx.car.app.notification.CarAppExtender#extend(android.app.Notification.Builder):
-    Added method androidx.car.app.notification.CarAppExtender.extend(android.app.Notification.Builder)
+AddedField: androidx.car.app.mediaextensions.MetadataExtras#KEY_TINTABLE_INDICATOR_ICON_URI_LIST:
+    Added field androidx.car.app.mediaextensions.MetadataExtras.KEY_TINTABLE_INDICATOR_ICON_URI_LIST
diff --git a/car/app/app/api/current.txt b/car/app/app/api/current.txt
index 79be8d8..d578a0c 100644
--- a/car/app/app/api/current.txt
+++ b/car/app/app/api/current.txt
@@ -921,6 +921,7 @@
     field public static final String KEY_EXCLUDE_MEDIA_ITEM_FROM_MIXED_APP_LIST = "androidx.car.app.mediaextensions.KEY_EXCLUDE_MEDIA_ITEM_FROM_MIXED_APP_LIST";
     field public static final String KEY_IMMERSIVE_AUDIO = "androidx.car.app.mediaextensions.KEY_IMMERSIVE_AUDIO";
     field public static final String KEY_SUBTITLE_LINK_MEDIA_ID = "androidx.car.app.mediaextensions.KEY_SUBTITLE_LINK_MEDIA_ID";
+    field public static final String KEY_TINTABLE_INDICATOR_ICON_URI_LIST = "androidx.car.app.mediaextensions.KEY_TINTABLE_INDICATOR_ICON_URI_LIST";
   }
 
 }
diff --git a/car/app/app/api/restricted_1.7.0-beta03.txt b/car/app/app/api/restricted_1.7.0-beta03.txt
index 79be8d8..d578a0c 100644
--- a/car/app/app/api/restricted_1.7.0-beta03.txt
+++ b/car/app/app/api/restricted_1.7.0-beta03.txt
@@ -921,6 +921,7 @@
     field public static final String KEY_EXCLUDE_MEDIA_ITEM_FROM_MIXED_APP_LIST = "androidx.car.app.mediaextensions.KEY_EXCLUDE_MEDIA_ITEM_FROM_MIXED_APP_LIST";
     field public static final String KEY_IMMERSIVE_AUDIO = "androidx.car.app.mediaextensions.KEY_IMMERSIVE_AUDIO";
     field public static final String KEY_SUBTITLE_LINK_MEDIA_ID = "androidx.car.app.mediaextensions.KEY_SUBTITLE_LINK_MEDIA_ID";
+    field public static final String KEY_TINTABLE_INDICATOR_ICON_URI_LIST = "androidx.car.app.mediaextensions.KEY_TINTABLE_INDICATOR_ICON_URI_LIST";
   }
 
 }
diff --git a/car/app/app/api/restricted_current.ignore b/car/app/app/api/restricted_current.ignore
index 3ef9479..e089e19 100644
--- a/car/app/app/api/restricted_current.ignore
+++ b/car/app/app/api/restricted_current.ignore
@@ -1,3 +1,3 @@
 // Baseline format: 1.0
-AddedMethod: androidx.car.app.notification.CarAppExtender#extend(android.app.Notification.Builder):
-    Added method androidx.car.app.notification.CarAppExtender.extend(android.app.Notification.Builder)
+AddedField: androidx.car.app.mediaextensions.MetadataExtras#KEY_TINTABLE_INDICATOR_ICON_URI_LIST:
+    Added field androidx.car.app.mediaextensions.MetadataExtras.KEY_TINTABLE_INDICATOR_ICON_URI_LIST
diff --git a/car/app/app/api/restricted_current.txt b/car/app/app/api/restricted_current.txt
index 79be8d8..d578a0c 100644
--- a/car/app/app/api/restricted_current.txt
+++ b/car/app/app/api/restricted_current.txt
@@ -921,6 +921,7 @@
     field public static final String KEY_EXCLUDE_MEDIA_ITEM_FROM_MIXED_APP_LIST = "androidx.car.app.mediaextensions.KEY_EXCLUDE_MEDIA_ITEM_FROM_MIXED_APP_LIST";
     field public static final String KEY_IMMERSIVE_AUDIO = "androidx.car.app.mediaextensions.KEY_IMMERSIVE_AUDIO";
     field public static final String KEY_SUBTITLE_LINK_MEDIA_ID = "androidx.car.app.mediaextensions.KEY_SUBTITLE_LINK_MEDIA_ID";
+    field public static final String KEY_TINTABLE_INDICATOR_ICON_URI_LIST = "androidx.car.app.mediaextensions.KEY_TINTABLE_INDICATOR_ICON_URI_LIST";
   }
 
 }
diff --git a/car/app/app/src/main/java/androidx/car/app/mediaextensions/MetadataExtras.java b/car/app/app/src/main/java/androidx/car/app/mediaextensions/MetadataExtras.java
index 0cb3fc4..0d5166b 100644
--- a/car/app/app/src/main/java/androidx/car/app/mediaextensions/MetadataExtras.java
+++ b/car/app/app/src/main/java/androidx/car/app/mediaextensions/MetadataExtras.java
@@ -137,4 +137,22 @@
      */
     public static final String KEY_CONTENT_FORMAT_TINTABLE_SMALL_ICON_URI =
             "androidx.car.app.mediaextensions.KEY_CONTENT_FORMAT_TINTABLE_SMALL_ICON_URI";
+
+    /**
+     * {@link Bundle} key used in the extras of a media item to indicate a list of tintable vector
+     * drawables where each drawable represents a property or a state of the media item.
+     * These drawables may be rendered in small views showing information about a media item,
+     * in an area roughly equivalent to 2 characters of the media item's subtitle.<br/>
+     *
+     * <b>Note:</b> when specifying this extra, the "android.media.extra.DOWNLOAD_STATUS" and
+     * "android.media.IS_EXPLICIT" extras will be ignored, so 3p apps should add their own
+     * downloaded and explicit icon uris to this extra's list. This way all these icons can use
+     * the same drawing style.
+     *
+     * <p>TYPE: {@code ArrayList<Uri>}, list of Uris - with each uri pointing to local content
+     * using either ContentResolver.SCHEME_CONTENT or ContentResolver.SCHEME_ANDROID_RESOURCE
+     * (ie not on the web) that can be parsed into a android.graphics.drawable.Drawable</p>
+     */
+    public static final String KEY_TINTABLE_INDICATOR_ICON_URI_LIST =
+            "androidx.car.app.mediaextensions.KEY_TINTABLE_INDICATOR_ICON_URI_LIST";
 }
diff --git a/compose/animation/animation/src/androidInstrumentedTest/kotlin/androidx/compose/animation/SharedTransitionTest.kt b/compose/animation/animation/src/androidInstrumentedTest/kotlin/androidx/compose/animation/SharedTransitionTest.kt
index f4967da..c02221fe 100644
--- a/compose/animation/animation/src/androidInstrumentedTest/kotlin/androidx/compose/animation/SharedTransitionTest.kt
+++ b/compose/animation/animation/src/androidInstrumentedTest/kotlin/androidx/compose/animation/SharedTransitionTest.kt
@@ -77,6 +77,7 @@
 import androidx.compose.ui.graphics.compositeOver
 import androidx.compose.ui.layout.ContentScale
 import androidx.compose.ui.layout.ScaleFactor
+import androidx.compose.ui.layout.SubcomposeLayout
 import androidx.compose.ui.layout.approachLayout
 import androidx.compose.ui.layout.onGloballyPositioned
 import androidx.compose.ui.layout.onPlaced
@@ -116,6 +117,7 @@
 @LargeTest
 class SharedTransitionTest {
     val rule = createComposeRule()
+
     // Detect leaks BEFORE and AFTER compose rule work
     @get:Rule
     val ruleChain: RuleChain = RuleChain.outerRule(DetectLeaksAfterTestSuccess()).around(rule)
@@ -2862,6 +2864,38 @@
         // Transition into a Red box
         clickAndAssertColorDuringTransition(Color.Red)
     }
+
+    @Test
+    fun foundMatchedElementButNeverMeasured() {
+        var target by mutableStateOf(true)
+        rule.setContent {
+            SharedTransitionLayout {
+                AnimatedContent(target) {
+                    SubcomposeLayout {
+                        subcompose(0) {
+                            Box(
+                                Modifier.sharedBounds(
+                                        rememberSharedContentState("test"),
+                                        animatedVisibilityScope = this@AnimatedContent
+                                    )
+                                    .size(200.dp)
+                                    .background(Color.Red)
+                            )
+                        }
+                        // Skip measure and return size
+                        layout(200, 200) {}
+                    }
+                    Box(Modifier.size(200.dp).background(Color.Black))
+                }
+            }
+        }
+
+        rule.waitForIdle()
+        rule.mainClock.autoAdvance = false
+        target = !target
+        rule.mainClock.advanceTimeByFrame()
+        rule.waitForIdle()
+    }
 }
 
 private fun assertEquals(a: IntSize, b: IntSize, delta: IntSize) {
diff --git a/compose/animation/animation/src/commonMain/kotlin/androidx/compose/animation/SharedContentNode.kt b/compose/animation/animation/src/commonMain/kotlin/androidx/compose/animation/SharedContentNode.kt
index 8d6b5e6..8022fd5 100644
--- a/compose/animation/animation/src/commonMain/kotlin/androidx/compose/animation/SharedContentNode.kt
+++ b/compose/animation/animation/src/commonMain/kotlin/androidx/compose/animation/SharedContentNode.kt
@@ -160,7 +160,7 @@
         }
     }
 
-    private fun MeasureScope.place(placeable: Placeable): MeasureResult {
+    private fun MeasureScope.approachPlace(placeable: Placeable): MeasureResult {
         if (!sharedElement.foundMatch) {
             // No match
             return layout(placeable.width, placeable.height) {
@@ -231,7 +231,7 @@
                 } ?: constraints
             }
         val placeable = measurable.measure(resolvedConstraints)
-        return place(placeable)
+        return approachPlace(placeable)
     }
 
     private fun LayoutCoordinates.updateCurrentBounds() {
@@ -243,6 +243,7 @@
     }
 
     override fun ContentDrawScope.draw() {
+        state.firstFrameDrawn = true
         // Update clipPath
         state.clipPathInOverlay =
             state.overlayClip.getClipPath(
diff --git a/compose/animation/animation/src/commonMain/kotlin/androidx/compose/animation/SharedElement.kt b/compose/animation/animation/src/commonMain/kotlin/androidx/compose/animation/SharedElement.kt
index afe96a9..36de972 100644
--- a/compose/animation/animation/src/commonMain/kotlin/androidx/compose/animation/SharedElement.kt
+++ b/compose/animation/animation/src/commonMain/kotlin/androidx/compose/animation/SharedElement.kt
@@ -170,6 +170,7 @@
     zIndex: Float
 ) : LayerRenderer, RememberObserver {
 
+    internal var firstFrameDrawn: Boolean = false
     override var zIndex: Float by mutableFloatStateOf(zIndex)
 
     var renderInOverlayDuringTransition: Boolean by mutableStateOf(renderInOverlayDuringTransition)
@@ -184,7 +185,10 @@
 
     override fun drawInOverlay(drawScope: DrawScope) {
         val layer = layer ?: return
-        if (shouldRenderInOverlay) {
+        // It is important to check that the first frame is drawn. In some cases shared content may
+        // be composed, but never measured, placed or drawn. In those cases, we will not have
+        // valid content to draw, therefore we need to skip drawing in overlay.
+        if (firstFrameDrawn && shouldRenderInOverlay) {
             with(drawScope) {
                 requireNotNull(sharedElement.currentBounds) { "Error: current bounds not set yet." }
                 val (x, y) = sharedElement.currentBounds?.topLeft!!
diff --git a/compose/integration-tests/macrobenchmark/src/main/java/androidx/compose/integration/macrobenchmark/SmallListStartupBenchmark.kt b/compose/integration-tests/macrobenchmark/src/main/java/androidx/compose/integration/macrobenchmark/SmallListStartupBenchmark.kt
index cc2bb08..2275c38 100644
--- a/compose/integration-tests/macrobenchmark/src/main/java/androidx/compose/integration/macrobenchmark/SmallListStartupBenchmark.kt
+++ b/compose/integration-tests/macrobenchmark/src/main/java/androidx/compose/integration/macrobenchmark/SmallListStartupBenchmark.kt
@@ -36,19 +36,12 @@
 ) {
     @get:Rule val benchmarkRule = MacrobenchmarkRule()
 
-    /**
-     * Temporary, tracking for b/231455742
-     *
-     * Note that this tracing only exists on more recent API levels
-     */
-    private val metrics = getStartupMetrics()
-
     @Test
     fun startup() =
         benchmarkRule.measureStartup(
             compilationMode = compilationMode,
             startupMode = startupMode,
-            metrics = metrics,
+            metrics = getStartupMetrics(),
             packageName = "androidx.compose.integration.macrobenchmark.target"
         ) {
             action = "androidx.compose.integration.macrobenchmark.target.LAZY_COLUMN_ACTIVITY"
diff --git a/compose/lint/common-test/build.gradle b/compose/lint/common-test/build.gradle
index 1b5afba..ee51a95 100644
--- a/compose/lint/common-test/build.gradle
+++ b/compose/lint/common-test/build.gradle
@@ -30,8 +30,8 @@
 
 dependencies {
     implementation(libs.kotlinStdlib)
-    api(libs.androidLintPrev)
-    api(libs.androidLintPrevTests)
+    api(libs.androidLintPrevAnalysis)
+    api(libs.androidLintTestsPrevAnalysis)
     api(libs.junit)
     api(libs.truth)
 }
diff --git a/compose/material3/adaptive/adaptive-layout/api/current.txt b/compose/material3/adaptive/adaptive-layout/api/current.txt
index fbb8549..22f9744 100644
--- a/compose/material3/adaptive/adaptive-layout/api/current.txt
+++ b/compose/material3/adaptive/adaptive-layout/api/current.txt
@@ -314,6 +314,46 @@
     property public final androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole pane;
   }
 
+  @SuppressCompatibility @androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi @androidx.compose.runtime.Immutable public final class ThreePaneScaffoldHorizontalOrder {
+    method public void forEach(kotlin.jvm.functions.Function1<? super androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole,kotlin.Unit> action);
+    method public void forEachIndexed(kotlin.jvm.functions.Function2<? super java.lang.Integer,? super androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole,kotlin.Unit> action);
+    method public void forEachIndexedReversed(kotlin.jvm.functions.Function2<? super java.lang.Integer,? super androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole,kotlin.Unit> action);
+    method public operator androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole get(int index);
+    method public int getSize();
+    method public int indexOf(androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole role);
+    property public int size;
+  }
+
+  public final class ThreePaneScaffoldKt {
+    method @SuppressCompatibility @androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi public static androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.material3.adaptive.layout.ThreePaneScaffoldOverride> getLocalThreePaneScaffoldOverride();
+    property @SuppressCompatibility @androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi public static final androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.material3.adaptive.layout.ThreePaneScaffoldOverride> LocalThreePaneScaffoldOverride;
+  }
+
+  @SuppressCompatibility @androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi public interface ThreePaneScaffoldOverride {
+    method @androidx.compose.runtime.Composable public void ThreePaneScaffold(androidx.compose.material3.adaptive.layout.ThreePaneScaffoldOverrideContext);
+  }
+
+  @SuppressCompatibility @androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi public final class ThreePaneScaffoldOverrideContext {
+    method public androidx.compose.ui.Modifier getModifier();
+    method public kotlin.jvm.functions.Function1<androidx.compose.material3.adaptive.layout.PaneExpansionState,kotlin.Unit>? getPaneExpansionDragHandle();
+    method public androidx.compose.material3.adaptive.layout.PaneExpansionState getPaneExpansionState();
+    method public androidx.compose.material3.adaptive.layout.ThreePaneScaffoldHorizontalOrder getPaneOrder();
+    method public kotlin.jvm.functions.Function0<kotlin.Unit> getPrimaryPane();
+    method public androidx.compose.material3.adaptive.layout.PaneScaffoldDirective getScaffoldDirective();
+    method public androidx.compose.material3.adaptive.layout.ThreePaneScaffoldState getScaffoldState();
+    method public kotlin.jvm.functions.Function0<kotlin.Unit> getSecondaryPane();
+    method public kotlin.jvm.functions.Function0<kotlin.Unit>? getTertiaryPane();
+    property public final androidx.compose.ui.Modifier modifier;
+    property public final kotlin.jvm.functions.Function1<androidx.compose.material3.adaptive.layout.PaneExpansionState,kotlin.Unit>? paneExpansionDragHandle;
+    property public final androidx.compose.material3.adaptive.layout.PaneExpansionState paneExpansionState;
+    property public final androidx.compose.material3.adaptive.layout.ThreePaneScaffoldHorizontalOrder paneOrder;
+    property public final kotlin.jvm.functions.Function0<kotlin.Unit> primaryPane;
+    property public final androidx.compose.material3.adaptive.layout.PaneScaffoldDirective scaffoldDirective;
+    property public final androidx.compose.material3.adaptive.layout.ThreePaneScaffoldState scaffoldState;
+    property public final kotlin.jvm.functions.Function0<kotlin.Unit> secondaryPane;
+    property public final kotlin.jvm.functions.Function0<kotlin.Unit>? tertiaryPane;
+  }
+
   @SuppressCompatibility @androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi public sealed interface ThreePaneScaffoldPaneScope extends androidx.compose.material3.adaptive.layout.ThreePaneScaffoldScope androidx.compose.material3.adaptive.layout.ExtendedPaneScaffoldPaneScope<androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole,androidx.compose.material3.adaptive.layout.ThreePaneScaffoldValue> {
   }
 
diff --git a/compose/material3/adaptive/adaptive-layout/api/restricted_current.txt b/compose/material3/adaptive/adaptive-layout/api/restricted_current.txt
index fbb8549..22f9744 100644
--- a/compose/material3/adaptive/adaptive-layout/api/restricted_current.txt
+++ b/compose/material3/adaptive/adaptive-layout/api/restricted_current.txt
@@ -314,6 +314,46 @@
     property public final androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole pane;
   }
 
+  @SuppressCompatibility @androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi @androidx.compose.runtime.Immutable public final class ThreePaneScaffoldHorizontalOrder {
+    method public void forEach(kotlin.jvm.functions.Function1<? super androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole,kotlin.Unit> action);
+    method public void forEachIndexed(kotlin.jvm.functions.Function2<? super java.lang.Integer,? super androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole,kotlin.Unit> action);
+    method public void forEachIndexedReversed(kotlin.jvm.functions.Function2<? super java.lang.Integer,? super androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole,kotlin.Unit> action);
+    method public operator androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole get(int index);
+    method public int getSize();
+    method public int indexOf(androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole role);
+    property public int size;
+  }
+
+  public final class ThreePaneScaffoldKt {
+    method @SuppressCompatibility @androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi public static androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.material3.adaptive.layout.ThreePaneScaffoldOverride> getLocalThreePaneScaffoldOverride();
+    property @SuppressCompatibility @androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi public static final androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.material3.adaptive.layout.ThreePaneScaffoldOverride> LocalThreePaneScaffoldOverride;
+  }
+
+  @SuppressCompatibility @androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi public interface ThreePaneScaffoldOverride {
+    method @androidx.compose.runtime.Composable public void ThreePaneScaffold(androidx.compose.material3.adaptive.layout.ThreePaneScaffoldOverrideContext);
+  }
+
+  @SuppressCompatibility @androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi public final class ThreePaneScaffoldOverrideContext {
+    method public androidx.compose.ui.Modifier getModifier();
+    method public kotlin.jvm.functions.Function1<androidx.compose.material3.adaptive.layout.PaneExpansionState,kotlin.Unit>? getPaneExpansionDragHandle();
+    method public androidx.compose.material3.adaptive.layout.PaneExpansionState getPaneExpansionState();
+    method public androidx.compose.material3.adaptive.layout.ThreePaneScaffoldHorizontalOrder getPaneOrder();
+    method public kotlin.jvm.functions.Function0<kotlin.Unit> getPrimaryPane();
+    method public androidx.compose.material3.adaptive.layout.PaneScaffoldDirective getScaffoldDirective();
+    method public androidx.compose.material3.adaptive.layout.ThreePaneScaffoldState getScaffoldState();
+    method public kotlin.jvm.functions.Function0<kotlin.Unit> getSecondaryPane();
+    method public kotlin.jvm.functions.Function0<kotlin.Unit>? getTertiaryPane();
+    property public final androidx.compose.ui.Modifier modifier;
+    property public final kotlin.jvm.functions.Function1<androidx.compose.material3.adaptive.layout.PaneExpansionState,kotlin.Unit>? paneExpansionDragHandle;
+    property public final androidx.compose.material3.adaptive.layout.PaneExpansionState paneExpansionState;
+    property public final androidx.compose.material3.adaptive.layout.ThreePaneScaffoldHorizontalOrder paneOrder;
+    property public final kotlin.jvm.functions.Function0<kotlin.Unit> primaryPane;
+    property public final androidx.compose.material3.adaptive.layout.PaneScaffoldDirective scaffoldDirective;
+    property public final androidx.compose.material3.adaptive.layout.ThreePaneScaffoldState scaffoldState;
+    property public final kotlin.jvm.functions.Function0<kotlin.Unit> secondaryPane;
+    property public final kotlin.jvm.functions.Function0<kotlin.Unit>? tertiaryPane;
+  }
+
   @SuppressCompatibility @androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi public sealed interface ThreePaneScaffoldPaneScope extends androidx.compose.material3.adaptive.layout.ThreePaneScaffoldScope androidx.compose.material3.adaptive.layout.ExtendedPaneScaffoldPaneScope<androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole,androidx.compose.material3.adaptive.layout.ThreePaneScaffoldValue> {
   }
 
diff --git a/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/ThreePaneScaffold.kt b/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/ThreePaneScaffold.kt
index 2da0e85..df52d0c 100644
--- a/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/ThreePaneScaffold.kt
+++ b/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/ThreePaneScaffold.kt
@@ -19,6 +19,8 @@
 import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.ProvidableCompositionLocal
+import androidx.compose.runtime.compositionLocalOf
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
@@ -46,6 +48,54 @@
 import kotlin.math.max
 import kotlin.math.min
 
+/** Interface that allows libraries to override the behavior of [ThreePaneScaffold]. */
+@ExperimentalMaterial3AdaptiveApi
+interface ThreePaneScaffoldOverride {
+    /** Behavior function that is called by the [ThreePaneScaffold] composable. */
+    @Composable fun ThreePaneScaffoldOverrideContext.ThreePaneScaffold()
+}
+
+/**
+ * Parameters available to [ThreePaneScaffold].
+ *
+ * @property modifier The modifier to be applied to the layout.
+ * @property scaffoldDirective The top-level directives about how the scaffold should arrange its
+ *   panes.
+ * @property scaffoldState The current state of the scaffold, containing information about the
+ *   adapted value of each pane of the scaffold and the transitions/animations in progress.
+ * @property paneOrder The horizontal order of the panes from start to end in the scaffold.
+ * @property secondaryPane The content of the secondary pane that has a priority lower then the
+ *   primary pane but higher than the tertiary pane.
+ * @property tertiaryPane The content of the tertiary pane that has the lowest priority.
+ * @property primaryPane The content of the primary pane that has the highest priority.
+ * @property paneExpansionDragHandle the pane expansion drag handle to allow users to drag to change
+ *   pane expansion state, `null` by default.
+ * @property paneExpansionState the state object of pane expansion state.
+ */
+@ExperimentalMaterial3AdaptiveApi
+class ThreePaneScaffoldOverrideContext
+internal constructor(
+    val modifier: Modifier,
+    val scaffoldDirective: PaneScaffoldDirective,
+    val scaffoldState: ThreePaneScaffoldState,
+    val paneOrder: ThreePaneScaffoldHorizontalOrder,
+    val primaryPane: @Composable () -> Unit,
+    val secondaryPane: @Composable () -> Unit,
+    val tertiaryPane: (@Composable () -> Unit)?,
+    val paneExpansionState: PaneExpansionState,
+    val paneExpansionDragHandle: (@Composable (PaneExpansionState) -> Unit)?,
+    internal val motionScopeImpl: ThreePaneScaffoldMotionScopeImpl
+)
+
+/** CompositionLocal containing the currently-selected [ThreePaneScaffoldOverride]. */
+@Suppress("OPT_IN_MARKER_ON_WRONG_TARGET")
+@get:ExperimentalMaterial3AdaptiveApi
+@ExperimentalMaterial3AdaptiveApi
+val LocalThreePaneScaffoldOverride: ProvidableCompositionLocal<ThreePaneScaffoldOverride> =
+    compositionLocalOf {
+        DefaultThreePaneScaffoldOverride
+    }
+
 /**
  * A pane scaffold composable that can display up to three panes according to the instructions
  * provided by [ThreePaneScaffoldValue] in the order that [ThreePaneScaffoldHorizontalOrder]
@@ -139,47 +189,69 @@
             remember(currentTransition, this) {
                 ThreePaneScaffoldScopeImpl(motionScope, transitionScope, this)
             }
-        // Create PaneWrappers for each of the panes and map the transitions according to each pane
-        // role and order.
+        with(LocalThreePaneScaffoldOverride.current) {
+            ThreePaneScaffoldOverrideContext(
+                    modifier = modifier,
+                    scaffoldDirective = scaffoldDirective,
+                    scaffoldState = scaffoldState,
+                    paneOrder = paneOrder,
+                    primaryPane = {
+                        rememberThreePaneScaffoldPaneScope(
+                                ThreePaneScaffoldRole.Primary,
+                                scaffoldScope,
+                                paneMotions[ThreePaneScaffoldRole.Primary]
+                            )
+                            .primaryPane()
+                    },
+                    secondaryPane = {
+                        rememberThreePaneScaffoldPaneScope(
+                                ThreePaneScaffoldRole.Secondary,
+                                scaffoldScope,
+                                paneMotions[ThreePaneScaffoldRole.Secondary]
+                            )
+                            .secondaryPane()
+                    },
+                    tertiaryPane =
+                        if (tertiaryPane == null) null
+                        else {
+                            {
+                                rememberThreePaneScaffoldPaneScope(
+                                        ThreePaneScaffoldRole.Tertiary,
+                                        scaffoldScope,
+                                        paneMotions[ThreePaneScaffoldRole.Tertiary]
+                                    )
+                                    .tertiaryPane()
+                            }
+                        },
+                    paneExpansionState = expansionState,
+                    paneExpansionDragHandle =
+                        if (paneExpansionDragHandle == null) null
+                        else {
+                            { paneExpansionState ->
+                                scaffoldScope.paneExpansionDragHandle(paneExpansionState)
+                            }
+                        },
+                    motionScopeImpl = motionScope
+                )
+                .ThreePaneScaffold()
+        }
+    }
+}
+
+/** [ThreePaneScaffoldOverride] used when no override is specified. */
+@ExperimentalMaterial3AdaptiveApi
+private object DefaultThreePaneScaffoldOverride : ThreePaneScaffoldOverride {
+    @Composable
+    override fun ThreePaneScaffoldOverrideContext.ThreePaneScaffold() {
+        val layoutDirection = LocalLayoutDirection.current
+        val ltrPaneOrder =
+            remember(paneOrder, layoutDirection) { paneOrder.toLtrOrder(layoutDirection) }
         val contents =
             listOf<@Composable () -> Unit>(
-                {
-                    remember(scaffoldScope) {
-                            ThreePaneScaffoldPaneScopeImpl(
-                                ThreePaneScaffoldRole.Primary,
-                                scaffoldScope
-                            )
-                        }
-                        .apply { updatePaneMotion(paneMotions) }
-                        .primaryPane()
-                },
-                {
-                    remember(scaffoldScope) {
-                            ThreePaneScaffoldPaneScopeImpl(
-                                ThreePaneScaffoldRole.Secondary,
-                                scaffoldScope
-                            )
-                        }
-                        .apply { updatePaneMotion(paneMotions) }
-                        .secondaryPane()
-                },
-                {
-                    if (tertiaryPane != null) {
-                        remember(scaffoldScope) {
-                                ThreePaneScaffoldPaneScopeImpl(
-                                    ThreePaneScaffoldRole.Tertiary,
-                                    scaffoldScope
-                                )
-                            }
-                            .apply { updatePaneMotion(paneMotions) }
-                            .tertiaryPane()
-                    }
-                },
-                {
-                    if (paneExpansionDragHandle != null) {
-                        scaffoldScope.paneExpansionDragHandle(expansionState)
-                    }
-                }
+                primaryPane,
+                secondaryPane,
+                tertiaryPane ?: {},
+                { paneExpansionDragHandle?.invoke(paneExpansionState) }
             )
 
         val measurePolicy =
@@ -187,9 +259,9 @@
                     ThreePaneContentMeasurePolicy(
                         scaffoldDirective,
                         scaffoldState.targetState,
-                        expansionState,
+                        paneExpansionState,
                         ltrPaneOrder,
-                        motionScope
+                        motionScopeImpl
                     )
                 }
                 .apply {
diff --git a/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/ThreePaneScaffoldHorizontalOrder.kt b/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/ThreePaneScaffoldHorizontalOrder.kt
index 0081f7b..f24f7fa 100644
--- a/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/ThreePaneScaffoldHorizontalOrder.kt
+++ b/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/ThreePaneScaffoldHorizontalOrder.kt
@@ -20,22 +20,14 @@
 import androidx.compose.runtime.Immutable
 import androidx.compose.ui.unit.LayoutDirection
 
-/**
- * Represents the horizontal order of panes in a [ThreePaneScaffold] from start to end. Note that
- * the values of [firstPane], [secondPane] and [thirdPane] have to be different, otherwise
- * [IllegalArgumentException] will be thrown.
- *
- * @param firstPane The first pane from the start of the [ThreePaneScaffold]
- * @param secondPane The second pane from the start of the [ThreePaneScaffold]
- * @param thirdPane The third pane from the start of the [ThreePaneScaffold]
- * @constructor create an instance of [ThreePaneScaffoldHorizontalOrder]
- */
+/** Represents the horizontal order of panes in a [ThreePaneScaffold] from start to end. */
 @ExperimentalMaterial3AdaptiveApi
 @Immutable
-internal class ThreePaneScaffoldHorizontalOrder(
-    val firstPane: ThreePaneScaffoldRole,
-    val secondPane: ThreePaneScaffoldRole,
-    val thirdPane: ThreePaneScaffoldRole
+class ThreePaneScaffoldHorizontalOrder
+internal constructor(
+    internal val firstPane: ThreePaneScaffoldRole,
+    internal val secondPane: ThreePaneScaffoldRole,
+    internal val thirdPane: ThreePaneScaffoldRole
 ) : PaneScaffoldHorizontalOrder<ThreePaneScaffoldRole> {
     init {
         require(firstPane != secondPane && secondPane != thirdPane && firstPane != thirdPane) {
diff --git a/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/ThreePaneScaffoldScope.kt b/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/ThreePaneScaffoldScope.kt
index 4ba5bd0..e3f7625 100644
--- a/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/ThreePaneScaffoldScope.kt
+++ b/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/ThreePaneScaffoldScope.kt
@@ -21,8 +21,10 @@
 import androidx.compose.foundation.gestures.draggable
 import androidx.compose.foundation.interaction.MutableInteractionSource
 import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
+import androidx.compose.runtime.Composable
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
 import androidx.compose.runtime.setValue
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.layout.LookaheadScope
@@ -73,16 +75,21 @@
 }
 
 @OptIn(ExperimentalMaterial3AdaptiveApi::class)
+@Composable
+internal fun rememberThreePaneScaffoldPaneScope(
+    paneRole: ThreePaneScaffoldRole,
+    scaffoldScope: ThreePaneScaffoldScope,
+    paneMotion: PaneMotion
+): ThreePaneScaffoldPaneScope =
+    remember(scaffoldScope) { ThreePaneScaffoldPaneScopeImpl(paneRole, scaffoldScope) }
+        .apply { this.paneMotion = paneMotion }
+
+@OptIn(ExperimentalMaterial3AdaptiveApi::class)
 internal class ThreePaneScaffoldPaneScopeImpl(
     override val paneRole: ThreePaneScaffoldRole,
     scaffoldScope: ThreePaneScaffoldScope,
 ) : ThreePaneScaffoldPaneScope, ThreePaneScaffoldScope by scaffoldScope {
     override var paneMotion: PaneMotion by mutableStateOf(PaneMotion.ExitToLeft)
-        private set
-
-    fun updatePaneMotion(paneMotions: ThreePaneMotion) {
-        paneMotion = paneMotions[paneRole]
-    }
 }
 
 @OptIn(ExperimentalMaterial3AdaptiveApi::class)
diff --git a/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/TimePickerTest.kt b/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/TimePickerTest.kt
index 29da824..4fffa57 100644
--- a/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/TimePickerTest.kt
+++ b/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/TimePickerTest.kt
@@ -671,10 +671,15 @@
             rule
                 .onNodeWithTimeValue(number, TimePickerSelectionMode.Hour, is24Hour = true)
                 .performClick()
+
             rule.runOnIdle {
                 state.selection = TimePickerSelectionMode.Hour
                 assertThat(state.hour).isEqualTo(number)
             }
+
+            rule
+                .onNodeWithTimeValue(number, TimePickerSelectionMode.Hour, is24Hour = true)
+                .assertIsSelected()
         }
     }
 
@@ -701,6 +706,10 @@
                 state.selection = TimePickerSelectionMode.Hour
                 assertThat(state.hour).isEqualTo(number)
             }
+
+            rule
+                .onNodeWithTimeValue(hour, TimePickerSelectionMode.Hour, is24Hour = false)
+                .assertIsSelected()
         }
     }
 
diff --git a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/ModalBottomSheet.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/ModalBottomSheet.kt
index 995fe3f..a37aae1 100644
--- a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/ModalBottomSheet.kt
+++ b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/ModalBottomSheet.kt
@@ -277,62 +277,11 @@
 
     Surface(
         modifier =
-            modifier
+            Modifier.bottomSheetNestedScroll(sheetGesturesEnabled, sheetState, settleToDismiss)
+                .bottomSheetDraggableAnchors(sheetGesturesEnabled, sheetState, settleToDismiss)
                 .align(Alignment.TopCenter)
                 .widthIn(max = sheetMaxWidth)
                 .fillMaxWidth()
-                .then(
-                    if (sheetGesturesEnabled)
-                        Modifier.nestedScroll(
-                            remember(sheetState) {
-                                ConsumeSwipeWithinBottomSheetBoundsNestedScrollConnection(
-                                    sheetState = sheetState,
-                                    orientation = Orientation.Vertical,
-                                    onFling = settleToDismiss
-                                )
-                            }
-                        )
-                    else Modifier
-                )
-                .draggableAnchors(sheetState.anchoredDraggableState, Orientation.Vertical) {
-                    sheetSize,
-                    constraints ->
-                    val fullHeight = constraints.maxHeight.toFloat()
-                    val newAnchors = DraggableAnchors {
-                        Hidden at fullHeight
-                        if (
-                            sheetSize.height > (fullHeight / 2) && !sheetState.skipPartiallyExpanded
-                        ) {
-                            PartiallyExpanded at fullHeight / 2f
-                        }
-                        if (sheetSize.height != 0) {
-                            Expanded at max(0f, fullHeight - sheetSize.height)
-                        }
-                    }
-                    val newTarget =
-                        when (sheetState.anchoredDraggableState.targetValue) {
-                            Hidden -> Hidden
-                            PartiallyExpanded -> {
-                                val hasPartiallyExpandedState =
-                                    newAnchors.hasAnchorFor(PartiallyExpanded)
-                                val newTarget =
-                                    if (hasPartiallyExpandedState) PartiallyExpanded
-                                    else if (newAnchors.hasAnchorFor(Expanded)) Expanded else Hidden
-                                newTarget
-                            }
-                            Expanded -> {
-                                if (newAnchors.hasAnchorFor(Expanded)) Expanded else Hidden
-                            }
-                        }
-                    return@draggableAnchors newAnchors to newTarget
-                }
-                .draggable(
-                    state = sheetState.anchoredDraggableState.draggableState,
-                    orientation = Orientation.Vertical,
-                    enabled = sheetGesturesEnabled && sheetState.isVisible,
-                    startDragImmediately = sheetState.anchoredDraggableState.isAnimationRunning,
-                    onDragStopped = { settleToDismiss(it) }
-                )
                 .semantics {
                     paneTitle = bottomSheetPaneTitle
                     traversalIndex = 0f
@@ -352,7 +301,8 @@
                 // min anchor. This is done to avoid showing a gap when the sheet opens and bounces
                 // when it's applied with a bouncy motion. Note that the content inside the Surface
                 // is scaled back down to maintain its aspect ratio (see below).
-                .verticalScaleUp(sheetState),
+                .verticalScaleUp(sheetState)
+                .then(modifier),
         shape = shape,
         color = containerColor,
         contentColor = contentColor,
@@ -536,6 +486,80 @@
     }
 }
 
+/**
+ * Provides custom bottom sheet [nestedScroll] behavior between the sheet's [draggable] modifier and
+ * the sheets scrollable content.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun Modifier.bottomSheetNestedScroll(
+    sheetGesturesEnabled: Boolean,
+    sheetState: SheetState,
+    settleToDismiss: (velocity: Float) -> Unit,
+): Modifier {
+    return if (!sheetGesturesEnabled) {
+        this
+    } else {
+        this.nestedScroll(
+            remember(sheetState) {
+                ConsumeSwipeWithinBottomSheetBoundsNestedScrollConnection(
+                    sheetState = sheetState,
+                    orientation = Orientation.Vertical,
+                    onFling = settleToDismiss
+                )
+            }
+        )
+    }
+}
+
+/**
+ * Provides the bottom sheet's anchor points on the screen and [draggable] behavior between them.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun Modifier.bottomSheetDraggableAnchors(
+    sheetGesturesEnabled: Boolean,
+    sheetState: SheetState,
+    settleToDismiss: (velocity: Float) -> Unit
+): Modifier {
+    return this.draggableAnchors(sheetState.anchoredDraggableState, Orientation.Vertical) {
+            sheetSize,
+            constraints ->
+            val fullHeight = constraints.maxHeight.toFloat()
+            val newAnchors = DraggableAnchors {
+                Hidden at fullHeight
+                if (sheetSize.height > (fullHeight / 2) && !sheetState.skipPartiallyExpanded) {
+                    PartiallyExpanded at fullHeight / 2f
+                }
+                if (sheetSize.height != 0) {
+                    Expanded at max(0f, fullHeight - sheetSize.height)
+                }
+            }
+            val newTarget =
+                when (sheetState.anchoredDraggableState.targetValue) {
+                    Hidden -> Hidden
+                    PartiallyExpanded -> {
+                        val hasPartiallyExpandedState = newAnchors.hasAnchorFor(PartiallyExpanded)
+                        val newTarget =
+                            if (hasPartiallyExpandedState) PartiallyExpanded
+                            else if (newAnchors.hasAnchorFor(Expanded)) Expanded else Hidden
+                        newTarget
+                    }
+                    Expanded -> {
+                        if (newAnchors.hasAnchorFor(Expanded)) Expanded else Hidden
+                    }
+                }
+            return@draggableAnchors newAnchors to newTarget
+        }
+        .draggable(
+            state = sheetState.anchoredDraggableState.draggableState,
+            orientation = Orientation.Vertical,
+            enabled = sheetGesturesEnabled && sheetState.isVisible,
+            startDragImmediately = sheetState.anchoredDraggableState.isAnimationRunning,
+            onDragStopped = { settleToDismiss(it) }
+        )
+}
+
 @OptIn(ExperimentalMaterial3Api::class)
 @Composable
 internal expect fun ModalBottomSheetDialog(
diff --git a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/TimePicker.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/TimePicker.kt
index ca50be8..5226452 100644
--- a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/TimePicker.kt
+++ b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/TimePicker.kt
@@ -24,7 +24,7 @@
 import androidx.compose.animation.Crossfade
 import androidx.compose.animation.core.Animatable
 import androidx.compose.animation.core.AnimationSpec
-import androidx.compose.animation.core.FiniteAnimationSpec
+import androidx.compose.animation.core.SnapSpec
 import androidx.compose.foundation.BorderStroke
 import androidx.compose.foundation.MutatePriority
 import androidx.compose.foundation.MutatePriority.PreventUserInput
@@ -98,6 +98,7 @@
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.ReadOnlyComposable
 import androidx.compose.runtime.Stable
+import androidx.compose.runtime.derivedStateOf
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableIntStateOf
 import androidx.compose.runtime.mutableStateOf
@@ -113,6 +114,7 @@
 import androidx.compose.ui.focus.FocusRequester
 import androidx.compose.ui.focus.focusRequester
 import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Rect
 import androidx.compose.ui.geometry.center
 import androidx.compose.ui.graphics.BlendMode
 import androidx.compose.ui.graphics.Brush
@@ -162,6 +164,7 @@
 import androidx.compose.ui.text.style.LineHeightStyle
 import androidx.compose.ui.text.style.TextAlign
 import androidx.compose.ui.unit.Constraints
+import androidx.compose.ui.unit.Density
 import androidx.compose.ui.unit.Dp
 import androidx.compose.ui.unit.DpOffset
 import androidx.compose.ui.unit.IntOffset
@@ -1677,9 +1680,11 @@
     autoSwitchToMinute: Boolean
 ) {
     val style = ClockDialLabelTextFont.value
-    val maxDist = with(LocalDensity.current) { MaxDistance.toPx() }
+    val density: Density = LocalDensity.current
+    val maxDist = with(density) { MaxDistance.toPx() }
     var center by remember { mutableStateOf(Offset.Zero) }
     var parentCenter by remember { mutableStateOf(IntOffset.Zero) }
+    var boundsInParent by remember { mutableStateOf(Rect.Zero) }
     val scope = rememberCoroutineScope()
     val contentDescription =
         numberContentDescription(
@@ -1689,22 +1694,24 @@
         )
 
     val text = value.toLocalString()
-    val selected =
-        if (state.selection == TimePickerSelectionMode.Minute) {
-            state.minute.toLocalString() == text
-        } else {
-            state.hour.toLocalString() == text
+    val selected by
+        remember(state) {
+            derivedStateOf {
+                val selectorPos = state.selectorPos
+                val offset = with(density) { Offset(selectorPos.x.toPx(), selectorPos.y.toPx()) }
+                boundsInParent.contains(offset)
+            }
         }
 
     // TODO Load the motionScheme tokens from the component tokens file
-    val animationSpec: FiniteAnimationSpec<Float> = MotionSchemeKeyTokens.DefaultSpatial.value()
     Box(
         contentAlignment = Alignment.Center,
         modifier =
             modifier
                 .onGloballyPositioned {
                     parentCenter = it.parentCoordinates?.size?.center ?: IntOffset.Zero
-                    center = it.boundsInParent().center
+                    boundsInParent = it.boundsInParent()
+                    center = boundsInParent.center
                 }
                 .minimumInteractiveComponentSize()
                 .size(MinimumInteractiveSize)
@@ -1718,7 +1725,7 @@
                                 maxDist,
                                 autoSwitchToMinute,
                                 parentCenter,
-                                animationSpec
+                                SnapSpec()
                             )
                         }
                         true
diff --git a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/WavyProgressIndicator.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/WavyProgressIndicator.kt
index 3debdfc..31664b6 100644
--- a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/WavyProgressIndicator.kt
+++ b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/WavyProgressIndicator.kt
@@ -625,13 +625,14 @@
             }
         }
     }
+    val coercedAmplitude = amplitude.coerceIn(0f, 1f)
     PathProgressIndicator(
         modifier = modifier.size(WavyProgressIndicatorDefaults.CircularContainerSize),
         // Resolves the Path from a RoundedPolygon that represents the active indicator.
         progressPath = { _, progressWavelength, strokeWidth, size, supportMotion, path ->
             circularShapes.update(size, progressWavelength, strokeWidth)
             circularShapes.activeIndicatorMorph!!.toPath(
-                progress = amplitude,
+                progress = coercedAmplitude,
                 path = path,
                 repeatPath = supportMotion,
                 rotationPivotX = 0.5f,
@@ -647,7 +648,7 @@
         trackColor = trackColor,
         stroke = stroke,
         trackStroke = trackStroke,
-        amplitude = amplitude.coerceIn(0f, 1f),
+        amplitude = coercedAmplitude,
         waveOffset = { lastOffsetValue.floatValue },
         wavelength = wavelength,
         gapSize = gapSize,
@@ -733,7 +734,7 @@
 
                 // Animate changes in the amplitude. As this requires a progress value, we do it
                 // inside the Spacer to avoid redundant recompositions.
-                val amplitudeForProgress = amplitude(progressValue)
+                val amplitudeForProgress = amplitude(progressValue).coerceIn(0f, 1f)
                 val animatedAmplitude =
                     amplitudeAnimatable
                         ?: Animatable(amplitudeForProgress, Float.VectorConverter).also {
diff --git a/compose/runtime/runtime-lint/build.gradle b/compose/runtime/runtime-lint/build.gradle
index 6a73e68..71ad3f5 100644
--- a/compose/runtime/runtime-lint/build.gradle
+++ b/compose/runtime/runtime-lint/build.gradle
@@ -33,14 +33,14 @@
 BundleInsideHelper.forInsideLintJar(project)
 
 dependencies {
-    compileOnly(libs.androidLintPrevApi)
+    compileOnly(libs.androidLintApiPrevAnalysis)
     compileOnly(libs.kotlinStdlib)
     bundleInside(project(":compose:lint:common"))
 
     testImplementation(project(":compose:lint:common-test"))
     testImplementation(libs.kotlinStdlib)
-    testImplementation(libs.androidLintPrev)
-    testImplementation(libs.androidLintPrevTests)
+    testImplementation(libs.androidLintPrevAnalysis)
+    testImplementation(libs.androidLintTestsPrevAnalysis)
     testImplementation(libs.junit)
     testImplementation(libs.truth)
 }
diff --git a/compose/runtime/runtime/compose-runtime-benchmark/src/androidTest/java/androidx/compose/runtime/benchmark/SnapshotStateObserverBenchmark.kt b/compose/runtime/runtime/compose-runtime-benchmark/src/androidTest/java/androidx/compose/runtime/benchmark/SnapshotStateObserverBenchmark.kt
index e75b154..4b6d834 100644
--- a/compose/runtime/runtime/compose-runtime-benchmark/src/androidTest/java/androidx/compose/runtime/benchmark/SnapshotStateObserverBenchmark.kt
+++ b/compose/runtime/runtime/compose-runtime-benchmark/src/androidTest/java/androidx/compose/runtime/benchmark/SnapshotStateObserverBenchmark.kt
@@ -19,7 +19,7 @@
 import android.os.Build
 import android.os.Handler
 import android.os.Looper
-import androidx.benchmark.junit4.measureRepeated
+import androidx.benchmark.junit4.measureRepeatedOnMainThread
 import androidx.compose.runtime.MutableState
 import androidx.compose.runtime.derivedStateOf
 import androidx.compose.runtime.mutableStateOf
@@ -77,59 +77,51 @@
     @Test
     fun modelObservation() {
         assumeTrue(Build.VERSION.SDK_INT != 29)
-        runOnUiThread {
-            benchmarkRule.measureRepeated {
-                runWithTimingDisabled {
-                    nodes.forEach { node -> stateObserver.clear(node) }
-                    random = Random(0)
-                }
-                setupObservations()
+        benchmarkRule.measureRepeatedOnMainThread {
+            runWithTimingDisabled {
+                nodes.forEach { node -> stateObserver.clear(node) }
+                random = Random(0)
             }
+            setupObservations()
         }
     }
 
     @Test
     fun nestedModelObservation() {
         assumeTrue(Build.VERSION.SDK_INT != 29)
-        runOnUiThread {
-            val list = mutableListOf<Any>()
-            repeat(10) { list += nodes[random.nextInt(ScopeCount)] }
-            benchmarkRule.measureRepeated {
-                runWithTimingDisabled {
-                    random = Random(0)
-                    nodes.forEach { node -> stateObserver.clear(node) }
-                }
-                stateObserver.observeReads(nodes[0], doNothing) {
-                    list.forEach { node -> observeForNode(node) }
-                }
+        val list = mutableListOf<Any>()
+        repeat(10) { list += nodes[random.nextInt(ScopeCount)] }
+        benchmarkRule.measureRepeatedOnMainThread {
+            runWithTimingDisabled {
+                random = Random(0)
+                nodes.forEach { node -> stateObserver.clear(node) }
+            }
+            stateObserver.observeReads(nodes[0], doNothing) {
+                list.forEach { node -> observeForNode(node) }
             }
         }
     }
 
     @Test
     fun derivedStateObservation() {
+        val node = Any()
+        val states = models.take(3)
+        val derivedState = derivedStateOf { states[0].value + states[1].value + states[2].value }
         runOnUiThread {
-            val node = Any()
-            val states = models.take(3)
-            val derivedState = derivedStateOf {
-                states[0].value + states[1].value + states[2].value
+            stateObserver.observeReads(node, doNothing) {
+                // read derived state a few times
+                repeat(10) { derivedState.value }
             }
-
+        }
+        benchmarkRule.measureRepeatedOnMainThread {
             stateObserver.observeReads(node, doNothing) {
                 // read derived state a few times
                 repeat(10) { derivedState.value }
             }
 
-            benchmarkRule.measureRepeated {
-                stateObserver.observeReads(node, doNothing) {
-                    // read derived state a few times
-                    repeat(10) { derivedState.value }
-                }
-
-                runWithTimingDisabled {
-                    states.forEach { it.value += 1 }
-                    Snapshot.sendApplyNotifications()
-                }
+            runWithTimingDisabled {
+                states.forEach { it.value += 1 }
+                Snapshot.sendApplyNotifications()
             }
         }
     }
@@ -137,71 +129,60 @@
     @Test
     fun deeplyNestedModelObservations() {
         assumeTrue(Build.VERSION.SDK_INT != 29)
-        runOnUiThread {
-            val list = mutableListOf<Any>()
-            repeat(100) { list += nodes[random.nextInt(ScopeCount)] }
+        val list = mutableListOf<Any>()
+        repeat(100) { list += nodes[random.nextInt(ScopeCount)] }
 
-            fun observeRecursive(index: Int) {
-                if (index == 100) return
-                val node = list[index]
-                stateObserver.observeReads(node, doNothing) {
-                    observeForNode(node)
-                    observeRecursive(index + 1)
-                }
+        fun observeRecursive(index: Int) {
+            if (index == 100) return
+            val node = list[index]
+            stateObserver.observeReads(node, doNothing) {
+                observeForNode(node)
+                observeRecursive(index + 1)
             }
+        }
 
-            benchmarkRule.measureRepeated {
-                runWithTimingDisabled {
-                    random = Random(0)
-                    nodes.forEach { node -> stateObserver.clear(node) }
-                }
-                observeRecursive(0)
+        benchmarkRule.measureRepeatedOnMainThread {
+            runWithTimingDisabled {
+                random = Random(0)
+                nodes.forEach { node -> stateObserver.clear(node) }
             }
+            observeRecursive(0)
         }
     }
 
     @Test
     fun modelClear() {
         assumeTrue(Build.VERSION.SDK_INT != 29)
-        runOnUiThread {
-            val nodeSet = hashSetOf<Any>()
-            nodeSet.addAll(nodes)
-
-            benchmarkRule.measureRepeated {
-                stateObserver.clearIf { node -> node in nodeSet }
-                random = Random(0)
-                runWithTimingDisabled { setupObservations() }
-            }
+        val nodeSet = hashSetOf<Any>()
+        nodeSet.addAll(nodes)
+        benchmarkRule.measureRepeatedOnMainThread {
+            stateObserver.clearIf { node -> node in nodeSet }
+            random = Random(0)
+            runWithTimingDisabled { setupObservations() }
         }
     }
 
     @Test
     fun modelIncrementalClear() {
         assumeTrue(Build.VERSION.SDK_INT != 29)
-        runOnUiThread {
-            benchmarkRule.measureRepeated {
-                for (i in 0 until nodes.size) {
-                    stateObserver.clearIf { node -> (node as Int) < i }
-                }
-                runWithTimingDisabled { setupObservations() }
-            }
+        benchmarkRule.measureRepeatedOnMainThread {
+            repeat(nodes.size) { i -> stateObserver.clearIf { node -> (node as Int) < i } }
+            runWithTimingDisabled { setupObservations() }
         }
     }
 
     @Test
     fun notifyChanges() {
         assumeTrue(Build.VERSION.SDK_INT != 29)
-        runOnUiThread {
-            val states = mutableSetOf<Int>()
-            repeat(50) { states += random.nextInt(StateCount) }
-            val snapshot: Snapshot = Snapshot.current
-            benchmarkRule.measureRepeated {
-                random = Random(0)
-                stateObserver.notifyChanges(states, snapshot)
-                runWithTimingDisabled {
-                    stateObserver.clear()
-                    setupObservations()
-                }
+        val states = mutableSetOf<Int>()
+        repeat(50) { states += random.nextInt(StateCount) }
+        val snapshot: Snapshot = Snapshot.current
+        benchmarkRule.measureRepeatedOnMainThread {
+            random = Random(0)
+            stateObserver.notifyChanges(states, snapshot)
+            runWithTimingDisabled {
+                stateObserver.clear()
+                setupObservations()
             }
         }
     }
diff --git a/compose/runtime/runtime/integration-tests/build.gradle b/compose/runtime/runtime/integration-tests/build.gradle
index 791773c..b611fc7 100644
--- a/compose/runtime/runtime/integration-tests/build.gradle
+++ b/compose/runtime/runtime/integration-tests/build.gradle
@@ -110,8 +110,6 @@
         incremental = false
         freeCompilerArgs += [
             "-Xcontext-receivers",
-            "-P",
-            "plugin:androidx.compose.compiler.plugins.kotlin:sourceInformation=true"
         ]
     }
 }
diff --git a/compose/ui/ui-inspection/build.gradle b/compose/ui/ui-inspection/build.gradle
index cfbe90d..3423082 100644
--- a/compose/ui/ui-inspection/build.gradle
+++ b/compose/ui/ui-inspection/build.gradle
@@ -101,14 +101,6 @@
     namespace "androidx.compose.ui.inspection"
 }
 
-tasks.withType(KotlinCompile).configureEach {
-    kotlinOptions {
-        freeCompilerArgs += [
-                "-P", "plugin:androidx.compose.compiler.plugins.kotlin:sourceInformation=true"
-        ]
-    }
-}
-
 inspection {
     name = "compose-ui-inspection.jar"
 }
diff --git a/compose/ui/ui-inspection/src/main/java/androidx/compose/ui/inspection/AlternateViewHelper.kt b/compose/ui/ui-inspection/src/main/java/androidx/compose/ui/inspection/AlternateViewHelper.kt
index a902d36..86d3154 100644
--- a/compose/ui/ui-inspection/src/main/java/androidx/compose/ui/inspection/AlternateViewHelper.kt
+++ b/compose/ui/ui-inspection/src/main/java/androidx/compose/ui/inspection/AlternateViewHelper.kt
@@ -29,10 +29,13 @@
 private const val PANEL_ENTITY_CLASS = "com.google.vr.androidx.xr.core.PanelEntity"
 private const val PANEL_ENTITY_IMPL_CLASS =
     "com.google.vr.realitycore.runtime.androidxr.PanelEntityImpl"
+private const val MAIN_PANEL_ENTITY_CLASS =
+    "com.google.vr.realitycore.runtime.androidxr.MainPanelEntityImpl"
 private const val JXR_CORE_SESSION_CLASS = "com.google.vr.androidx.xr.core.Session"
 
 private const val PANEL_ENTITY_CLASS_ANDROIDX = "androidx.xr.scenecore.PanelEntity"
 private const val PANEL_ENTITY_IMPL_CLASS_ANDROIDX = "androidx.xr.scenecore.PanelEntityImpl"
+private const val MAIN_PANEL_ENTITY_CLASS_ANDROIDX = "androidx.xr.scenecore.MainPanelEntityImpl"
 private const val JXR_CORE_SESSION_CLASS_ANDROIDX = "androidx.xr.scenecore.Session"
 
 private const val GET_ENTITIES_OF_TYPE_METHOD = "getEntitiesOfType"
@@ -40,6 +43,7 @@
 
 private const val SURFACE_CONTROL_VIEW_HOST_FIELD = "surfaceControlViewHost"
 private const val RT_PANEL_ENTITY_FIELD = "rtPanelEntity"
+private const val RUNTIME_ACTIVITY_FIELD = "runtimeActivity"
 
 class AlternateViewHelper(private val environment: InspectorEnvironment) {
     private val activity = environment.artTooling().findInstances(Activity::class.java).first()
@@ -77,7 +81,7 @@
         return entity
             .mapAllFields { field ->
                 if (field.name == RT_PANEL_ENTITY_FIELD) {
-                    getRuntimeEntityView(field.get(entity)!!)
+                    getEntityView(field.get(entity)!!)
                 } else {
                     null
                 }
@@ -103,12 +107,22 @@
     }
 
     @RequiresApi(Build.VERSION_CODES.R)
+    private fun getEntityView(instance: Any): View? {
+        val clazz = instance.javaClass
+        return when (clazz.name) {
+            PANEL_ENTITY_IMPL_CLASS,
+            PANEL_ENTITY_IMPL_CLASS_ANDROIDX -> getRuntimeEntityView(instance)
+            MAIN_PANEL_ENTITY_CLASS,
+            MAIN_PANEL_ENTITY_CLASS_ANDROIDX -> getMainPanelEntityImplView(instance)
+            else -> null
+        }
+    }
+
+    @RequiresApi(Build.VERSION_CODES.R)
     private fun getRuntimeEntityView(instance: Any): View? {
         val clazz = instance.javaClass
-        if (clazz.name !in listOf(PANEL_ENTITY_IMPL_CLASS, PANEL_ENTITY_IMPL_CLASS_ANDROIDX)) {
-            return null
-        }
-        val surfaceControlViewHostField = loadField(clazz, SURFACE_CONTROL_VIEW_HOST_FIELD)
+        val surfaceControlViewHostField =
+            runCatching { clazz.getDeclaredField(SURFACE_CONTROL_VIEW_HOST_FIELD) }.getOrNull()
         if (surfaceControlViewHostField != null) {
             surfaceControlViewHostField.isAccessible = true
             val surfaceControlViewHost =
@@ -119,6 +133,19 @@
         }
     }
 
+    private fun getMainPanelEntityImplView(instance: Any): View? {
+        val clazz = instance.javaClass
+        val runtimeActivityField =
+            runCatching { clazz.getDeclaredField(RUNTIME_ACTIVITY_FIELD) }.getOrNull()
+        return if (runtimeActivityField != null) {
+            runtimeActivityField.isAccessible = true
+            val runtimeActivityInstance = runtimeActivityField.get(instance) as Activity
+            runtimeActivityInstance.window.decorView
+        } else {
+            null
+        }
+    }
+
     @Suppress("UnnecessaryLambdaCreation")
     private fun <T> Any.mapAllFields(block: (filed: Field) -> T): List<T> {
         var clazz: Class<*>? = javaClass
diff --git a/compose/ui/ui-inspection/src/main/java/androidx/compose/ui/inspection/ComposeLayoutInspector.kt b/compose/ui/ui-inspection/src/main/java/androidx/compose/ui/inspection/ComposeLayoutInspector.kt
index 9364f6e..8f37b41 100644
--- a/compose/ui/ui-inspection/src/main/java/androidx/compose/ui/inspection/ComposeLayoutInspector.kt
+++ b/compose/ui/ui-inspection/src/main/java/androidx/compose/ui/inspection/ComposeLayoutInspector.kt
@@ -26,7 +26,6 @@
 import androidx.collection.mutableLongObjectMapOf
 import androidx.compose.ui.inspection.compose.AndroidComposeViewWrapper
 import androidx.compose.ui.inspection.compose.convertToParameterGroup
-import androidx.compose.ui.inspection.compose.flatten
 import androidx.compose.ui.inspection.framework.addSlotTable
 import androidx.compose.ui.inspection.framework.flatten
 import androidx.compose.ui.inspection.framework.hasSlotTable
@@ -516,7 +515,7 @@
     /** Add a slot table to all AndroidComposeViews that doesn't already have one. */
     private fun addSlotTableToComposeViews() =
         ThreadUtils.runOnMainThread {
-                val roots = rootsDetector.getRoots()
+                val roots = rootsDetector.getAllRoots()
                 val composeViews =
                     roots.flatMap { it.flatten() }.filter { it.isAndroidComposeView() }
 
diff --git a/compose/ui/ui-inspection/src/main/java/androidx/compose/ui/inspection/RootsDetector.kt b/compose/ui/ui-inspection/src/main/java/androidx/compose/ui/inspection/RootsDetector.kt
index 5619d3c..a3ff163 100644
--- a/compose/ui/ui-inspection/src/main/java/androidx/compose/ui/inspection/RootsDetector.kt
+++ b/compose/ui/ui-inspection/src/main/java/androidx/compose/ui/inspection/RootsDetector.kt
@@ -29,6 +29,10 @@
         return alternateViews.ifEmpty { getAndroidViews() }
     }
 
+    fun getAllRoots(): List<View> {
+        return alternateViewHelper.getAlternateViews() + getAndroidViews()
+    }
+
     private fun getAndroidViews(): List<View> {
         ThreadUtils.assertOnMainThread()
         val views = WindowInspector.getGlobalWindowViews()
diff --git a/compose/ui/ui-lint/build.gradle b/compose/ui/ui-lint/build.gradle
index a33faf7..ed5073c 100644
--- a/compose/ui/ui-lint/build.gradle
+++ b/compose/ui/ui-lint/build.gradle
@@ -33,15 +33,15 @@
 BundleInsideHelper.forInsideLintJar(project)
 
 dependencies {
-    compileOnly(libs.androidLintPrevApi)
+    compileOnly(libs.androidLintApiPrevAnalysis)
     compileOnly(libs.kotlinStdlib)
 
     bundleInside(project(":compose:lint:common"))
 
     testImplementation(project(":compose:lint:common-test"))
     testImplementation(libs.kotlinStdlib)
-    testImplementation(libs.androidLintPrev)
-    testImplementation(libs.androidLintPrevTests)
+    testImplementation(libs.androidLintPrevAnalysis)
+    testImplementation(libs.androidLintTestsPrevAnalysis)
     testImplementation(libs.junit)
     testImplementation(libs.truth)
 }
diff --git a/compose/ui/ui-test/src/androidMain/kotlin/androidx/compose/ui/test/AndroidInputDispatcher.android.kt b/compose/ui/ui-test/src/androidMain/kotlin/androidx/compose/ui/test/AndroidInputDispatcher.android.kt
index 1fd427d..1f618df 100644
--- a/compose/ui/ui-test/src/androidMain/kotlin/androidx/compose/ui/test/AndroidInputDispatcher.android.kt
+++ b/compose/ui/ui-test/src/androidMain/kotlin/androidx/compose/ui/test/AndroidInputDispatcher.android.kt
@@ -16,6 +16,8 @@
 
 package androidx.compose.ui.test
 
+import android.content.Context
+import android.hardware.input.InputManager
 import android.view.InputEvent
 import android.view.KeyCharacterMap
 import android.view.KeyEvent
@@ -477,7 +479,7 @@
                     /* buttonState = */ 0,
                     /* xPrecision = */ 1f,
                     /* yPrecision = */ 1f,
-                    /* deviceId = */ 0,
+                    /* deviceId = */ findInputDevice(root.view.context, SOURCE_ROTARY_ENCODER),
                     /* edgeFlags = */ 0,
                     /* source = */ SOURCE_ROTARY_ENCODER,
                     /* flags = */ 0
@@ -597,4 +599,19 @@
     private fun recycleEventIfPossible(event: InputEvent) {
         (event as? MotionEvent)?.recycle()
     }
+
+    private fun findInputDevice(context: Context, source: Int): Int {
+        with(context.getSystemService(Context.INPUT_SERVICE) as InputManager) {
+            inputDeviceIds.forEach { deviceId ->
+                getInputDevice(deviceId)?.apply {
+                    motionRanges
+                        .find { it.source == source }
+                        ?.let {
+                            return deviceId
+                        }
+                }
+            }
+        }
+        return 0
+    }
 }
diff --git a/compose/ui/ui/benchmark/src/androidTest/java/androidx/compose/ui/benchmark/LayoutNodeModifierBenchmark.kt b/compose/ui/ui/benchmark/src/androidTest/java/androidx/compose/ui/benchmark/LayoutNodeModifierBenchmark.kt
index 9f92539..e6e9eb3 100644
--- a/compose/ui/ui/benchmark/src/androidTest/java/androidx/compose/ui/benchmark/LayoutNodeModifierBenchmark.kt
+++ b/compose/ui/ui/benchmark/src/androidTest/java/androidx/compose/ui/benchmark/LayoutNodeModifierBenchmark.kt
@@ -21,7 +21,7 @@
 import androidx.activity.ComponentActivity
 import androidx.activity.compose.setContent
 import androidx.benchmark.junit4.BenchmarkRule
-import androidx.benchmark.junit4.measureRepeated
+import androidx.benchmark.junit4.measureRepeatedOnMainThread
 import androidx.compose.foundation.gestures.detectTapGestures
 import androidx.compose.foundation.layout.padding
 import androidx.compose.ui.Modifier
@@ -59,9 +59,9 @@
 
     @get:Rule val rule = SimpleAndroidBenchmarkRule()
 
-    var modifiers = emptyList<Modifier>()
-    var combinedModifier: Modifier = Modifier
-    lateinit var testModifierUpdater: TestModifierUpdater
+    private var modifiers = emptyList<Modifier>()
+    private var combinedModifier: Modifier = Modifier
+    private lateinit var testModifierUpdater: TestModifierUpdater
 
     @Before
     fun setup() {
@@ -92,24 +92,19 @@
 
     @Test
     fun setAndClearModifiers() {
-        rule.activityTestRule.runOnUiThread {
-            rule.benchmarkRule.measureRepeated {
-                testModifierUpdater.updateModifier(combinedModifier)
-                testModifierUpdater.updateModifier(Modifier)
-            }
+        rule.benchmarkRule.measureRepeatedOnMainThread {
+            testModifierUpdater.updateModifier(combinedModifier)
+            testModifierUpdater.updateModifier(Modifier)
         }
     }
 
     @Test
     fun smallModifierChange() {
-        rule.activityTestRule.runOnUiThread {
-            val altModifier = Modifier.padding(10.dp).then(combinedModifier)
+        val altModifier = Modifier.padding(10.dp).then(combinedModifier)
+        rule.activityTestRule.runOnUiThread { testModifierUpdater.updateModifier(altModifier) }
+        rule.benchmarkRule.measureRepeatedOnMainThread {
+            testModifierUpdater.updateModifier(combinedModifier)
             testModifierUpdater.updateModifier(altModifier)
-
-            rule.benchmarkRule.measureRepeated {
-                testModifierUpdater.updateModifier(combinedModifier)
-                testModifierUpdater.updateModifier(altModifier)
-            }
         }
     }
 
@@ -133,15 +128,13 @@
             }
         }
 
-        rule.activityTestRule.runOnUiThread {
-            rule.benchmarkRule.measureRepeated {
-                testModifierUpdater.updateModifier(combinedModifier)
-                testModifierUpdater.updateModifier(altModifier)
-            }
+        rule.benchmarkRule.measureRepeatedOnMainThread {
+            testModifierUpdater.updateModifier(combinedModifier)
+            testModifierUpdater.updateModifier(altModifier)
         }
     }
 
-    class SimpleAndroidBenchmarkRule() : TestRule {
+    class SimpleAndroidBenchmarkRule : TestRule {
         @Suppress("DEPRECATION")
         val activityTestRule = androidx.test.rule.ActivityTestRule(ComponentActivity::class.java)
 
diff --git a/compose/ui/ui/benchmark/src/androidTest/java/androidx/compose/ui/benchmark/LifecycleAwareWindowRecomposerBenchmark.kt b/compose/ui/ui/benchmark/src/androidTest/java/androidx/compose/ui/benchmark/LifecycleAwareWindowRecomposerBenchmark.kt
index 0da80e7..96bfb2b 100644
--- a/compose/ui/ui/benchmark/src/androidTest/java/androidx/compose/ui/benchmark/LifecycleAwareWindowRecomposerBenchmark.kt
+++ b/compose/ui/ui/benchmark/src/androidTest/java/androidx/compose/ui/benchmark/LifecycleAwareWindowRecomposerBenchmark.kt
@@ -20,11 +20,10 @@
 import android.view.ViewGroup
 import androidx.activity.ComponentActivity
 import androidx.benchmark.junit4.BenchmarkRule
-import androidx.benchmark.junit4.measureRepeated
+import androidx.benchmark.junit4.measureRepeatedOnMainThread
 import androidx.compose.ui.platform.createLifecycleAwareWindowRecomposer
 import androidx.lifecycle.Lifecycle
 import androidx.lifecycle.testing.TestLifecycleOwner
-import androidx.test.annotation.UiThreadTest
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.LargeTest
 import org.junit.Rule
@@ -42,21 +41,20 @@
     @get:Rule val rule = CombinedActivityBenchmarkRule()
 
     @Test
-    @UiThreadTest
     fun createRecomposer() {
-        val rootView = rule.activityTestRule.activity.window.decorView.rootView
+        var rootView: View? = null
+        rule.activityTestRule.runOnUiThread {
+            rootView = rule.activityTestRule.activity.window.decorView.rootView
+        }
         val lifecycleOwner = TestLifecycleOwner(Lifecycle.State.CREATED)
-        var view: View? = null
-        rule.benchmarkRule.measureRepeated {
+        rule.benchmarkRule.measureRepeatedOnMainThread {
+            var view: View? = null
             runWithTimingDisabled {
                 view = View(rule.activityTestRule.activity)
                 (rootView as ViewGroup).addView(view)
             }
             view!!.createLifecycleAwareWindowRecomposer(lifecycle = lifecycleOwner.lifecycle)
-            runWithTimingDisabled {
-                (rootView as ViewGroup).removeAllViews()
-                view = null
-            }
+            runWithTimingDisabled { (rootView as ViewGroup).removeAllViews() }
         }
     }
 
diff --git a/compose/ui/ui/benchmark/src/androidTest/java/androidx/compose/ui/benchmark/autofill/AndroidAutofillBenchmark.kt b/compose/ui/ui/benchmark/src/androidTest/java/androidx/compose/ui/benchmark/autofill/AndroidAutofillBenchmark.kt
index 964171d..465fc6b 100644
--- a/compose/ui/ui/benchmark/src/androidTest/java/androidx/compose/ui/benchmark/autofill/AndroidAutofillBenchmark.kt
+++ b/compose/ui/ui/benchmark/src/androidTest/java/androidx/compose/ui/benchmark/autofill/AndroidAutofillBenchmark.kt
@@ -20,7 +20,7 @@
 import android.view.View
 import android.view.autofill.AutofillValue
 import androidx.benchmark.junit4.BenchmarkRule
-import androidx.benchmark.junit4.measureRepeated
+import androidx.benchmark.junit4.measureRepeatedOnMainThread
 import androidx.compose.ui.autofill.AutofillNode
 import androidx.compose.ui.autofill.AutofillTree
 import androidx.compose.ui.autofill.AutofillType
@@ -28,7 +28,6 @@
 import androidx.compose.ui.platform.LocalAutofillTree
 import androidx.compose.ui.platform.LocalView
 import androidx.compose.ui.test.junit4.createComposeRule
-import androidx.test.annotation.UiThreadTest
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.LargeTest
 import androidx.test.filters.SdkSuppress
@@ -57,24 +56,26 @@
     }
 
     @Test
-    @UiThreadTest
     @SdkSuppress(minSdkVersion = 26)
     fun provideAutofillVirtualStructure_performAutofill() {
-
-        // Arrange.
-        val autofillNode =
-            AutofillNode(
-                onFill = {},
-                autofillTypes = listOf(AutofillType.PersonFullName),
-                boundingBox = Rect(0f, 0f, 0f, 0f)
-            )
         val autofillValues =
-            SparseArray<AutofillValue>().apply {
-                append(autofillNode.id, AutofillValue.forText("Name"))
+            composeTestRule.runOnUiThread {
+                // Arrange.
+                val autofillNode =
+                    AutofillNode(
+                        onFill = {},
+                        autofillTypes = listOf(AutofillType.PersonFullName),
+                        boundingBox = Rect(0f, 0f, 0f, 0f)
+                    )
+
+                autofillTree += autofillNode
+
+                SparseArray<AutofillValue>().apply {
+                    append(autofillNode.id, AutofillValue.forText("Name"))
+                }
             }
-        autofillTree += autofillNode
 
         // Assess.
-        benchmarkRule.measureRepeated { composeView.autofill(autofillValues) }
+        benchmarkRule.measureRepeatedOnMainThread { composeView.autofill(autofillValues) }
     }
 }
diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/focus/FocusManagerCompositionLocalTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/focus/FocusManagerCompositionLocalTest.kt
index 08b702e..3c2313f 100644
--- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/focus/FocusManagerCompositionLocalTest.kt
+++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/focus/FocusManagerCompositionLocalTest.kt
@@ -33,6 +33,7 @@
 import androidx.compose.ui.test.junit4.createComposeRule
 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.Ignore
 import org.junit.Rule
@@ -48,6 +49,7 @@
     private lateinit var inputModeManager: InputModeManager
     private val focusStates = mutableListOf<FocusState>()
 
+    @SdkSuppress(minSdkVersion = 28)
     @Test
     fun clearFocus_singleLayout_focusIsRestoredAfterClear() {
         // Arrange.
@@ -164,6 +166,7 @@
         }
     }
 
+    @SdkSuppress(minSdkVersion = 28)
     @Test
     fun releaseFocus_whenOwnerFocusIsCleared() {
         // Arrange.
@@ -236,6 +239,7 @@
         }
     }
 
+    @SdkSuppress(minSdkVersion = 28)
     @Test
     fun clearFocus_whenRootIsActiveParent() {
         // Arrange.
@@ -301,6 +305,7 @@
         }
     }
 
+    @SdkSuppress(minSdkVersion = 28)
     @Test
     fun clearFocus_forced_whenHierarchyHasCapturedFocus() {
         // Arrange.
diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/rotary/RotaryScrollEventTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/rotary/RotaryScrollEventTest.kt
index be72fd0..aa17a438 100644
--- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/rotary/RotaryScrollEventTest.kt
+++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/rotary/RotaryScrollEventTest.kt
@@ -16,6 +16,8 @@
 
 package androidx.compose.ui.input.rotary
 
+import android.content.Context
+import android.hardware.input.InputManager
 import android.view.MotionEvent
 import android.view.MotionEvent.ACTION_SCROLL
 import android.view.View
@@ -47,6 +49,7 @@
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.MediumTest
 import com.google.common.truth.Truth.assertThat
+import org.junit.Assume
 import org.junit.Before
 import org.junit.Rule
 import org.junit.Test
@@ -276,6 +279,54 @@
     }
 
     @Test
+    fun verticalRotaryEventContainsDeviceId() {
+        // Arrange.
+        ContentWithInitialFocus {
+            Box(
+                modifier =
+                    Modifier.onRotaryScrollEvent {
+                            receivedEvent = it
+                            true
+                        }
+                        .focusable(initiallyFocused = true)
+            )
+        }
+        // Ignore on all devices which doesn't have rotary input.
+        Assume.assumeTrue(hasRotaryInputDevice())
+
+        // Act.
+        @OptIn(ExperimentalTestApi::class)
+        rule.onRoot().performRotaryScrollInput { rotateToScrollVertically(3.0f) }
+
+        // Assert.
+        rule.runOnIdle { assertThat(receivedEvent?.inputDeviceId).isGreaterThan(1) }
+    }
+
+    @Test
+    fun horizontalRotaryEventContainsDeviceId() {
+        // Arrange.
+        ContentWithInitialFocus {
+            Box(
+                modifier =
+                    Modifier.onRotaryScrollEvent {
+                            receivedEvent = it
+                            true
+                        }
+                        .focusable(initiallyFocused = true)
+            )
+        }
+        // Ignore on all devices which doesn't have rotary input.
+        Assume.assumeTrue(hasRotaryInputDevice())
+
+        // Act.
+        @OptIn(ExperimentalTestApi::class)
+        rule.onRoot().performRotaryScrollInput { rotateToScrollHorizontally(3.0f) }
+
+        // Assert.
+        rule.runOnIdle { assertThat(receivedEvent?.inputDeviceId).isGreaterThan(1) }
+    }
+
+    @Test
     fun rotaryEventHasTime() {
         val TIME = 1234567890L
 
@@ -511,4 +562,19 @@
     private val verticalScrollFactor: Float
         get() =
             getScaledVerticalScrollFactor(ViewConfiguration.get(rootView.context), rootView.context)
+
+    private fun hasRotaryInputDevice(): Boolean {
+        with(rootView.context.getSystemService(Context.INPUT_SERVICE) as InputManager) {
+            inputDeviceIds.forEach { deviceId ->
+                getInputDevice(deviceId)?.apply {
+                    motionRanges
+                        .find { it.source == SOURCE_ROTARY_ENCODER }
+                        ?.let {
+                            return true
+                        }
+                }
+            }
+        }
+        return false
+    }
 }
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/autofill/AutofillManager.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/autofill/AutofillManager.kt
index 79f4666..f22e94ab 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/autofill/AutofillManager.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/autofill/AutofillManager.kt
@@ -25,18 +25,19 @@
 interface AutofillManager {
 
     /**
-     * Indicate the autofill context should be committed.
+     * Indicate the autofill session should be committed.
      *
-     * Call this function to notify the Autofill framework that the current context should be
-     * committed. After calling this function, the framework considers the form submitted, and the
-     * credentials entered will be processed.
+     * Call this function to notify the Autofill framework that the current session should be
+     * committed and so the entered credentials might be saved or updated. After calling this
+     * function, the framework considers the form submitted, and any relevant dialog will appear to
+     * notify the user of the data processed.
      */
     fun commit()
 
     /**
      * Indicate the autofill context should be canceled.
      *
-     * Call this function to notify the Autofill framework that the current context should be
+     * Call this function to notify the Autofill framework that the current session should be
      * canceled. After calling this function, the framework will stop the current autofill session
      * without processing any information entered in the autofillable field.
      */
diff --git a/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/E2EExtensionTests.kt b/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/E2EExtensionTests.kt
index e19740a..582b073 100644
--- a/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/E2EExtensionTests.kt
+++ b/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/E2EExtensionTests.kt
@@ -58,7 +58,6 @@
 import org.junit.Assert.fail
 import org.junit.Assume.assumeTrue
 import org.junit.Before
-import org.junit.Ignore
 import org.junit.Rule
 import org.junit.Test
 import org.junit.runner.RunWith
@@ -414,21 +413,16 @@
      * This is an end to end test that verifies a VoIP application and InCallService can add the
      * LocalCallSilenceExtension and toggle the value.
      */
-    @SdkSuppress(
-        minSdkVersion = VERSION_CODES.O,
-        maxSdkVersion = VERSION_CODES.TIRAMISU
-    ) // TODO:: b/377707977
     @LargeTest
-    @Ignore("b/377706280")
     @Test(timeout = 10000)
     fun testVoipAndIcsTogglingTheLocalCallSilenceExtension(): Unit = runBlocking {
         usingIcs { ics ->
-            val globalMuteStateReceiver = TestMuteStateReceiver(this)
-            mContext.registerReceiver(
-                globalMuteStateReceiver,
-                IntentFilter(AudioManager.ACTION_MICROPHONE_MUTE_CHANGED)
-            )
+            val globalMuteStateReceiver = TestMuteStateReceiver()
             try {
+                mContext.registerReceiver(
+                    globalMuteStateReceiver,
+                    IntentFilter(AudioManager.ACTION_MICROPHONE_MUTE_CHANGED)
+                )
                 val voipAppControl = bindToVoipAppWithExtensions()
                 val callback = TestCallCallbackListener(this)
                 voipAppControl.setCallback(callback)
@@ -457,12 +451,18 @@
                                 // the mute is called. Otherwise, telecom will unmute during the
                                 // call setup
                                 delay(500)
+                                Log.i("LCS_Test", "manually muting the mic")
                                 am.setMicrophoneMute(true)
                                 assertTrue(am.isMicrophoneMute)
-                                globalMuteStateReceiver.waitForGlobalMuteState(true, "1")
+                                waitForGlobalMuteState(true, "1", callback, globalMuteStateReceiver)
                                 // LocalCallSilenceExtensionImpl handles globally unmuting the
                                 // microphone
-                                globalMuteStateReceiver.waitForGlobalMuteState(false, "2")
+                                waitForGlobalMuteState(
+                                    false,
+                                    "2",
+                                    callback,
+                                    globalMuteStateReceiver
+                                )
                             }
 
                             // VoIP --> ICS
@@ -478,11 +478,16 @@
                             callback.waitForIsLocalSilenced(voipCallId, false)
 
                             // set the call state via voip app control
-                            if (VERSION.SDK_INT >= VERSION_CODES.P) {
+                            if (VERSION.SDK_INT >= VERSION_CODES.R) {
                                 call.hold()
-                                globalMuteStateReceiver.waitForGlobalMuteState(true, "3")
+                                waitForGlobalMuteState(true, "3", callback, globalMuteStateReceiver)
                                 call.unhold()
-                                globalMuteStateReceiver.waitForGlobalMuteState(false, "4")
+                                waitForGlobalMuteState(
+                                    false,
+                                    "4",
+                                    callback,
+                                    globalMuteStateReceiver
+                                )
                             }
                             call.disconnect()
                         }
@@ -495,6 +500,19 @@
         }
     }
 
+    private suspend fun waitForGlobalMuteState(
+        expectedValue: Boolean,
+        tag: String,
+        cb: TestCallCallbackListener,
+        receiver: TestMuteStateReceiver
+    ) {
+        if (VERSION.SDK_INT >= VERSION_CODES.UPSIDE_DOWN_CAKE) {
+            cb.waitForGlobalMuteState(expectedValue, tag)
+        } else if (VERSION.SDK_INT >= VERSION_CODES.P) {
+            receiver.waitForGlobalMuteState(expectedValue, tag)
+        }
+    }
+
     /**
      * Create a VOIP call with a participants extension and attach participant Call extensions.
      * Verify kick participant functionality works as expected
diff --git a/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/utils/TestMuteStateReceiver.kt b/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/utils/TestMuteStateReceiver.kt
new file mode 100644
index 0000000..dc5983d
--- /dev/null
+++ b/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/utils/TestMuteStateReceiver.kt
@@ -0,0 +1,60 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.core.telecom.test.utils
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.media.AudioManager
+import android.util.Log
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.firstOrNull
+import kotlinx.coroutines.withTimeoutOrNull
+import org.junit.Assert.assertEquals
+
+class TestMuteStateReceiver : BroadcastReceiver() {
+    private val isMutedFlow: MutableStateFlow<Boolean> = MutableStateFlow(false)
+
+    companion object {
+        private val TAG: String = TestMuteStateReceiver::class.java.simpleName.toString()
+    }
+
+    suspend fun waitForGlobalMuteState(isMuted: Boolean, id: String = "") {
+        Log.i(TAG, "waitForGlobalMuteState: v=[$isMuted], id=[$id]")
+        val result =
+            withTimeoutOrNull(5000) {
+                isMutedFlow
+                    .filter {
+                        Log.i(TAG, "it=[$isMuted], isMuted=[$isMuted]")
+                        it == isMuted
+                    }
+                    .firstOrNull()
+            }
+        Log.i(TAG, "asserting id=[$id], result=$result")
+        assertEquals("Global Mute State {$id} never reached the expected state", isMuted, result)
+    }
+
+    override fun onReceive(context: Context, intent: Intent) {
+        if (AudioManager.ACTION_MICROPHONE_MUTE_CHANGED == intent.action) {
+            val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
+            val isMicGloballyMuted = audioManager.isMicrophoneMute
+            Log.i(TAG, "onReceive: isMicGloballyMuted=[${isMicGloballyMuted}]")
+            isMutedFlow.value = isMicGloballyMuted
+        }
+    }
+}
diff --git a/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/utils/TestUtils.kt b/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/utils/TestUtils.kt
index af9dec8..36a49d80 100644
--- a/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/utils/TestUtils.kt
+++ b/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/utils/TestUtils.kt
@@ -16,9 +16,7 @@
 
 package androidx.core.telecom.test.utils
 
-import android.content.BroadcastReceiver
 import android.content.Context
-import android.content.Intent
 import android.media.AudioManager
 import android.net.Uri
 import android.os.Build
@@ -49,6 +47,7 @@
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.filter
 import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.firstOrNull
 import kotlinx.coroutines.flow.map
 import kotlinx.coroutines.isActive
 import kotlinx.coroutines.launch
@@ -414,8 +413,12 @@
     private val callAddedFlow: MutableSharedFlow<Pair<Int, String>> = MutableSharedFlow(replay = 1)
     private val isMutedFlow: MutableStateFlow<Boolean> = MutableStateFlow(false)
 
+    companion object {
+        private val TAG: String = TestCallCallbackListener::class.java.simpleName.toString()
+    }
+
     override fun onGlobalMuteStateChanged(isMuted: Boolean) {
-        Log.i("TestCallCallbackListener", "onGlobalMuteStateChanged: isMuted: $isMuted")
+        Log.i(TAG, "onGlobalMuteStateChanged: isMuted: $isMuted")
         scope.launch { isMutedFlow.emit(isMuted) }
     }
 
@@ -463,9 +466,19 @@
         assertEquals("<LOCAL CALL SILENCE> never received", expectedState, result?.second)
     }
 
-    suspend fun waitForGlobalMuteState(isMuted: Boolean) {
-        val result = withTimeoutOrNull(5000) { isMutedFlow.filter { it == isMuted }.first() }
-        assertEquals("Global mute state never reached the expected state", isMuted, result)
+    suspend fun waitForGlobalMuteState(isMuted: Boolean, id: String = "") {
+        Log.i(TAG, "waitForGlobalMuteState: v=[$isMuted], id=[$id]")
+        val result =
+            withTimeoutOrNull(5000) {
+                isMutedFlow
+                    .filter {
+                        Log.i(TAG, "it=[$isMuted], isMuted=[$isMuted]")
+                        it == isMuted
+                    }
+                    .firstOrNull()
+            }
+        Log.i(TAG, "asserting id=[$id], result=$result")
+        assertEquals("Global Mute State {$id} never reached the expected state", isMuted, result)
     }
 
     suspend fun waitForKickParticipant(callId: String, expectedParticipant: Participant?) {
@@ -478,27 +491,3 @@
         assertEquals("kick participant action never received", expectedParticipant, result?.second)
     }
 }
-
-class TestMuteStateReceiver(private val scope: CoroutineScope) : BroadcastReceiver() {
-    private val isMutedFlow: MutableStateFlow<Boolean> = MutableStateFlow(false)
-
-    suspend fun waitForGlobalMuteState(isMuted: Boolean, id: String = "") {
-        val result =
-            withTimeoutOrNull(5000) {
-                isMutedFlow
-                    .filter {
-                        Log.i("TestMuteStateReceiver", "received $isMuted")
-                        it == isMuted
-                    }
-                    .first()
-            }
-        assertEquals("Global Mute State {$id} never reached the expected state", isMuted, result)
-    }
-
-    override fun onReceive(context: Context, intent: Intent) {
-        if (AudioManager.ACTION_MICROPHONE_MUTE_CHANGED == intent.action) {
-            val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
-            scope.launch { isMutedFlow.emit(audioManager.isMicrophoneMute) }
-        }
-    }
-}
diff --git a/core/core-viewtree/build.gradle b/core/core-viewtree/build.gradle
index 9fe5bca..45281e8 100644
--- a/core/core-viewtree/build.gradle
+++ b/core/core-viewtree/build.gradle
@@ -48,7 +48,7 @@
 androidx {
     name = "androidx.core:core-viewtree"
     type = LibraryType.PUBLISHED_LIBRARY
-    mavenVersion = LibraryVersions.CORE
+    mavenVersion = LibraryVersions.CORE_VIEWTREE
     inceptionYear = "2024"
     description = "Provides ViewTree extensions packaged for use by other core androidx libraries"
 }
diff --git a/core/core/api/current.txt b/core/core/api/current.txt
index d140c6a..41a187f 100644
--- a/core/core/api/current.txt
+++ b/core/core/api/current.txt
@@ -2948,6 +2948,13 @@
     method @Deprecated public static void setQuickScaleEnabled(Object!, boolean);
   }
 
+  public class ScrollFeedbackProviderCompat {
+    method public static androidx.core.view.ScrollFeedbackProviderCompat createProvider(android.view.View);
+    method public void onScrollLimit(int, int, int, boolean);
+    method public void onScrollProgress(int, int, int, int);
+    method public void onSnapToItem(int, int, int);
+  }
+
   public interface ScrollingView {
     method public int computeHorizontalScrollExtent();
     method public int computeHorizontalScrollOffset();
diff --git a/core/core/api/restricted_current.txt b/core/core/api/restricted_current.txt
index c33f186..ede0127 100644
--- a/core/core/api/restricted_current.txt
+++ b/core/core/api/restricted_current.txt
@@ -3453,6 +3453,13 @@
     method @Deprecated public static void setQuickScaleEnabled(Object!, boolean);
   }
 
+  public class ScrollFeedbackProviderCompat {
+    method public static androidx.core.view.ScrollFeedbackProviderCompat createProvider(android.view.View);
+    method public void onScrollLimit(int, int, int, boolean);
+    method public void onScrollProgress(int, int, int, int);
+    method public void onSnapToItem(int, int, int);
+  }
+
   public interface ScrollingView {
     method public int computeHorizontalScrollExtent();
     method public int computeHorizontalScrollOffset();
diff --git a/core/core/src/androidTest/java/androidx/core/view/inputmethod/ImeViewCompatMultiWindowTest.java b/core/core/src/androidTest/java/androidx/core/view/inputmethod/ImeViewCompatMultiWindowTest.java
index 8cd0379..7dfdeef 100644
--- a/core/core/src/androidTest/java/androidx/core/view/inputmethod/ImeViewCompatMultiWindowTest.java
+++ b/core/core/src/androidTest/java/androidx/core/view/inputmethod/ImeViewCompatMultiWindowTest.java
@@ -49,7 +49,7 @@
 
 @RunWith(AndroidJUnit4.class)
 @LargeTest
-@SdkSuppress(minSdkVersion = 30)
+@SdkSuppress(minSdkVersion = 31)
 public class ImeViewCompatMultiWindowTest {
 
     @Rule
@@ -73,13 +73,11 @@
 
     /**
      * This test is using a deprecated codepath that doesn't support the workaround, so it is
-     * expected to fail hiding the IME.
-     * If this test begins failing on a new API version (that is, an assertion error is no longer
-     * being thrown), it is likely that the workaround is no longer needed on that API version:
-     * b/280532442
+     * expected to fail hiding the IME. The workaround is no longer needed on API version 35.
+     * See b/280532442 for details.
      */
     @Test(expected = AssertionError.class)
-    @SdkSuppress(minSdkVersion = 30, excludedSdks = { 30 }) // Excluded due to flakes (b/324889554)
+    @SdkSuppress(minSdkVersion = 31, maxSdkVersion = 35)
     public void testImeShowAndHide_splitScreen() {
         if (Build.VERSION.SDK_INT < 32) {
             // FLAG_ACTIVITY_LAUNCH_ADJACENT is not support before Sdk 32, using the
diff --git a/core/core/src/main/java/androidx/core/view/ScrollFeedbackProviderCompat.java b/core/core/src/main/java/androidx/core/view/ScrollFeedbackProviderCompat.java
new file mode 100644
index 0000000..07be72b
--- /dev/null
+++ b/core/core/src/main/java/androidx/core/view/ScrollFeedbackProviderCompat.java
@@ -0,0 +1,151 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.core.view;
+
+import android.os.Build;
+import android.view.InputDevice;
+import android.view.MotionEvent;
+import android.view.ScrollFeedbackProvider;
+import android.view.View;
+import android.view.ViewConfiguration;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.RequiresApi;
+
+/** Compat to access {@link ScrollFeedbackProvider} across different build versions. */
+public class ScrollFeedbackProviderCompat {
+
+    private final ScrollFeedbackProviderImpl mImpl;
+
+    private ScrollFeedbackProviderCompat(@NonNull View view) {
+        if (Build.VERSION.SDK_INT >= 35) {
+            mImpl = new ScrollFeedbackProviderApi35Impl(view);
+        } else {
+            mImpl = new ScrollFeedbackProviderBaseImpl();
+        }
+    }
+
+    /** Creates an instance of {@link ScrollFeedbackProviderCompat}. */
+    @NonNull
+    public static ScrollFeedbackProviderCompat createProvider(@NonNull View view) {
+        return new ScrollFeedbackProviderCompat(view);
+    }
+
+    /**
+     * Call this when the view has snapped to an item.
+     *
+     * @param inputDeviceId the ID of the {@link InputDevice} that generated the motion triggering
+     *          the snap.
+     * @param source the input source of the motion causing the snap.
+     * @param axis the axis of {@code event} that caused the item to snap.
+     */
+    public void onSnapToItem(int inputDeviceId, int source, int axis) {
+        mImpl.onSnapToItem(inputDeviceId, source, axis);
+    }
+
+    /**
+     * Call this when the view has reached the scroll limit.
+     *
+     * <p>Note that a feedback may not be provided on every call to this method. This interface, for
+     * instance, may provide feedback on every `N`th scroll limit event. For the interface to
+     * properly provide feedback when needed, call this method for each scroll limit event that you
+     * want to be accounted to scroll limit feedback.
+     *
+     * @param inputDeviceId the ID of the {@link InputDevice} that caused scrolling to hit limit.
+     * @param source the input source of the motion that caused scrolling to hit the limit.
+     * @param axis the axis of {@code event} that caused scrolling to hit the limit.
+     * @param isStart {@code true} if scrolling hit limit at the start of the scrolling list, and
+     *                {@code false} if the scrolling hit limit at the end of the scrolling list.
+     *                <i>start</i> and <i>end</i> in this context are not geometrical references.
+     *                Instead, they refer to the start and end of a scrolling experience. As such,
+     *                "start" for some views may be at the bottom of a scrolling list, while it may
+     *                be at the top of scrolling list for others.
+     */
+    public void onScrollLimit(int inputDeviceId, int source, int axis, boolean isStart) {
+        mImpl.onScrollLimit(inputDeviceId, source, axis, isStart);
+    }
+
+    /**
+     * Call this when the view has scrolled.
+     *
+     * <p>Different axes have different ways to map their raw axis values to pixels for scrolling.
+     * When calling this method, use the scroll values in pixels by which the view was scrolled; do
+     * not use the raw axis values. That is, use whatever value is passed to one of View's scrolling
+     * methods (example: {@link View#scrollBy(int, int)}). For example, for vertical scrolling on
+     * {@link MotionEvent#AXIS_SCROLL}, convert the raw axis value to the equivalent pixels by using
+     * {@link ViewConfiguration#getScaledVerticalScrollFactor()}, and use that value for this method
+     * call.
+     *
+     * <p>Note that a feedback may not be provided on every call to this method. This interface, for
+     * instance, may provide feedback for every `x` pixels scrolled. For the interface to properly
+     * track scroll progress and provide feedback when needed, call this method for each scroll
+     * event that you want to be accounted to scroll feedback.
+     *
+     * @param inputDeviceId the ID of the {@link InputDevice} that caused scroll progress.
+     * @param source the input source of the motion that caused scroll progress.
+     * @param axis the axis of {@code event} that caused scroll progress.
+     * @param deltaInPixels the amount of scroll progress, in pixels.
+     */
+    public void onScrollProgress(int inputDeviceId, int source, int axis, int deltaInPixels) {
+        mImpl.onScrollProgress(inputDeviceId, source, axis, deltaInPixels);
+    }
+
+    @RequiresApi(35)
+    private static class ScrollFeedbackProviderApi35Impl implements ScrollFeedbackProviderImpl {
+        private final ScrollFeedbackProvider mProvider;
+
+        ScrollFeedbackProviderApi35Impl(View view) {
+            mProvider = ScrollFeedbackProvider.createProvider(view);
+        }
+
+        @Override
+        public void onSnapToItem(int inputDeviceId, int source, int axis) {
+            mProvider.onSnapToItem(inputDeviceId, source, axis);
+        }
+
+        @Override
+        public void onScrollLimit(int inputDeviceId, int source, int axis, boolean isStart) {
+            mProvider.onScrollLimit(inputDeviceId, source, axis, isStart);
+        }
+
+        @Override
+        public void onScrollProgress(int inputDeviceId, int source, int axis, int deltaInPixels) {
+            mProvider.onScrollProgress(inputDeviceId, source, axis, deltaInPixels);
+        }
+    }
+
+    private static class ScrollFeedbackProviderBaseImpl implements ScrollFeedbackProviderImpl {
+        @Override
+        public void onSnapToItem(int inputDeviceId, int source, int axis) {}
+
+        @Override
+        public void onScrollLimit(int inputDeviceId, int source, int axis, boolean isStart) {}
+
+        @Override
+        public void onScrollProgress(int inputDeviceId, int source, int axis, int deltaInPixels) {}
+    }
+
+    /**
+     * An interface parallel to {@link ScrollFeedbackProvider}, to allow different compat
+     * implementations based on Build SDK version.
+     */
+    private interface ScrollFeedbackProviderImpl {
+        void onSnapToItem(int inputDeviceId, int source, int axis);
+        void onScrollLimit(int inputDeviceId, int source, int axis, boolean isStart);
+        void onScrollProgress(int inputDeviceId, int source, int axis, int deltaInPixels);
+    }
+}
diff --git a/core/uwb/uwb/api/current.txt b/core/uwb/uwb/api/current.txt
index 782a9f8..b5981af 100644
--- a/core/uwb/uwb/api/current.txt
+++ b/core/uwb/uwb/api/current.txt
@@ -110,6 +110,12 @@
     property public abstract androidx.core.uwb.UwbDevice device;
   }
 
+  public static final class RangingResult.RangingResultInitialized extends androidx.core.uwb.RangingResult {
+    ctor public RangingResult.RangingResultInitialized(androidx.core.uwb.UwbDevice device);
+    method public androidx.core.uwb.UwbDevice getDevice();
+    property public androidx.core.uwb.UwbDevice device;
+  }
+
   public static final class RangingResult.RangingResultPeerDisconnected extends androidx.core.uwb.RangingResult {
     ctor public RangingResult.RangingResultPeerDisconnected(androidx.core.uwb.UwbDevice device);
     method public androidx.core.uwb.UwbDevice getDevice();
diff --git a/core/uwb/uwb/api/restricted_current.txt b/core/uwb/uwb/api/restricted_current.txt
index 782a9f8..b5981af 100644
--- a/core/uwb/uwb/api/restricted_current.txt
+++ b/core/uwb/uwb/api/restricted_current.txt
@@ -110,6 +110,12 @@
     property public abstract androidx.core.uwb.UwbDevice device;
   }
 
+  public static final class RangingResult.RangingResultInitialized extends androidx.core.uwb.RangingResult {
+    ctor public RangingResult.RangingResultInitialized(androidx.core.uwb.UwbDevice device);
+    method public androidx.core.uwb.UwbDevice getDevice();
+    property public androidx.core.uwb.UwbDevice device;
+  }
+
   public static final class RangingResult.RangingResultPeerDisconnected extends androidx.core.uwb.RangingResult {
     ctor public RangingResult.RangingResultPeerDisconnected(androidx.core.uwb.UwbDevice device);
     method public androidx.core.uwb.UwbDevice getDevice();
diff --git a/core/uwb/uwb/src/main/java/androidx/core/uwb/RangingResult.kt b/core/uwb/uwb/src/main/java/androidx/core/uwb/RangingResult.kt
index bb26126..f310bb4 100644
--- a/core/uwb/uwb/src/main/java/androidx/core/uwb/RangingResult.kt
+++ b/core/uwb/uwb/src/main/java/androidx/core/uwb/RangingResult.kt
@@ -33,4 +33,7 @@
 
     /** A ranging result with peer disconnected status update. */
     public class RangingResultPeerDisconnected(override val device: UwbDevice) : RangingResult()
+
+    /** A ranging result when a ranging session is initialized with peer device. */
+    public class RangingResultInitialized(override val device: UwbDevice) : RangingResult()
 }
diff --git a/core/uwb/uwb/src/main/java/androidx/core/uwb/impl/UwbClientSessionScopeAospImpl.kt b/core/uwb/uwb/src/main/java/androidx/core/uwb/impl/UwbClientSessionScopeAospImpl.kt
index c7c07f2..9479357 100644
--- a/core/uwb/uwb/src/main/java/androidx/core/uwb/impl/UwbClientSessionScopeAospImpl.kt
+++ b/core/uwb/uwb/src/main/java/androidx/core/uwb/impl/UwbClientSessionScopeAospImpl.kt
@@ -16,7 +16,6 @@
 
 package androidx.core.uwb.impl
 
-import android.util.Log
 import androidx.core.uwb.RangingCapabilities
 import androidx.core.uwb.RangingMeasurement
 import androidx.core.uwb.RangingParameters
@@ -135,7 +134,11 @@
         val callback =
             object : IRangingSessionCallback.Stub() {
                 override fun onRangingInitialized(device: UwbDevice) {
-                    Log.i(TAG, "Started UWB ranging.")
+                    trySend(
+                        RangingResult.RangingResultInitialized(
+                            androidx.core.uwb.UwbDevice(UwbAddress(device.address?.address!!))
+                        )
+                    )
                 }
 
                 override fun onRangingResult(device: UwbDevice, position: RangingPosition) {
diff --git a/core/uwb/uwb/src/main/java/androidx/core/uwb/impl/UwbClientSessionScopeImpl.kt b/core/uwb/uwb/src/main/java/androidx/core/uwb/impl/UwbClientSessionScopeImpl.kt
index e7e6fcb..62a8407 100644
--- a/core/uwb/uwb/src/main/java/androidx/core/uwb/impl/UwbClientSessionScopeImpl.kt
+++ b/core/uwb/uwb/src/main/java/androidx/core/uwb/impl/UwbClientSessionScopeImpl.kt
@@ -16,10 +16,10 @@
 
 package androidx.core.uwb.impl
 
-import android.util.Log
 import androidx.core.uwb.RangingCapabilities
 import androidx.core.uwb.RangingMeasurement
 import androidx.core.uwb.RangingParameters
+import androidx.core.uwb.RangingResult.RangingResultInitialized
 import androidx.core.uwb.RangingResult.RangingResultPeerDisconnected
 import androidx.core.uwb.RangingResult.RangingResultPosition
 import androidx.core.uwb.UwbAddress
@@ -139,7 +139,11 @@
         val callback =
             object : RangingSessionCallback {
                 override fun onRangingInitialized(device: UwbDevice) {
-                    Log.i(TAG, "Started UWB ranging.")
+                    trySend(
+                        RangingResultInitialized(
+                            androidx.core.uwb.UwbDevice(UwbAddress(device.address.address))
+                        )
+                    )
                 }
 
                 override fun onRangingResult(device: UwbDevice, position: RangingPosition) {
diff --git a/credentials/registry/registry-provider/src/main/java/androidx/credentials/registry/provider/ProviderGetCredentialRequest.kt b/credentials/registry/registry-provider/src/main/java/androidx/credentials/registry/provider/ProviderGetCredentialRequest.kt
index 89a12f3..65815eb 100644
--- a/credentials/registry/registry-provider/src/main/java/androidx/credentials/registry/provider/ProviderGetCredentialRequest.kt
+++ b/credentials/registry/registry-provider/src/main/java/androidx/credentials/registry/provider/ProviderGetCredentialRequest.kt
@@ -35,6 +35,9 @@
  * A null return means that entry ID isn't supported for the given type of the use case at all. For
  * example, a [androidx.credentials.provider.PasswordCredentialEntry] does not have an id property
  * and so this getter will return null if the selected entry was a password credential.
+ *
+ * For how to handle a user selection and extract the [ProviderGetCredentialRequest] containing the
+ * selection information, see [RegistryManager.ACTION_GET_CREDENTIAL].
  */
 @get:JvmName("getSelectedEntryId")
 public val ProviderGetCredentialRequest.selectedEntryId: String?
diff --git a/credentials/registry/registry-provider/src/main/java/androidx/credentials/registry/provider/RegistryManager.kt b/credentials/registry/registry-provider/src/main/java/androidx/credentials/registry/provider/RegistryManager.kt
index c601099..4d85bb0 100644
--- a/credentials/registry/registry-provider/src/main/java/androidx/credentials/registry/provider/RegistryManager.kt
+++ b/credentials/registry/registry-provider/src/main/java/androidx/credentials/registry/provider/RegistryManager.kt
@@ -44,10 +44,13 @@
          * when the user selects a credential that belongs to your application. Your activity will
          * be launched and you should use the
          * [androidx.credentials.provider.PendingIntentHandler.retrieveProviderGetCredentialRequest]
-         * API to retrieve information about the user selection and the verifier request contained
-         * in [androidx.credentials.provider.ProviderGetCredentialRequest]. Next, perform the
-         * necessary steps (e.g. consent collection, credential lookup) to generate a response for
-         * the given request. Pass the result back using one of the
+         * API to retrieve information about the user selection (you can do this through
+         * [androidx.credentials.registry.provider.selectedEntryId]), the verifier request, and
+         * other caller app information contained in
+         * [androidx.credentials.provider.ProviderGetCredentialRequest].
+         *
+         * Next, perform the necessary steps (e.g. consent collection, credential lookup) to
+         * generate a response for the given request. Pass the result back using one of the
          * [androidx.credentials.provider.PendingIntentHandler.setGetCredentialResponse] and
          * [androidx.credentials.provider.PendingIntentHandler.setGetCredentialException] APIs.
          */
diff --git a/exifinterface/exifinterface/src/androidTest/java/androidx/exifinterface/media/ExifInterfaceTest.java b/exifinterface/exifinterface/src/androidTest/java/androidx/exifinterface/media/ExifInterfaceTest.java
index b138827..1e85fb5 100644
--- a/exifinterface/exifinterface/src/androidTest/java/androidx/exifinterface/media/ExifInterfaceTest.java
+++ b/exifinterface/exifinterface/src/androidTest/java/androidx/exifinterface/media/ExifInterfaceTest.java
@@ -325,12 +325,13 @@
 
     @Test
     @LargeTest
-    public void testPngWithExif() throws Throwable {
+    public void testPngWithExifAndXmp() throws Throwable {
         File imageFile =
                 copyFromResourceToFile(
-                        R.raw.png_with_exif_byte_order_ii, "png_with_exif_byte_order_ii.png");
-        readFromFilesWithExif(imageFile, ExpectedAttributes.PNG_WITH_EXIF_BYTE_ORDER_II);
-        testWritingExif(imageFile, ExpectedAttributes.PNG_WITH_EXIF_BYTE_ORDER_II);
+                        R.raw.png_with_exif_and_xmp_byte_order_ii,
+                        "png_with_exif_and_xmp_byte_order_ii.png");
+        readFromFilesWithExif(imageFile, ExpectedAttributes.PNG_WITH_EXIF_AND_XMP_BYTE_ORDER_II);
+        testWritingExif(imageFile, ExpectedAttributes.PNG_WITH_EXIF_AND_XMP_BYTE_ORDER_II);
     }
 
     @Test
diff --git a/exifinterface/exifinterface/src/androidTest/java/androidx/exifinterface/media/ExpectedAttributes.java b/exifinterface/exifinterface/src/androidTest/java/androidx/exifinterface/media/ExpectedAttributes.java
index 0a51698..8b39aa1 100644
--- a/exifinterface/exifinterface/src/androidTest/java/androidx/exifinterface/media/ExpectedAttributes.java
+++ b/exifinterface/exifinterface/src/androidTest/java/androidx/exifinterface/media/ExpectedAttributes.java
@@ -151,13 +151,15 @@
                     .setXmpOffsetAndLength(1809, 13197)
                     .build();
 
-    /** Expected attributes for {@link R.raw#png_with_exif_byte_order_ii}. */
-    public static final ExpectedAttributes PNG_WITH_EXIF_BYTE_ORDER_II =
+    /** Expected attributes for {@link R.raw#png_with_exif_and_xmp_byte_order_ii}. */
+    public static final ExpectedAttributes PNG_WITH_EXIF_AND_XMP_BYTE_ORDER_II =
             JPEG_WITH_EXIF_BYTE_ORDER_II
                     .buildUpon()
                     .setThumbnailOffset(212271)
                     .setMakeOffset(211525)
                     .setFocalLength("41/10")
+                    // TODO: b/332793608 - Add expected XMP values and offset/length when
+                    //  ExifInterface can parse the iTXt chunk.
                     .build();
 
     /** Expected attributes for {@link R.raw#webp_with_exif}. */
diff --git a/exifinterface/exifinterface/src/androidTest/res/raw/png_with_exif_byte_order_ii.png b/exifinterface/exifinterface/src/androidTest/res/raw/png_with_exif_and_xmp_byte_order_ii.png
similarity index 100%
rename from exifinterface/exifinterface/src/androidTest/res/raw/png_with_exif_byte_order_ii.png
rename to exifinterface/exifinterface/src/androidTest/res/raw/png_with_exif_and_xmp_byte_order_ii.png
Binary files differ
diff --git a/exifinterface/exifinterface/src/main/java/androidx/exifinterface/media/ExifInterface.java b/exifinterface/exifinterface/src/main/java/androidx/exifinterface/media/ExifInterface.java
index f52cec7..4ed56d3 100644
--- a/exifinterface/exifinterface/src/main/java/androidx/exifinterface/media/ExifInterface.java
+++ b/exifinterface/exifinterface/src/main/java/androidx/exifinterface/media/ExifInterface.java
@@ -3101,9 +3101,9 @@
             (byte) 0x47, (byte) 0x0d, (byte) 0x0a, (byte) 0x1a, (byte) 0x0a};
     // See "Extensions to the PNG 1.2 Specification, Version 1.5.0",
     // 3.7. eXIf Exchangeable Image File (Exif) Profile
-    private static final int PNG_CHUNK_TYPE_EXIF = intFromBytes('e', 'X', 'I', 'f');
-    private static final int PNG_CHUNK_TYPE_IHDR = intFromBytes('I', 'H', 'D', 'R');
-    private static final int PNG_CHUNK_TYPE_IEND = intFromBytes('I', 'E', 'N', 'D');
+    private static final int PNG_CHUNK_TYPE_EXIF = 'e' << 24 | 'X' << 16 | 'I' << 8 | 'f';
+    private static final int PNG_CHUNK_TYPE_IHDR = 'I' << 24 | 'H' << 16 | 'D' << 8 | 'R';
+    private static final int PNG_CHUNK_TYPE_IEND = 'I' << 24 | 'E' << 16 | 'N' << 8 | 'D';
     private static final int PNG_CHUNK_TYPE_BYTE_LENGTH = 4;
     private static final int PNG_CHUNK_CRC_BYTE_LENGTH = 4;
 
@@ -8328,12 +8328,4 @@
         }
         return false;
     }
-
-    /*
-     * Combines the lower eight bits of each parameter into a 32-bit int. {@code b1} is the highest
-     * byte of the result, {@code b4} is the lowest.
-     */
-    private static int intFromBytes(int b1, int b2, int b3, int b4) {
-        return ((b1 & 0xFF) << 24) | ((b2 & 0xFF) << 16) | ((b3 & 0xFF) << 8) | (b4 & 0xFF);
-    }
 }
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index c6259a9..e9f5ba4 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -12,7 +12,9 @@
 # -----------------------------------------------------------------------------
 
 androidLintMin = "31.1.0"
-androidLintPrev = "31.7.0-alpha02"
+# Lint version that uses the old analysis API. Updating projects using this version to the new
+# analysis API would make those lint checks unavailable for users of previous AGP versions.
+androidLintPrevAnalysis = "31.7.0-alpha02"
 androidxTestRunner = "1.6.1"
 androidxTestRules = "1.6.1"
 androidxTestMonitor = "1.7.1"
@@ -81,14 +83,14 @@
 androidLayoutlibApi = { module = "com.android.tools.layoutlib:layoutlib-api", version.ref = "androidLint" }
 androidLint = { module = "com.android.tools.lint:lint", version.ref = "androidLint" }
 androidLintMin = { module = "com.android.tools.lint:lint", version.ref = "androidLintMin" }
-androidLintPrev = { module = "com.android.tools.lint:lint", version.ref = "androidLintPrev" }
-androidLintPrevApi = { module = "com.android.tools.lint:lint-api", version.ref = "androidLintPrev" }
+androidLintPrevAnalysis = { module = "com.android.tools.lint:lint", version.ref = "androidLintPrevAnalysis" }
+androidLintApiPrevAnalysis = { module = "com.android.tools.lint:lint-api", version.ref = "androidLintPrevAnalysis" }
 androidLintApi = { module = "com.android.tools.lint:lint-api", version.ref = "androidLint" }
 androidLintMinApi = { module = "com.android.tools.lint:lint-api", version.ref = "androidLintMin" }
 androidLintChecks = { module = "com.android.tools.lint:lint-checks", version.ref = "androidLint" }
 androidLintChecksMin = { module = "com.android.tools.lint:lint-checks", version.ref = "androidLintMin" }
 androidLintTests = { module = "com.android.tools.lint:lint-tests", version.ref = "androidLint" }
-androidLintPrevTests = { module = "com.android.tools.lint:lint-tests", version.ref = "androidLintPrev" }
+androidLintTestsPrevAnalysis = { module = "com.android.tools.lint:lint-tests", version.ref = "androidLintPrevAnalysis" }
 androidToolsCommon = { module = "com.android.tools:common", version.ref = "androidLint" }
 androidToolsRepository= { module = "com.android.tools:repository", version.ref = "androidLint" }
 androidToolsSdkCommon = { module = "com.android.tools:sdk-common", version.ref = "androidLint" }
diff --git a/health/connect/connect-client/src/androidTest/AndroidManifest.xml b/health/connect/connect-client/src/androidTest/AndroidManifest.xml
index e235b8d..47aac45 100644
--- a/health/connect/connect-client/src/androidTest/AndroidManifest.xml
+++ b/health/connect/connect-client/src/androidTest/AndroidManifest.xml
@@ -32,6 +32,7 @@
     <uses-permission android:name="android.permission.health.READ_ELEVATION_GAINED"/>
     <uses-permission android:name="android.permission.health.READ_EXERCISE"/>
     <uses-permission android:name="android.permission.health.READ_FLOORS_CLIMBED"/>
+    <uses-permission android:name="android.permission.health.READ_PLANNED_EXERCISE"/>
     <uses-permission android:name="android.permission.health.READ_STEPS"/>
     <uses-permission android:name="android.permission.health.READ_TOTAL_CALORIES_BURNED"/>
     <uses-permission android:name="android.permission.health.READ_VO2_MAX"/>
@@ -50,6 +51,7 @@
 
     <!-- Read permissions for CYCLE_TRACKING. -->
     <uses-permission android:name="android.permission.health.READ_CERVICAL_MUCUS"/>
+    <uses-permission android:name="android.permission.health.READ_INTERMENSTRUAL_BLEEDING"/>
     <uses-permission android:name="android.permission.health.READ_MENSTRUATION"/>
     <uses-permission android:name="android.permission.health.READ_OVULATION_TEST"/>
     <uses-permission android:name="android.permission.health.READ_SEXUAL_ACTIVITY"/>
@@ -71,6 +73,7 @@
     <uses-permission android:name="android.permission.health.READ_OXYGEN_SATURATION"/>
     <uses-permission android:name="android.permission.health.READ_RESPIRATORY_RATE"/>
     <uses-permission android:name="android.permission.health.READ_RESTING_HEART_RATE"/>
+    <uses-permission android:name="android.permission.health.READ_SKIN_TEMPERATURE"/>
 
     <!-- Write permissions for ACTIVITY. -->
     <uses-permission android:name="android.permission.health.WRITE_ACTIVE_CALORIES_BURNED"/>
@@ -78,6 +81,7 @@
     <uses-permission android:name="android.permission.health.WRITE_ELEVATION_GAINED"/>
     <uses-permission android:name="android.permission.health.WRITE_EXERCISE"/>
     <uses-permission android:name="android.permission.health.WRITE_FLOORS_CLIMBED"/>
+    <uses-permission android:name="android.permission.health.WRITE_PLANNED_EXERCISE"/>
     <uses-permission android:name="android.permission.health.WRITE_STEPS"/>
     <uses-permission android:name="android.permission.health.WRITE_TOTAL_CALORIES_BURNED"/>
     <uses-permission android:name="android.permission.health.WRITE_VO2_MAX"/>
@@ -118,4 +122,5 @@
     <uses-permission android:name="android.permission.health.WRITE_OXYGEN_SATURATION"/>
     <uses-permission android:name="android.permission.health.WRITE_RESPIRATORY_RATE"/>
     <uses-permission android:name="android.permission.health.WRITE_RESTING_HEART_RATE"/>
+    <uses-permission android:name="android.permission.health.WRITE_SKIN_TEMPERATURE"/>
 </manifest>
diff --git a/health/connect/connect-client/src/androidTest/java/androidx/health/connect/client/impl/HealthConnectClientUpsideDownImplTest.kt b/health/connect/connect-client/src/androidTest/java/androidx/health/connect/client/impl/HealthConnectClientUpsideDownImplTest.kt
index e1f40ab..d93b15c 100644
--- a/health/connect/connect-client/src/androidTest/java/androidx/health/connect/client/impl/HealthConnectClientUpsideDownImplTest.kt
+++ b/health/connect/connect-client/src/androidTest/java/androidx/health/connect/client/impl/HealthConnectClientUpsideDownImplTest.kt
@@ -17,7 +17,6 @@
 package androidx.health.connect.client.impl
 
 import android.content.Context
-import android.content.pm.PackageManager
 import android.os.Build
 import android.os.ext.SdkExtensions
 import androidx.health.connect.client.HealthConnectClient
@@ -25,9 +24,10 @@
 import androidx.health.connect.client.changes.DeletionChange
 import androidx.health.connect.client.changes.UpsertionChange
 import androidx.health.connect.client.feature.ExperimentalFeatureAvailabilityApi
-import androidx.health.connect.client.impl.converters.datatype.RECORDS_CLASS_NAME_MAP
 import androidx.health.connect.client.impl.platform.aggregate.AGGREGATE_METRICS_ADDED_IN_SDK_EXT_10
-import androidx.health.connect.client.permission.HealthPermission.Companion.PERMISSION_PREFIX
+import androidx.health.connect.client.impl.platform.records.SDK_TO_PLATFORM_RECORD_CLASS
+import androidx.health.connect.client.impl.platform.records.SDK_TO_PLATFORM_RECORD_CLASS_EXT_13
+import androidx.health.connect.client.permission.HealthPermission
 import androidx.health.connect.client.readRecord
 import androidx.health.connect.client.records.BloodPressureRecord
 import androidx.health.connect.client.records.HeartRateRecord
@@ -84,21 +84,31 @@
             LocalDate.now().minusDays(5).atStartOfDay().toInstant(ZoneOffset.UTC)
         private val ZONE_OFFSET = ZoneOffset.UTC
         private val ZONE_ID = ZoneId.of(ZONE_OFFSET.id)
+
+        fun getAllRecordPermissions(): Array<String> {
+            val permissions: HashSet<String> = HashSet()
+
+            for (recordType in SDK_TO_PLATFORM_RECORD_CLASS.keys) {
+                permissions.add(HealthPermission.getReadPermission(recordType))
+                permissions.add(HealthPermission.getWritePermission(recordType))
+            }
+
+            if (SdkExtensions.getExtensionVersion(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) >= 13) {
+                for (recordType in SDK_TO_PLATFORM_RECORD_CLASS_EXT_13.keys) {
+                    permissions.add(HealthPermission.getReadPermission(recordType))
+                    permissions.add(HealthPermission.getWritePermission(recordType))
+                }
+            }
+
+            return permissions.toTypedArray()
+        }
     }
 
     private val context: Context = ApplicationProvider.getApplicationContext()
-    private val allHealthPermissions =
-        context.packageManager
-            .getPackageInfo(
-                context.packageName,
-                PackageManager.PackageInfoFlags.of(PackageManager.GET_PERMISSIONS.toLong())
-            )
-            .requestedPermissions!!
-            .filter { it.startsWith(PERMISSION_PREFIX) }
-            .toTypedArray()
 
     @get:Rule
-    val grantPermissionRule: GrantPermissionRule = GrantPermissionRule.grant(*allHealthPermissions)
+    val grantPermissionRule: GrantPermissionRule =
+        GrantPermissionRule.grant(*getAllRecordPermissions())
 
     private lateinit var healthConnectClient: HealthConnectClient
 
@@ -109,9 +119,15 @@
 
     @After
     fun tearDown() = runTest {
-        for (recordType in RECORDS_CLASS_NAME_MAP.keys) {
+        for (recordType in SDK_TO_PLATFORM_RECORD_CLASS.keys) {
             healthConnectClient.deleteRecords(recordType, TimeRangeFilter.none())
         }
+
+        if (SdkExtensions.getExtensionVersion(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) >= 13) {
+            for (recordType in SDK_TO_PLATFORM_RECORD_CLASS_EXT_13.keys) {
+                healthConnectClient.deleteRecords(recordType, TimeRangeFilter.none())
+            }
+        }
     }
 
     @Test
@@ -837,7 +853,7 @@
     @Test
     fun getGrantedPermissions() = runTest {
         assertThat(healthConnectClient.permissionController.getGrantedPermissions())
-            .containsExactlyElementsIn(allHealthPermissions)
+            .containsExactlyElementsIn(getAllRecordPermissions())
     }
 
     private fun <A, E> assertEquals(vararg assertions: Pair<A, E>) {
diff --git a/health/connect/connect-client/src/androidTest/java/androidx/health/connect/client/impl/platform/aggregate/HealthConnectClientAggregationExtensionsTest.kt b/health/connect/connect-client/src/androidTest/java/androidx/health/connect/client/impl/platform/aggregate/HealthConnectClientAggregationExtensionsTest.kt
index 653ea0c..ff31f66 100644
--- a/health/connect/connect-client/src/androidTest/java/androidx/health/connect/client/impl/platform/aggregate/HealthConnectClientAggregationExtensionsTest.kt
+++ b/health/connect/connect-client/src/androidTest/java/androidx/health/connect/client/impl/platform/aggregate/HealthConnectClientAggregationExtensionsTest.kt
@@ -18,13 +18,13 @@
 
 import android.annotation.TargetApi
 import android.content.Context
-import android.content.pm.PackageManager
 import android.os.Build
 import android.os.ext.SdkExtensions
 import androidx.health.connect.client.HealthConnectClient
 import androidx.health.connect.client.impl.HealthConnectClientUpsideDownImpl
-import androidx.health.connect.client.impl.converters.datatype.RECORDS_CLASS_NAME_MAP
-import androidx.health.connect.client.permission.HealthPermission.Companion.PERMISSION_PREFIX
+import androidx.health.connect.client.impl.platform.records.SDK_TO_PLATFORM_RECORD_CLASS
+import androidx.health.connect.client.impl.platform.records.SDK_TO_PLATFORM_RECORD_CLASS_EXT_13
+import androidx.health.connect.client.permission.HealthPermission
 import androidx.health.connect.client.records.BloodPressureRecord
 import androidx.health.connect.client.records.CyclingPedalingCadenceRecord
 import androidx.health.connect.client.records.NutritionRecord
@@ -70,26 +70,41 @@
     private companion object {
         private val START_TIME =
             LocalDate.now().minusDays(5).atStartOfDay().toInstant(ZoneOffset.UTC)
+
+        fun getAllRecordPermissions(): Array<String> {
+            val permissions: HashSet<String> = HashSet()
+
+            for (recordType in SDK_TO_PLATFORM_RECORD_CLASS.keys) {
+                permissions.add(HealthPermission.getReadPermission(recordType))
+                permissions.add(HealthPermission.getWritePermission(recordType))
+            }
+
+            if (SdkExtensions.getExtensionVersion(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) >= 13) {
+                for (recordType in SDK_TO_PLATFORM_RECORD_CLASS_EXT_13.keys) {
+                    permissions.add(HealthPermission.getReadPermission(recordType))
+                    permissions.add(HealthPermission.getWritePermission(recordType))
+                }
+            }
+
+            return permissions.toTypedArray()
+        }
     }
 
-    private val allHealthPermissions =
-        context.packageManager
-            .getPackageInfo(
-                context.packageName,
-                PackageManager.PackageInfoFlags.of(PackageManager.GET_PERMISSIONS.toLong())
-            )
-            .requestedPermissions!!
-            .filter { it.startsWith(PERMISSION_PREFIX) }
-            .toTypedArray()
-
     @get:Rule
-    val grantPermissionRule: GrantPermissionRule = GrantPermissionRule.grant(*allHealthPermissions)
+    val grantPermissionRule: GrantPermissionRule =
+        GrantPermissionRule.grant(*getAllRecordPermissions())
 
     @After
     fun tearDown() = runTest {
-        for (recordType in RECORDS_CLASS_NAME_MAP.keys) {
+        for (recordType in SDK_TO_PLATFORM_RECORD_CLASS.keys) {
             healthConnectClient.deleteRecords(recordType, TimeRangeFilter.none())
         }
+
+        if (SdkExtensions.getExtensionVersion(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) >= 13) {
+            for (recordType in SDK_TO_PLATFORM_RECORD_CLASS_EXT_13.keys) {
+                healthConnectClient.deleteRecords(recordType, TimeRangeFilter.none())
+            }
+        }
     }
 
     @Test
diff --git a/health/health-services-client-external-protobuf/OWNERS b/health/health-services-client-external-protobuf/OWNERS
new file mode 100644
index 0000000..ff2eed8
--- /dev/null
+++ b/health/health-services-client-external-protobuf/OWNERS
@@ -0,0 +1,5 @@
+# Bug component: 1126127
[email protected]
[email protected]
[email protected]
[email protected]
diff --git a/health/health-services-client-proto/OWNERS b/health/health-services-client-proto/OWNERS
new file mode 100644
index 0000000..ff2eed8
--- /dev/null
+++ b/health/health-services-client-proto/OWNERS
@@ -0,0 +1,5 @@
+# Bug component: 1126127
[email protected]
[email protected]
[email protected]
[email protected]
diff --git a/health/health-services-client-proto/src/main/proto/data.proto b/health/health-services-client-proto/src/main/proto/data.proto
index 3da3cca..c12c497 100644
--- a/health/health-services-client-proto/src/main/proto/data.proto
+++ b/health/health-services-client-proto/src/main/proto/data.proto
@@ -224,13 +224,15 @@
   repeated BatchingMode batching_mode_overrides = 11 [packed = true];
   repeated ExerciseEventType exercise_event_types = 12 [packed = true];
   repeated DebouncedGoal debounced_goals = 15;
-  reserved 9, 13, 14, 16 to max;
+  reserved 9, 13, 14, 16, 17, 18, 19;
+  reserved 20 to max; // Next ID
 }
 
 message ExerciseInfo {
   optional ExerciseTrackedStatus exercise_tracked_status = 1;
   optional ExerciseType exercise_type = 2;
-  reserved 3 to max;  // Next ID
+  reserved 3;
+  reserved 4 to max;  // Next ID
 }
 
 message ExerciseGoal {
@@ -490,7 +492,8 @@
   optional ExerciseEndReason exercise_end_reason = 11;
   repeated DebouncedGoal latest_achieved_debounced_goals = 13;
 
-  reserved 12, 14 to max;
+  reserved 12, 14;
+  reserved 15 to max;  // Next ID
 }
 
 enum ExerciseEndReason {
@@ -561,7 +564,8 @@
 message WarmUpConfig {
   optional ExerciseType exercise_type = 1;
   repeated DataType data_types = 2;
-  reserved 3 to max;  // Next ID
+  reserved 3, 4, 5, 6;
+  reserved 7 to max;  // Next ID
 }
 
 message PassiveGoal {
diff --git a/libraryversions.toml b/libraryversions.toml
index b7dbc73..082fb04 100644
--- a/libraryversions.toml
+++ b/libraryversions.toml
@@ -45,6 +45,7 @@
 CORE_SPLASHSCREEN = "1.2.0-alpha02"
 CORE_TELECOM = "1.0.0-alpha4"
 CORE_UWB = "1.0.0-alpha09"
+CORE_VIEWTREE = "1.0.0-alpha01"
 CREDENTIALS = "1.5.0-beta01"
 CREDENTIALS_E2EE_QUARANTINE = "1.0.0-alpha02"
 CREDENTIALS_FIDO_QUARANTINE = "1.0.0-alpha02"
@@ -157,8 +158,8 @@
 VIEWPAGER = "1.1.0-rc01"
 VIEWPAGER2 = "1.2.0-alpha01"
 WEAR = "1.4.0-alpha01"
-WEAR_COMPOSE = "1.5.0-alpha06"
-WEAR_COMPOSE_MATERIAL3 = "1.0.0-alpha29"
+WEAR_COMPOSE = "1.5.0-alpha07"
+WEAR_COMPOSE_MATERIAL3 = "1.0.0-alpha30"
 WEAR_CORE = "1.0.0-alpha01"
 WEAR_INPUT = "1.2.0-alpha03"
 WEAR_INPUT_TESTING = "1.2.0-alpha03"
diff --git a/lifecycle/integration-tests/incrementality/build.gradle b/lifecycle/integration-tests/incrementality/build.gradle
index 7a8cfcf..2ab870e 100644
--- a/lifecycle/integration-tests/incrementality/build.gradle
+++ b/lifecycle/integration-tests/incrementality/build.gradle
@@ -44,6 +44,7 @@
             ":lifecycle:lifecycle-compiler:publish",
             ":lifecycle:lifecycle-common:publish",
             ":lifecycle:lifecycle-runtime:publish",
+            ":core:core-viewtree:publish",
             ":annotation:annotation:publish",
             ":arch:core:core-common:publish",
             ":arch:core:core-runtime:publish"
diff --git a/lifecycle/lifecycle-livedata-core-lint/build.gradle b/lifecycle/lifecycle-livedata-core-lint/build.gradle
index ac05ab8..4884d3c 100644
--- a/lifecycle/lifecycle-livedata-core-lint/build.gradle
+++ b/lifecycle/lifecycle-livedata-core-lint/build.gradle
@@ -29,12 +29,12 @@
 }
 
 dependencies {
-    compileOnly(libs.androidLintPrevApi)
+    compileOnly(libs.androidLintApiPrevAnalysis)
     compileOnly(libs.kotlinStdlib)
 
     testImplementation(libs.kotlinStdlib)
-    testImplementation(libs.androidLintPrev)
-    testImplementation(libs.androidLintPrevTests)
+    testImplementation(libs.androidLintPrevAnalysis)
+    testImplementation(libs.androidLintTestsPrevAnalysis)
     testImplementation(libs.junit)
 }
 
diff --git a/lifecycle/lifecycle-viewmodel-navigation3/build.gradle b/lifecycle/lifecycle-viewmodel-navigation3/build.gradle
index 90b6646..4f4b58b 100644
--- a/lifecycle/lifecycle-viewmodel-navigation3/build.gradle
+++ b/lifecycle/lifecycle-viewmodel-navigation3/build.gradle
@@ -41,11 +41,12 @@
     sourceSets {
         commonMain {
             dependencies {
+                api("androidx.lifecycle:lifecycle-viewmodel:2.8.7")
+                api("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
+
                 implementation(libs.kotlinStdlib)
                 implementation("androidx.compose.runtime:runtime:1.7.5")
                 implementation("androidx.compose.runtime:runtime-saveable:1.7.5")
-                implementation("androidx.lifecycle:lifecycle-viewmodel:2.8.7")
-                implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
             }
         }
 
diff --git a/mediarouter/mediarouter/api/current.txt b/mediarouter/mediarouter/api/current.txt
index 45aff505..6ed85777 100644
--- a/mediarouter/mediarouter/api/current.txt
+++ b/mediarouter/mediarouter/api/current.txt
@@ -514,6 +514,7 @@
   public class MediaRouterParams {
     method public int getDialogType();
     method public boolean isMediaTransferReceiverEnabled();
+    method public boolean isMediaTransferRestrictedToSelfProviders();
     method public boolean isOutputSwitcherEnabled();
     method public boolean isTransferToLocalEnabled();
     field public static final int DIALOG_TYPE_DEFAULT = 1; // 0x1
@@ -527,6 +528,7 @@
     method public androidx.mediarouter.media.MediaRouterParams build();
     method public androidx.mediarouter.media.MediaRouterParams.Builder setDialogType(int);
     method public androidx.mediarouter.media.MediaRouterParams.Builder setMediaTransferReceiverEnabled(boolean);
+    method public androidx.mediarouter.media.MediaRouterParams.Builder setMediaTransferRestrictedToSelfProviders(boolean);
     method public androidx.mediarouter.media.MediaRouterParams.Builder setOutputSwitcherEnabled(boolean);
     method public androidx.mediarouter.media.MediaRouterParams.Builder setTransferToLocalEnabled(boolean);
   }
diff --git a/mediarouter/mediarouter/api/restricted_current.txt b/mediarouter/mediarouter/api/restricted_current.txt
index 45aff505..6ed85777 100644
--- a/mediarouter/mediarouter/api/restricted_current.txt
+++ b/mediarouter/mediarouter/api/restricted_current.txt
@@ -514,6 +514,7 @@
   public class MediaRouterParams {
     method public int getDialogType();
     method public boolean isMediaTransferReceiverEnabled();
+    method public boolean isMediaTransferRestrictedToSelfProviders();
     method public boolean isOutputSwitcherEnabled();
     method public boolean isTransferToLocalEnabled();
     field public static final int DIALOG_TYPE_DEFAULT = 1; // 0x1
@@ -527,6 +528,7 @@
     method public androidx.mediarouter.media.MediaRouterParams build();
     method public androidx.mediarouter.media.MediaRouterParams.Builder setDialogType(int);
     method public androidx.mediarouter.media.MediaRouterParams.Builder setMediaTransferReceiverEnabled(boolean);
+    method public androidx.mediarouter.media.MediaRouterParams.Builder setMediaTransferRestrictedToSelfProviders(boolean);
     method public androidx.mediarouter.media.MediaRouterParams.Builder setOutputSwitcherEnabled(boolean);
     method public androidx.mediarouter.media.MediaRouterParams.Builder setTransferToLocalEnabled(boolean);
   }
diff --git a/mediarouter/mediarouter/src/androidTest/AndroidManifest.xml b/mediarouter/mediarouter/src/androidTest/AndroidManifest.xml
index 7961fb1..787c415 100644
--- a/mediarouter/mediarouter/src/androidTest/AndroidManifest.xml
+++ b/mediarouter/mediarouter/src/androidTest/AndroidManifest.xml
@@ -54,7 +54,8 @@
             android:exported="true"
             android:enabled="true">
             <intent-filter>
-                <action android:name="aandroid.media.MediaRouteProviderService"/>
+                <action android:name="android.media.MediaRouteProviderService"/>
+                <action android:name="android.media.MediaRoute2ProviderService"/>
             </intent-filter>
         </service>
     </application>
diff --git a/mediarouter/mediarouter/src/androidTest/java/androidx/mediarouter/media/MediaRouteProviderServiceTest.java b/mediarouter/mediarouter/src/androidTest/java/androidx/mediarouter/media/MediaRouteProviderServiceTest.java
index 9fd3cc8..032e7b9 100644
--- a/mediarouter/mediarouter/src/androidTest/java/androidx/mediarouter/media/MediaRouteProviderServiceTest.java
+++ b/mediarouter/mediarouter/src/androidTest/java/androidx/mediarouter/media/MediaRouteProviderServiceTest.java
@@ -25,6 +25,7 @@
 
 import android.content.Context;
 import android.content.Intent;
+import android.os.Build;
 import android.os.Bundle;
 import android.os.Handler;
 import android.os.IBinder;
@@ -37,6 +38,7 @@
 import androidx.test.core.app.ApplicationProvider;
 import androidx.test.ext.junit.runners.AndroidJUnit4;
 import androidx.test.filters.LargeTest;
+import androidx.test.filters.SdkSuppress;
 import androidx.test.filters.SmallTest;
 import androidx.test.rule.ServiceTestRule;
 
@@ -192,8 +194,10 @@
 
     @LargeTest
     @Test
-    public void testSetEmptyPassiveDiscoveryRequest_shouldNotRequestScan() throws Exception {
-        sendDiscoveryRequest(mReceiveMessenger1,
+    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.S)
+    public void setEmptyPassiveDiscoveryRequest_shouldNotScan() throws Exception {
+        sendDiscoveryRequest(
+                mReceiveMessenger1,
                 new MediaRouteDiscoveryRequest(MediaRouteSelector.EMPTY, false));
 
         Thread.sleep(TIME_OUT_MS);
diff --git a/mediarouter/mediarouter/src/androidTest/java/androidx/mediarouter/media/MediaRouterParamsTest.java b/mediarouter/mediarouter/src/androidTest/java/androidx/mediarouter/media/MediaRouterParamsTest.java
new file mode 100644
index 0000000..98bf620
--- /dev/null
+++ b/mediarouter/mediarouter/src/androidTest/java/androidx/mediarouter/media/MediaRouterParamsTest.java
@@ -0,0 +1,120 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.mediarouter.media;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertSame;
+
+import android.os.Build;
+import android.os.Bundle;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SdkSuppress;
+import androidx.test.filters.SmallTest;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/** Test {@link MediaRouterParams}. */
+@RunWith(AndroidJUnit4.class)
+public class MediaRouterParamsTest {
+
+    private static final String TEST_KEY = "test_key";
+    private static final String TEST_VALUE = "test_value";
+
+    @Test
+    @SmallTest
+    @SdkSuppress(maxSdkVersion = Build.VERSION_CODES.Q)
+    public void mediaRouterParamsBuilder_androidQOrBelow() {
+        verifyMediaRouterParamsBuilder(/* isAndroidROrAbove= */ false);
+    }
+
+    @Test
+    @SmallTest
+    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.R)
+    public void mediaRouterParamsBuilder_androidROrAbove() {
+        verifyMediaRouterParamsBuilder(/* isAndroidROrAbove= */ true);
+    }
+
+    private void verifyMediaRouterParamsBuilder(boolean isAndroidROrAbove) {
+        final int dialogType = MediaRouterParams.DIALOG_TYPE_DYNAMIC_GROUP;
+        final boolean isOutputSwitcherEnabled = true;
+        final boolean transferToLocalEnabled = true;
+        final boolean transferReceiverEnabled = false;
+        final boolean mediaTransferRestrictedToSelfProviders = true;
+        final Bundle extras = new Bundle();
+        extras.putString(TEST_KEY, TEST_VALUE);
+
+        MediaRouterParams params =
+                new MediaRouterParams.Builder()
+                        .setDialogType(dialogType)
+                        .setOutputSwitcherEnabled(isOutputSwitcherEnabled)
+                        .setTransferToLocalEnabled(transferToLocalEnabled)
+                        .setMediaTransferReceiverEnabled(transferReceiverEnabled)
+                        .setMediaTransferRestrictedToSelfProviders(
+                                mediaTransferRestrictedToSelfProviders)
+                        .setExtras(extras)
+                        .build();
+
+        assertEquals(dialogType, params.getDialogType());
+
+        if (isAndroidROrAbove) {
+            assertEquals(isOutputSwitcherEnabled, params.isOutputSwitcherEnabled());
+            assertEquals(transferToLocalEnabled, params.isTransferToLocalEnabled());
+            assertEquals(transferReceiverEnabled, params.isMediaTransferReceiverEnabled());
+            assertEquals(
+                    mediaTransferRestrictedToSelfProviders,
+                    params.isMediaTransferRestrictedToSelfProviders());
+        } else {
+            // Earlier than Android R, output switcher cannot be enabled.
+            // Same for transfer to local.
+            assertFalse(params.isOutputSwitcherEnabled());
+            assertFalse(params.isTransferToLocalEnabled());
+            assertFalse(params.isMediaTransferReceiverEnabled());
+            assertFalse(params.isMediaTransferRestrictedToSelfProviders());
+        }
+
+        extras.remove(TEST_KEY);
+        assertEquals(TEST_VALUE, params.getExtras().getString(TEST_KEY));
+
+        // Tests copy constructor of builder
+        MediaRouterParams copiedParams = new MediaRouterParams.Builder(params).build();
+        assertEquals(params.getDialogType(), copiedParams.getDialogType());
+        assertEquals(params.isOutputSwitcherEnabled(), copiedParams.isOutputSwitcherEnabled());
+        assertEquals(params.isTransferToLocalEnabled(), copiedParams.isTransferToLocalEnabled());
+        assertEquals(
+                params.isMediaTransferReceiverEnabled(),
+                copiedParams.isMediaTransferReceiverEnabled());
+        assertEquals(
+                params.isMediaTransferRestrictedToSelfProviders(),
+                copiedParams.isMediaTransferRestrictedToSelfProviders());
+        assertBundleEquals(params.getExtras(), copiedParams.getExtras());
+    }
+
+    /** Asserts that two Bundles are equal. */
+    @SuppressWarnings("deprecation")
+    public static void assertBundleEquals(Bundle expected, Bundle observed) {
+        if (expected == null || observed == null) {
+            assertSame(expected, observed);
+        }
+        assertEquals(expected.size(), observed.size());
+        for (String key : expected.keySet()) {
+            assertEquals(expected.get(key), observed.get(key));
+        }
+    }
+}
diff --git a/mediarouter/mediarouter/src/androidTest/java/androidx/mediarouter/media/MediaRouterTest.java b/mediarouter/mediarouter/src/androidTest/java/androidx/mediarouter/media/MediaRouterTest.java
index 6f7b964..6a54716 100644
--- a/mediarouter/mediarouter/src/androidTest/java/androidx/mediarouter/media/MediaRouterTest.java
+++ b/mediarouter/mediarouter/src/androidTest/java/androidx/mediarouter/media/MediaRouterTest.java
@@ -24,7 +24,6 @@
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertNull;
-import static org.junit.Assert.assertSame;
 import static org.junit.Assert.assertTrue;
 
 import android.content.Context;
@@ -37,6 +36,7 @@
 import androidx.test.annotation.UiThreadTest;
 import androidx.test.ext.junit.runners.AndroidJUnit4;
 import androidx.test.filters.LargeTest;
+import androidx.test.filters.SdkSuppress;
 import androidx.test.filters.SmallTest;
 
 import org.junit.After;
@@ -70,7 +70,7 @@
     private CountDownLatch mPassiveScanCountDownLatch;
 
     @Before
-    public void setUp() throws Exception {
+    public void setUp() {
         resetActiveAndPassiveScanCountDownLatches();
         getInstrumentation()
                 .runOnMainSync(
@@ -80,10 +80,11 @@
                             mSession = new MediaSessionCompat(mContext, SESSION_TAG);
                             mProvider = new MediaRouteProviderImpl(mContext);
                         });
+        assertTrue(MediaTransferReceiver.isDeclared(mContext));
     }
 
     @After
-    public void tearDown() throws Exception {
+    public void tearDown() {
         mSession.release();
         getInstrumentation().runOnMainSync(() -> MediaRouterTestHelper.resetMediaRouter());
     }
@@ -119,67 +120,26 @@
 
     @Test
     @SmallTest
-    public void mediaRouterParamsBuilder() {
-        final int dialogType = MediaRouterParams.DIALOG_TYPE_DYNAMIC_GROUP;
-        final boolean isOutputSwitcherEnabled = true;
-        final boolean transferToLocalEnabled = true;
-        final boolean transferReceiverEnabled = false;
-        final Bundle extras = new Bundle();
-        extras.putString(TEST_KEY, TEST_VALUE);
-
-        MediaRouterParams params = new MediaRouterParams.Builder()
-                .setDialogType(dialogType)
-                .setOutputSwitcherEnabled(isOutputSwitcherEnabled)
-                .setTransferToLocalEnabled(transferToLocalEnabled)
-                .setMediaTransferReceiverEnabled(transferReceiverEnabled)
-                .setExtras(extras)
-                .build();
-
-        assertEquals(dialogType, params.getDialogType());
-
-        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
-            assertEquals(isOutputSwitcherEnabled, params.isOutputSwitcherEnabled());
-            assertEquals(transferToLocalEnabled, params.isTransferToLocalEnabled());
-            assertEquals(transferReceiverEnabled, params.isMediaTransferReceiverEnabled());
-        } else {
-            // Earlier than Android R, output switcher cannot be enabled.
-            // Same for transfer to local.
-            assertFalse(params.isOutputSwitcherEnabled());
-            assertFalse(params.isTransferToLocalEnabled());
-            assertFalse(params.isMediaTransferReceiverEnabled());
-        }
-
-        extras.remove(TEST_KEY);
-        assertEquals(TEST_VALUE, params.getExtras().getString(TEST_KEY));
-
-        // Tests copy constructor of builder
-        MediaRouterParams copiedParams = new MediaRouterParams.Builder(params).build();
-        assertEquals(params.getDialogType(), copiedParams.getDialogType());
-        assertEquals(params.isOutputSwitcherEnabled(), copiedParams.isOutputSwitcherEnabled());
-        assertEquals(params.isTransferToLocalEnabled(), copiedParams.isTransferToLocalEnabled());
-        assertEquals(params.isMediaTransferReceiverEnabled(),
-                copiedParams.isMediaTransferReceiverEnabled());
-        assertBundleEquals(params.getExtras(), copiedParams.getExtras());
-    }
-
-    @Test
-    @SmallTest
     @UiThreadTest
     public void getRouterParams_afterSetRouterParams_returnsSetParams() {
         final int dialogType = MediaRouterParams.DIALOG_TYPE_DYNAMIC_GROUP;
         final boolean isOutputSwitcherEnabled = true;
         final boolean transferToLocalEnabled = true;
         final boolean transferReceiverEnabled = false;
+        final boolean mediaTransferRestrictedToSelfProviders = true;
         final Bundle paramExtras = new Bundle();
         paramExtras.putString(TEST_KEY, TEST_VALUE);
 
-        MediaRouterParams expectedParams = new MediaRouterParams.Builder()
-                .setDialogType(dialogType)
-                .setOutputSwitcherEnabled(isOutputSwitcherEnabled)
-                .setTransferToLocalEnabled(transferToLocalEnabled)
-                .setMediaTransferReceiverEnabled(transferReceiverEnabled)
-                .setExtras(paramExtras)
-                .build();
+        MediaRouterParams expectedParams =
+                new MediaRouterParams.Builder()
+                        .setDialogType(dialogType)
+                        .setOutputSwitcherEnabled(isOutputSwitcherEnabled)
+                        .setTransferToLocalEnabled(transferToLocalEnabled)
+                        .setMediaTransferReceiverEnabled(transferReceiverEnabled)
+                        .setMediaTransferRestrictedToSelfProviders(
+                                mediaTransferRestrictedToSelfProviders)
+                        .setExtras(paramExtras)
+                        .build();
 
         paramExtras.remove(TEST_KEY);
         mRouter.setRouterParams(expectedParams);
@@ -188,6 +148,29 @@
         assertEquals(expectedParams, actualParams);
     }
 
+    @SmallTest
+    @Test
+    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.R)
+    public void setRouterParams_shouldSetMediaTransferRestrictToSelfProviders() {
+        MediaRouterParams params =
+                new MediaRouterParams.Builder()
+                        .setMediaTransferRestrictedToSelfProviders(true)
+                        .build();
+        getInstrumentation()
+                .runOnMainSync(
+                        () -> {
+                            mRouter.setRouterParams(params);
+                        });
+        assertTrue(
+                MediaRouter.getGlobalRouter()
+                        .mRegisteredProviderWatcher
+                        .isMediaTransferRestrictedToSelfProvidersForTesting());
+        assertTrue(
+                MediaRouter.getGlobalRouter()
+                        .getMediaRoute2ProviderForTesting()
+                        .isMediaTransferRestrictedToSelfProviders());
+    }
+
     @Test
     @LargeTest
     public void testRegisterActiveScanCallback_suppressActiveScanAfter30Seconds() throws Exception {
@@ -289,20 +272,6 @@
         assertFalse(newInstance.getRoutes().isEmpty());
     }
 
-    /**
-     * Asserts that two Bundles are equal.
-     */
-    @SuppressWarnings("deprecation")
-    public static void assertBundleEquals(Bundle expected, Bundle observed) {
-        if (expected == null || observed == null) {
-            assertSame(expected, observed);
-        }
-        assertEquals(expected.size(), observed.size());
-        for (String key : expected.keySet()) {
-            assertEquals(expected.get(key), observed.get(key));
-        }
-    }
-
     private class MediaSessionCallback extends MediaSessionCompat.Callback {
         private boolean mOnPlayCalled;
         private boolean mOnPauseCalled;
@@ -348,7 +317,6 @@
                 }
             }
         }
-
     }
 
     private void resetActiveAndPassiveScanCountDownLatches() {
diff --git a/mediarouter/mediarouter/src/androidTest/java/androidx/mediarouter/media/RegisteredMediaRouteProviderWatcherTest.java b/mediarouter/mediarouter/src/androidTest/java/androidx/mediarouter/media/RegisteredMediaRouteProviderWatcherTest.java
new file mode 100644
index 0000000..a132e52
--- /dev/null
+++ b/mediarouter/mediarouter/src/androidTest/java/androidx/mediarouter/media/RegisteredMediaRouteProviderWatcherTest.java
@@ -0,0 +1,100 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.mediarouter.media;
+
+import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import android.content.Context;
+import android.content.pm.ServiceInfo;
+import android.os.Build;
+import android.text.TextUtils;
+
+import androidx.annotation.NonNull;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SdkSuppress;
+import androidx.test.filters.SmallTest;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.util.List;
+
+/** Test {@link RegisteredMediaRouteProviderWatcher}. */
+@RunWith(AndroidJUnit4.class)
+public class RegisteredMediaRouteProviderWatcherTest {
+    private Context mContext;
+    private RegisteredMediaRouteProviderWatcher mProviderWatcher;
+
+    @Before
+    public void setUp() {
+        mContext = ApplicationProvider.getApplicationContext();
+        RegisteredMediaRouteProviderWatcher.Callback callback =
+                new RegisteredMediaRouteProviderWatcher.Callback() {
+                    @Override
+                    public void addProvider(@NonNull MediaRouteProvider provider) {}
+
+                    @Override
+                    public void removeProvider(@NonNull MediaRouteProvider provider) {}
+
+                    @Override
+                    public void releaseProviderController(
+                            @NonNull RegisteredMediaRouteProvider provider,
+                            @NonNull MediaRouteProvider.RouteController controller) {}
+                };
+
+        getInstrumentation()
+                .runOnMainSync(
+                        () -> {
+                            mProviderWatcher =
+                                    new RegisteredMediaRouteProviderWatcher(mContext, callback);
+                        });
+    }
+
+    @SmallTest
+    @Test
+    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.R)
+    public void getMediaRoute2ProviderServices_restrictedToSelfProviders_shouldGetSelfProviders() {
+        mProviderWatcher.setMediaTransferRestrictedToSelfProviders(true);
+        assertTrue(mProviderWatcher.isMediaTransferRestrictedToSelfProvidersForTesting());
+        List<ServiceInfo> serviceInfos = mProviderWatcher.getMediaRoute2ProviderServices();
+        assertTrue(isSelfProvidersContained(serviceInfos));
+    }
+
+    @SmallTest
+    @Test
+    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.R)
+    public void getMediaRoute2ProviderServices_notRestrictedToSelfProviders_shouldGetAllProvider() {
+        mProviderWatcher.setMediaTransferRestrictedToSelfProviders(false);
+        assertFalse(mProviderWatcher.isMediaTransferRestrictedToSelfProvidersForTesting());
+        List<ServiceInfo> serviceInfos = mProviderWatcher.getMediaRoute2ProviderServices();
+        assertTrue(isSelfProvidersContained(serviceInfos));
+    }
+
+    private boolean isSelfProvidersContained(List<ServiceInfo> serviceInfos) {
+        for (ServiceInfo serviceInfo : serviceInfos) {
+            if (TextUtils.equals(serviceInfo.packageName, mContext.getPackageName())) {
+                return true;
+            }
+        }
+        return false;
+    }
+}
diff --git a/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/GlobalMediaRouter.java b/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/GlobalMediaRouter.java
index f95695e..f24905e 100644
--- a/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/GlobalMediaRouter.java
+++ b/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/GlobalMediaRouter.java
@@ -285,8 +285,13 @@
                 addProvider(mMr2Provider, /* treatRouteDescriptorIdsAsUnique= */ true);
                 // Make sure mDiscoveryRequestForMr2Provider is updated
                 updateDiscoveryRequest();
-                mRegisteredProviderWatcher.rescan();
             }
+            boolean mediaTransferRestrictedToSelfProviders =
+                    params != null && params.isMediaTransferRestrictedToSelfProviders();
+            mMr2Provider.setMediaTransferRestrictedToSelfProviders(
+                    mediaTransferRestrictedToSelfProviders);
+            mRegisteredProviderWatcher.setMediaTransferRestrictedToSelfProviders(
+                    mediaTransferRestrictedToSelfProviders);
 
             boolean oldTransferToLocalEnabled =
                     oldParams != null && oldParams.isTransferToLocalEnabled();
@@ -1343,9 +1348,13 @@
         }
     }
 
+    @VisibleForTesting
+    /* package */ MediaRoute2Provider getMediaRoute2ProviderForTesting() {
+        return mMr2Provider;
+    }
+
     private final class ProviderCallback extends MediaRouteProvider.Callback {
-        ProviderCallback() {
-        }
+        ProviderCallback() {}
 
         @Override
         public void onDescriptorChanged(
diff --git a/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/MediaRoute2Provider.java b/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/MediaRoute2Provider.java
index eb1009c..654df9b 100644
--- a/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/MediaRoute2Provider.java
+++ b/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/MediaRoute2Provider.java
@@ -47,6 +47,7 @@
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
+import androidx.annotation.VisibleForTesting;
 import androidx.mediarouter.R;
 import androidx.mediarouter.media.MediaRouteProvider.DynamicGroupRouteController.DynamicRouteDescriptor;
 import androidx.mediarouter.media.MediaRouter.ControlRequestCallback;
@@ -67,6 +68,7 @@
 class MediaRoute2Provider extends MediaRouteProvider {
     static final String TAG = "MR2Provider";
     static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG);
+    private static final String PACKAGE_NAME_SEPARATOR = "/";
 
     final MediaRouter2 mMediaRouter2;
     final Callback mCallback;
@@ -77,9 +79,10 @@
     private final MediaRouter2.ControllerCallback mControllerCallback = new ControllerCallback();
     private final Handler mHandler;
     private final Executor mHandlerExecutor;
-
+    private boolean mMediaTransferRestrictedToSelfProviders;
     private List<MediaRoute2Info> mRoutes = new ArrayList<>();
     private Map<String, String> mRouteIdToOriginalRouteIdMap = new ArrayMap<>();
+
     @SuppressWarnings({"SyntheticAccessor"})
     MediaRoute2Provider(@NonNull Context context, @NonNull Callback callback) {
         super(context);
@@ -153,6 +156,17 @@
         return null;
     }
 
+    /* package */ void setMediaTransferRestrictedToSelfProviders(
+            boolean mediaTransferRestrictedToSelfProviders) {
+        mMediaTransferRestrictedToSelfProviders = mediaTransferRestrictedToSelfProviders;
+        refreshRoutes();
+    }
+
+    @VisibleForTesting
+    /* package */ boolean isMediaTransferRestrictedToSelfProviders() {
+        return mMediaTransferRestrictedToSelfProviders;
+    }
+
     public void transferTo(@NonNull String routeId) {
         MediaRoute2Info route = getRouteById(routeId);
         if (route == null) {
@@ -171,6 +185,17 @@
             if (route == null || route2InfoSet.contains(route) || route.isSystemRoute()) {
                 continue;
             }
+
+            if (mMediaTransferRestrictedToSelfProviders) {
+                // The routeId is created by Android framework with the provider's package name.
+                boolean isRoutePublishedBySelfProviders =
+                        route.getId()
+                                .startsWith(getContext().getPackageName() + PACKAGE_NAME_SEPARATOR);
+                if (!isRoutePublishedBySelfProviders) {
+                    continue;
+                }
+            }
+
             route2InfoSet.add(route);
 
             // Not using new ArrayList(route2InfoSet) here for preserving the order.
@@ -374,8 +399,9 @@
     }
 
     abstract static class Callback {
-        public abstract void onSelectRoute(@NonNull String routeDescriptorId,
-                @MediaRouter.UnselectReason int reason);
+        public abstract void onSelectRoute(
+                @NonNull String routeDescriptorId, @MediaRouter.UnselectReason int reason);
+
         public abstract void onSelectFallbackRoute(@MediaRouter.UnselectReason int reason);
 
         public abstract void onReleaseController(@NonNull RouteController controller);
diff --git a/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/MediaRouterParams.java b/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/MediaRouterParams.java
index a799b00..7883ff3 100644
--- a/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/MediaRouterParams.java
+++ b/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/MediaRouterParams.java
@@ -72,11 +72,11 @@
     public static final String EXTRAS_KEY_FIXED_CAST_ICON =
             "androidx.mediarouter.media.MediaRouterParams.FIXED_CAST_ICON";
 
-    @DialogType
-    final int mDialogType;
+    @DialogType final int mDialogType;
     final boolean mMediaTransferReceiverEnabled;
     final boolean mOutputSwitcherEnabled;
     final boolean mTransferToLocalEnabled;
+    final boolean mMediaTransferRestrictedToSelfProviders;
     final Bundle mExtras;
 
     MediaRouterParams(@NonNull Builder builder) {
@@ -84,6 +84,7 @@
         mMediaTransferReceiverEnabled = builder.mMediaTransferEnabled;
         mOutputSwitcherEnabled = builder.mOutputSwitcherEnabled;
         mTransferToLocalEnabled = builder.mTransferToLocalEnabled;
+        mMediaTransferRestrictedToSelfProviders = builder.mMediaTransferRestrictedToSelfProviders;
 
         Bundle extras = builder.mExtras;
         mExtras = extras == null ? Bundle.EMPTY : new Bundle(extras);
@@ -120,8 +121,8 @@
 
     /**
      * Returns whether transferring media from remote to local is enabled.
-     * <p>
-     * Note that it always returns {@code false} for Android versions earlier than Android R.
+     *
+     * <p>Note that it always returns {@code false} for Android versions earlier than Android R.
      *
      * @see Builder#setTransferToLocalEnabled(boolean)
      */
@@ -130,7 +131,16 @@
     }
 
     /**
+     * Returns whether the declared {@link MediaTransferReceiver} feature is restricted to the app's
+     * own media route providers.
+     *
+     * @see Builder#setMediaTransferRestrictedToSelfProviders(boolean)
      */
+    public boolean isMediaTransferRestrictedToSelfProviders() {
+        return mMediaTransferRestrictedToSelfProviders;
+    }
+
+    /** */
     @NonNull
     @RestrictTo(RestrictTo.Scope.LIBRARY)
     public Bundle getExtras() {
@@ -141,21 +151,19 @@
      * Builder class for {@link MediaRouterParams}.
      */
     public static final class Builder {
-        @DialogType
-        int mDialogType = DIALOG_TYPE_DEFAULT;
+        @DialogType int mDialogType = DIALOG_TYPE_DEFAULT;
         boolean mMediaTransferEnabled = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R;
         boolean mOutputSwitcherEnabled;
         boolean mTransferToLocalEnabled;
+        boolean mMediaTransferRestrictedToSelfProviders;
         Bundle mExtras;
 
-        /**
-         * Constructor for builder to create {@link MediaRouterParams}.
-         */
+        /** Constructor for builder to create {@link MediaRouterParams}. */
         public Builder() {}
 
         /**
-         * Constructor for builder to create {@link MediaRouterParams} with existing
-         * {@link MediaRouterParams} instance.
+         * Constructor for builder to create {@link MediaRouterParams} with existing {@link
+         * MediaRouterParams} instance.
          *
          * @param params the existing instance to copy data from.
          */
@@ -168,15 +176,17 @@
             mOutputSwitcherEnabled = params.mOutputSwitcherEnabled;
             mTransferToLocalEnabled = params.mTransferToLocalEnabled;
             mMediaTransferEnabled = params.mMediaTransferReceiverEnabled;
+            mMediaTransferRestrictedToSelfProviders =
+                    params.mMediaTransferRestrictedToSelfProviders;
             mExtras = params.mExtras == null ? null : new Bundle(params.mExtras);
         }
 
         /**
-         * Sets the media route controller dialog type. Default value is
-         * {@link #DIALOG_TYPE_DEFAULT}.
-         * <p>
-         * Note that from Android R, output switcher will be used rather than the dialog type set by
-         * this method if both {@link #setOutputSwitcherEnabled(boolean)} output switcher} and
+         * Sets the media route controller dialog type. Default value is {@link
+         * #DIALOG_TYPE_DEFAULT}.
+         *
+         * <p>Note that from Android R, output switcher will be used rather than the dialog type set
+         * by this method if both {@link #setOutputSwitcherEnabled(boolean)} output switcher} and
          * {@link MediaTransferReceiver media transfer feature} are enabled.
          *
          * @param dialogType the dialog type
@@ -255,9 +265,30 @@
         }
 
         /**
-         * Set extras. Default value is {@link Bundle#EMPTY} if not set.
+         * Sets whether the declared {@link MediaTransferReceiver} feature is restricted to {@link
+         * MediaRouteProviderService} provider services that handle the action {@code
+         * android.media.MediaRoute2ProviderService} declared by this app.
          *
+         * <p>If this app restricts the {@link MediaTransferReceiver} feature to its own {@link
+         * MediaRouteProviderService} provider service that handles the action {@code
+         * android.media.MediaRoute2ProviderService}, then all other media route providers that
+         * declare both the {@code android.media.MediaRouteProviderService} action and the {@code
+         * android.media.MediaRoute2ProviderService} action would be treated as {@link
+         * MediaRouteProviderService} provider services with only the action {@code
+         * android.media.MediaRouteProviderService}.
+         *
+         * <p>For {@link MediaRouteProviderService} provider services that only handle the action
+         * {@code android.media.MediaRouteProviderService}, they are not affected by this flag.
          */
+        @NonNull
+        public Builder setMediaTransferRestrictedToSelfProviders(boolean enabled) {
+            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+                mMediaTransferRestrictedToSelfProviders = enabled;
+            }
+            return this;
+        }
+
+        /** Set extras. Default value is {@link Bundle#EMPTY} if not set. */
         @RestrictTo(RestrictTo.Scope.LIBRARY)
         @NonNull
         public Builder setExtras(@Nullable Bundle extras) {
@@ -265,9 +296,7 @@
             return this;
         }
 
-        /**
-         * Builds the {@link MediaRouterParams} instance.
-         */
+        /** Builds the {@link MediaRouterParams} instance. */
         @NonNull
         public MediaRouterParams build() {
             return new MediaRouterParams(this);
diff --git a/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/RegisteredMediaRouteProviderWatcher.java b/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/RegisteredMediaRouteProviderWatcher.java
index f80c24e0..82c5ca3 100644
--- a/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/RegisteredMediaRouteProviderWatcher.java
+++ b/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/RegisteredMediaRouteProviderWatcher.java
@@ -27,9 +27,11 @@
 import android.media.MediaRoute2ProviderService;
 import android.os.Build;
 import android.os.Handler;
+import android.text.TextUtils;
 
 import androidx.annotation.NonNull;
 import androidx.annotation.RequiresApi;
+import androidx.annotation.VisibleForTesting;
 
 import java.util.ArrayList;
 import java.util.Collections;
@@ -48,6 +50,7 @@
     private final PackageManager mPackageManager;
 
     private final ArrayList<RegisteredMediaRouteProvider> mProviders = new ArrayList<>();
+    private boolean mMediaTransferRestrictedToSelfProviders;
     private boolean mRunning;
 
     RegisteredMediaRouteProviderWatcher(Context context, Callback callback) {
@@ -97,6 +100,17 @@
         }
     }
 
+    /* package */ void setMediaTransferRestrictedToSelfProviders(
+            boolean mediaTransferRestrictedToSelfProviders) {
+        mMediaTransferRestrictedToSelfProviders = mediaTransferRestrictedToSelfProviders;
+        rescan();
+    }
+
+    @VisibleForTesting
+    /* package */ boolean isMediaTransferRestrictedToSelfProvidersForTesting() {
+        return mMediaTransferRestrictedToSelfProviders;
+    }
+
     void scanPackages() {
         if (!mRunning) {
             return;
@@ -171,7 +185,13 @@
 
         List<ServiceInfo> serviceInfoList = new ArrayList<>();
         for (ResolveInfo resolveInfo : mPackageManager.queryIntentServices(intent, 0)) {
-            serviceInfoList.add(resolveInfo.serviceInfo);
+            ServiceInfo serviceInfo = resolveInfo.serviceInfo;
+            if (mMediaTransferRestrictedToSelfProviders
+                    && !TextUtils.equals(mContext.getPackageName(), serviceInfo.packageName)) {
+                // The app only allows its own Media Router provider to be a MediaRoute2 provider.
+                continue;
+            }
+            serviceInfoList.add(serviceInfo);
         }
         return serviceInfoList;
     }
diff --git a/navigation/navigation-common-lint/build.gradle b/navigation/navigation-common-lint/build.gradle
index 46b47fd..afa7352 100644
--- a/navigation/navigation-common-lint/build.gradle
+++ b/navigation/navigation-common-lint/build.gradle
@@ -35,14 +35,14 @@
 dependencies {
     compileOnly(libs.kotlinCompiler)
     compileOnly(libs.kotlinStdlib)
-    compileOnly(libs.androidLintPrevApi)
+    compileOnly(libs.androidLintApiPrevAnalysis)
     compileOnly(libs.intellijCore)
     compileOnly(libs.uast)
 
     bundleInside(project(":navigation:navigation-lint-common"))
 
-    testImplementation(libs.androidLintPrev)
-    testImplementation(libs.androidLintPrevTests)
+    testImplementation(libs.androidLintPrevAnalysis)
+    testImplementation(libs.androidLintTestsPrevAnalysis)
     testImplementation(libs.junit)
     testImplementation(libs.truth)
 }
diff --git a/navigation/navigation-common/src/main/java/androidx/navigation/NavGraph.kt b/navigation/navigation-common/src/main/java/androidx/navigation/NavGraph.kt
index 5fbe69d..f1454d9 100644
--- a/navigation/navigation-common/src/main/java/androidx/navigation/NavGraph.kt
+++ b/navigation/navigation-common/src/main/java/androidx/navigation/NavGraph.kt
@@ -238,16 +238,32 @@
      * Searches all children and parents recursively.
      *
      * Does not revisit graphs (whether it's a child or parent) if it has already been visited.
+     *
+     * @param resId the [NavDestination.id]
+     * @param lastVisited the previously visited node
+     * @param searchChildren searches the graph's children for the node when true
+     * @param matchingDest an optional NavDestination that the node should match with. This is
+     *   because [resId] is only unique to a local graph. Nodes in sibling graphs can have the same
+     *   id.
      */
     @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
     public fun findNodeComprehensive(
         @IdRes resId: Int,
         lastVisited: NavDestination?,
-        searchChildren: Boolean
+        searchChildren: Boolean,
+        matchingDest: NavDestination? = null,
     ): NavDestination? {
         // first search direct children
         var destination = nodes[resId]
-        if (destination != null) return destination
+        when {
+            matchingDest != null ->
+                // check parent in case of duplicated destinations to ensure it finds the correct
+                // nested destination
+                if (destination == matchingDest && destination.parent == matchingDest.parent)
+                    return destination
+                else destination = null
+            else -> if (destination != null) return destination
+        }
 
         if (searchChildren) {
             // then dfs through children. Avoid re-visiting children that were recursing up this
@@ -255,7 +271,7 @@
             destination =
                 nodes.valueIterator().asSequence().firstNotNullOfOrNull { child ->
                     if (child is NavGraph && child != lastVisited) {
-                        child.findNodeComprehensive(resId, this, true)
+                        child.findNodeComprehensive(resId, this, true, matchingDest)
                     } else null
                 }
         }
@@ -264,7 +280,7 @@
         // this way.
         return destination
             ?: if (parent != null && parent != lastVisited) {
-                parent!!.findNodeComprehensive(resId, this, searchChildren)
+                parent!!.findNodeComprehensive(resId, this, searchChildren, matchingDest)
             } else null
     }
 
diff --git a/navigation/navigation-compose-lint/build.gradle b/navigation/navigation-compose-lint/build.gradle
index d321692..b4bde72 100644
--- a/navigation/navigation-compose-lint/build.gradle
+++ b/navigation/navigation-compose-lint/build.gradle
@@ -44,8 +44,8 @@
     testImplementation(project(":compose:lint:common-test"))
     testImplementation(libs.kotlinStdlib)
     testImplementation(libs.kotlinStdlibJdk8)
-    testImplementation(libs.androidLintPrevApi)
-    testImplementation(libs.androidLintPrevTests)
+    testImplementation(libs.androidLintApiPrevAnalysis)
+    testImplementation(libs.androidLintTestsPrevAnalysis)
     testImplementation(libs.junit)
 }
 
diff --git a/navigation/navigation-runtime-lint/build.gradle b/navigation/navigation-runtime-lint/build.gradle
index 575793e..c7b9d8e 100644
--- a/navigation/navigation-runtime-lint/build.gradle
+++ b/navigation/navigation-runtime-lint/build.gradle
@@ -41,8 +41,8 @@
     bundleInside(project(":navigation:navigation-lint-common"))
 
     testImplementation(libs.kotlinStdlib)
-    testImplementation(libs.androidLintPrevApi)
-    testImplementation(libs.androidLintPrevTests)
+    testImplementation(libs.androidLintApiPrevAnalysis)
+    testImplementation(libs.androidLintTestsPrevAnalysis)
     testImplementation(libs.intellijCore)
     testImplementation(libs.junit)
     testImplementation(libs.truth)
diff --git a/navigation/navigation-runtime/src/androidTest/java/androidx/navigation/NavControllerRouteTest.kt b/navigation/navigation-runtime/src/androidTest/java/androidx/navigation/NavControllerRouteTest.kt
index 22c047f..fb3748f 100644
--- a/navigation/navigation-runtime/src/androidTest/java/androidx/navigation/NavControllerRouteTest.kt
+++ b/navigation/navigation-runtime/src/androidTest/java/androidx/navigation/NavControllerRouteTest.kt
@@ -1076,6 +1076,32 @@
 
     @UiThreadTest
     @Test
+    fun testNavigateNestedDuplicateDestination() {
+        val navController = createNavController()
+        navController.graph =
+            navController.createGraph(route = "root", startDestination = "start") {
+                test("start")
+                navigation(route = "second", startDestination = "duplicate") { test("duplicate") }
+                navigation(route = "duplicate", startDestination = "third") { test("third") }
+            }
+        assertThat(navController.currentDestination?.route).isEqualTo("start")
+
+        navController.navigate("second")
+        assertThat(navController.currentBackStack.value.map { it.destination.route })
+            .containsExactly("root", "start", "second", "duplicate")
+
+        navController.navigate("third")
+        assertThat(navController.currentBackStack.value.map { it.destination.route })
+            .containsExactly("root", "start", "second", "duplicate", "duplicate", "third")
+        val duplicateNode =
+            navController.currentBackStack.value
+                .last { it.destination.route == "duplicate" }
+                .destination
+        assertThat(duplicateNode.parent?.route).isEqualTo("root")
+    }
+
+    @UiThreadTest
+    @Test
     fun testNavigateWithObject() {
         val navController = createNavController()
         navController.graph =
diff --git a/navigation/navigation-runtime/src/main/java/androidx/navigation/NavController.kt b/navigation/navigation-runtime/src/main/java/androidx/navigation/NavController.kt
index 4f43515..8d9d678 100644
--- a/navigation/navigation-runtime/src/main/java/androidx/navigation/NavController.kt
+++ b/navigation/navigation-runtime/src/main/java/androidx/navigation/NavController.kt
@@ -1713,34 +1713,72 @@
             return currentBackStackEntry?.destination
         }
 
-    /** Recursively searches through parents */
+    /**
+     * Recursively searches through parents
+     *
+     * @param destinationId the [NavDestination.id]
+     * @param matchingDest an optional NavDestination that the node should match with. This is
+     *   because [destinationId] is only unique to a local graph. Nodes in sibling graphs can have
+     *   the same id.
+     */
     @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
-    public fun findDestination(@IdRes destinationId: Int): NavDestination? {
+    public fun findDestination(
+        @IdRes destinationId: Int,
+        matchingDest: NavDestination? = null,
+    ): NavDestination? {
         if (_graph == null) {
             return null
         }
+
         if (_graph!!.id == destinationId) {
-            return _graph
+            when {
+                /**
+                 * if the search expected a specific NavDestination (i.e. a duplicated destination
+                 * within a specific graph), we need to make sure the result matches it to ensure
+                 * this search returns the correct duplicate.
+                 */
+                matchingDest != null ->
+                    if (_graph == matchingDest && matchingDest.parent == null) return _graph
+                else -> return _graph
+            }
         }
+
         val currentNode = backQueue.lastOrNull()?.destination ?: _graph!!
-        return currentNode.findDestinationComprehensive(destinationId, false)
+        return currentNode.findDestinationComprehensive(destinationId, false, matchingDest)
     }
 
     /**
      * Recursively searches through parents. If [searchChildren] is true, also recursively searches
      * children.
+     *
+     * @param destinationId the [NavDestination.id]
+     * @param searchChildren recursively searches children when true
+     * @param matchingDest an optional NavDestination that the node should match with. This is
+     *   because [destinationId] is only unique to a local graph. Nodes in sibling graphs can have
+     *   the same id.
      */
     @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
     public fun NavDestination.findDestinationComprehensive(
         @IdRes destinationId: Int,
-        searchChildren: Boolean
+        searchChildren: Boolean,
+        matchingDest: NavDestination? = null,
     ): NavDestination? {
-
         if (id == destinationId) {
-            return this
+            when {
+                // check parent in case of duplicated destinations to ensure it finds the correct
+                // nested destination
+                matchingDest != null ->
+                    if (this == matchingDest && this.parent == matchingDest.parent) return this
+                else -> return this
+            }
         }
         val currentGraph = if (this is NavGraph) this else parent!!
-        return currentGraph.findNodeComprehensive(destinationId, currentGraph, searchChildren)
+        return currentGraph.findNodeComprehensive(
+            destinationId,
+            currentGraph,
+            searchChildren,
+            matchingDest
+        )
     }
 
     /** Recursively searches through parents */
@@ -2352,7 +2390,9 @@
         // equality to ensure that same destinations with a parent that is not this _graph
         // will also have their parents added to the hierarchy.
         destination = if (hierarchy.isEmpty()) newDest else hierarchy.first().destination
-        while (destination != null && findDestination(destination.id) !== destination) {
+        while (
+            destination != null && findDestination(destination.id, destination) !== destination
+        ) {
             val parent = destination.parent
             if (parent != null) {
                 val args = if (finalArgs?.isEmpty == true) null else finalArgs
diff --git a/navigation3/navigation3/api/current.txt b/navigation3/navigation3/api/current.txt
index 17c13b7..b41230e 100644
--- a/navigation3/navigation3/api/current.txt
+++ b/navigation3/navigation3/api/current.txt
@@ -1,6 +1,16 @@
 // Signature format: 4.0
 package androidx.navigation3 {
 
+  public final class AnimatedNavDisplay {
+    method public java.util.Map<java.lang.String,java.lang.Object> isDialog(boolean boolean);
+    method public java.util.Map<java.lang.String,java.lang.Object> transition(androidx.compose.animation.EnterTransition? enter, androidx.compose.animation.ExitTransition? exit);
+    field public static final androidx.navigation3.AnimatedNavDisplay INSTANCE;
+  }
+
+  public final class AnimatedNavDisplay_androidKt {
+    method @androidx.compose.runtime.Composable public static void AnimatedNavDisplay(java.util.List<?> backstack, androidx.navigation3.NavWrapperManager wrapperManager, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.ui.Alignment contentAlignment, optional androidx.compose.animation.SizeTransform? sizeTransform, optional kotlin.jvm.functions.Function0<kotlin.Unit> onBack, kotlin.jvm.functions.Function1<java.lang.Object,androidx.navigation3.Record> recordProvider);
+  }
+
   public interface NavContentWrapper {
     method public default void WrapBackStack(java.util.List<?> backStack);
     method public void WrapContent(androidx.navigation3.Record record);
diff --git a/navigation3/navigation3/api/restricted_current.txt b/navigation3/navigation3/api/restricted_current.txt
index 17c13b7..b41230e 100644
--- a/navigation3/navigation3/api/restricted_current.txt
+++ b/navigation3/navigation3/api/restricted_current.txt
@@ -1,6 +1,16 @@
 // Signature format: 4.0
 package androidx.navigation3 {
 
+  public final class AnimatedNavDisplay {
+    method public java.util.Map<java.lang.String,java.lang.Object> isDialog(boolean boolean);
+    method public java.util.Map<java.lang.String,java.lang.Object> transition(androidx.compose.animation.EnterTransition? enter, androidx.compose.animation.ExitTransition? exit);
+    field public static final androidx.navigation3.AnimatedNavDisplay INSTANCE;
+  }
+
+  public final class AnimatedNavDisplay_androidKt {
+    method @androidx.compose.runtime.Composable public static void AnimatedNavDisplay(java.util.List<?> backstack, androidx.navigation3.NavWrapperManager wrapperManager, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.ui.Alignment contentAlignment, optional androidx.compose.animation.SizeTransform? sizeTransform, optional kotlin.jvm.functions.Function0<kotlin.Unit> onBack, kotlin.jvm.functions.Function1<java.lang.Object,androidx.navigation3.Record> recordProvider);
+  }
+
   public interface NavContentWrapper {
     method public default void WrapBackStack(java.util.List<?> backStack);
     method public void WrapContent(androidx.navigation3.Record record);
diff --git a/navigation3/navigation3/build.gradle b/navigation3/navigation3/build.gradle
index f61edaa..1911ba9 100644
--- a/navigation3/navigation3/build.gradle
+++ b/navigation3/navigation3/build.gradle
@@ -66,6 +66,7 @@
             dependencies {
                 implementation("androidx.activity:activity-compose:1.9.3")
                 implementation("androidx.annotation:annotation:1.8.0")
+                implementation("androidx.compose.animation:animation:1.7.5")
                 implementation("androidx.compose.foundation:foundation:1.7.5")
                 implementation("androidx.compose.ui:ui:1.7.5")
                 implementation("androidx.lifecycle:lifecycle-runtime:2.8.7")
diff --git a/navigation3/navigation3/samples/build.gradle b/navigation3/navigation3/samples/build.gradle
index b2b8be8..8e42ee7 100644
--- a/navigation3/navigation3/samples/build.gradle
+++ b/navigation3/navigation3/samples/build.gradle
@@ -37,6 +37,7 @@
     compileOnly(project(":annotation:annotation-sampled"))
 
     implementation(libs.kotlinStdlib)
+    implementation("androidx.compose.animation:animation:1.7.5")
     implementation("androidx.compose.foundation:foundation:1.7.5")
     implementation("androidx.compose.foundation:foundation-layout:1.7.5")
     implementation("androidx.compose.material:material:1.7.5")
diff --git a/navigation3/navigation3/samples/src/main/kotlin/androidx/navigation3/samples/NavDisplaySamples.kt b/navigation3/navigation3/samples/src/main/kotlin/androidx/navigation3/samples/NavDisplaySamples.kt
index f7adfa2..a6364e4 100644
--- a/navigation3/navigation3/samples/src/main/kotlin/androidx/navigation3/samples/NavDisplaySamples.kt
+++ b/navigation3/navigation3/samples/src/main/kotlin/androidx/navigation3/samples/NavDisplaySamples.kt
@@ -17,11 +17,14 @@
 package androidx.navigation3.samples
 
 import androidx.annotation.Sampled
+import androidx.compose.animation.slideInHorizontally
+import androidx.compose.animation.slideOutHorizontally
 import androidx.compose.material3.Text
 import androidx.compose.runtime.Composable
 import androidx.lifecycle.ViewModel
 import androidx.lifecycle.viewmodel.compose.viewModel
 import androidx.lifecycle.viewmodel.navigation3.ViewModelStoreNavContentWrapper
+import androidx.navigation3.AnimatedNavDisplay
 import androidx.navigation3.NavDisplay
 import androidx.navigation3.Record
 import androidx.navigation3.SavedStateNavContentWrapper
@@ -71,3 +74,64 @@
 class ProfileViewModel : ViewModel() {
     val name = "no user"
 }
+
+@Sampled
+@Composable
+fun AnimatedNav() {
+    val backStack = rememberMutableStateListOf(Profile)
+    val manager =
+        rememberNavWrapperManager(
+            listOf(SavedStateNavContentWrapper, ViewModelStoreNavContentWrapper)
+        )
+    AnimatedNavDisplay(
+        backstack = backStack,
+        wrapperManager = manager,
+        onBack = { backStack.removeLast() },
+    ) { key ->
+        when (key) {
+            Profile -> {
+                Record(
+                    Profile,
+                    AnimatedNavDisplay.transition(
+                        slideInHorizontally { it },
+                        slideOutHorizontally { it }
+                    )
+                ) {
+                    val viewModel = viewModel<ProfileViewModel>()
+                    Profile(viewModel, { backStack.add(it) }) { backStack.removeLast() }
+                }
+            }
+            Scrollable -> {
+                Record(
+                    Scrollable,
+                    AnimatedNavDisplay.transition(
+                        slideInHorizontally { it },
+                        slideOutHorizontally { it }
+                    )
+                ) {
+                    Scrollable({ backStack.add(it) }) { backStack.removeLast() }
+                }
+            }
+            Dialog -> {
+                Record(Dialog, featureMap = NavDisplay.isDialog(true)) {
+                    DialogContent { backStack.removeLast() }
+                }
+            }
+            Dashboard -> {
+                Record(
+                    Dashboard,
+                    AnimatedNavDisplay.transition(
+                        slideInHorizontally { it },
+                        slideOutHorizontally { it }
+                    )
+                ) { dashboardArgs ->
+                    val userId = (dashboardArgs as Dashboard).userId
+                    Dashboard(userId, onBack = { backStack.removeLast() })
+                }
+            }
+            else -> {
+                Record(Unit) { Text(text = "Invalid Key") }
+            }
+        }
+    }
+}
diff --git a/navigation3/navigation3/src/androidInstrumentedTest/kotlin/androidx/navigation3/AnimatedNavDisplayTest.kt b/navigation3/navigation3/src/androidInstrumentedTest/kotlin/androidx/navigation3/AnimatedNavDisplayTest.kt
new file mode 100644
index 0000000..bd42647
--- /dev/null
+++ b/navigation3/navigation3/src/androidInstrumentedTest/kotlin/androidx/navigation3/AnimatedNavDisplayTest.kt
@@ -0,0 +1,81 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.navigation3
+
+import androidx.compose.animation.core.AnimationConstants.DefaultDurationMillis
+import androidx.compose.material3.Text
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.test.isDisplayed
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithText
+import androidx.kruth.assertThat
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.LargeTest
+import kotlin.test.Test
+import org.junit.Rule
+import org.junit.runner.RunWith
+
+@LargeTest
+@RunWith(AndroidJUnit4::class)
+class AnimatedNavDisplayTest {
+    @get:Rule val composeTestRule = createComposeRule()
+
+    @Test
+    fun testNavHostAnimations() {
+        lateinit var backstack: MutableList<Any>
+
+        composeTestRule.mainClock.autoAdvance = false
+
+        composeTestRule.setContent {
+            backstack = remember { mutableStateListOf(first) }
+            val manager = rememberNavWrapperManager(emptyList())
+            AnimatedNavDisplay(backstack, wrapperManager = manager) {
+                when (it) {
+                    first -> Record(first) { Text(first) }
+                    second -> Record(second) { Text(second) }
+                    else -> error("Invalid key passed")
+                }
+            }
+        }
+
+        composeTestRule.mainClock.autoAdvance = true
+
+        composeTestRule.waitForIdle()
+        assertThat(composeTestRule.onNodeWithText(first).isDisplayed()).isTrue()
+
+        composeTestRule.mainClock.autoAdvance = false
+
+        composeTestRule.runOnIdle { backstack.add(second) }
+
+        // advance half way between animations
+        composeTestRule.mainClock.advanceTimeBy(DefaultDurationMillis.toLong() / 2)
+
+        composeTestRule.waitForIdle()
+        composeTestRule.onNodeWithText(first).assertExists()
+        composeTestRule.onNodeWithText(second).assertExists()
+
+        composeTestRule.mainClock.autoAdvance = true
+
+        composeTestRule.waitForIdle()
+        composeTestRule.onNodeWithText(first).assertDoesNotExist()
+        composeTestRule.onNodeWithText(second).assertExists()
+    }
+}
+
+private const val first = "first"
+private const val second = "second"
diff --git a/navigation3/navigation3/src/androidMain/kotlin/androidx/navigation3/AnimatedNavDisplay.android.kt b/navigation3/navigation3/src/androidMain/kotlin/androidx/navigation3/AnimatedNavDisplay.android.kt
new file mode 100644
index 0000000..4699ac1
--- /dev/null
+++ b/navigation3/navigation3/src/androidMain/kotlin/androidx/navigation3/AnimatedNavDisplay.android.kt
@@ -0,0 +1,125 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package androidx.navigation3
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.ContentTransform
+import androidx.compose.animation.EnterTransition
+import androidx.compose.animation.ExitTransition
+import androidx.compose.animation.SizeTransform
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.core.updateTransition
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.window.Dialog
+
+/** Object that indicates the features that can be handled by the [AnimatedNavDisplay] */
+public object AnimatedNavDisplay {
+    /**
+     * Function to be called on the [Record.featureMap] to notify the [AnimatedNavDisplay] that the
+     * content should be animated using the provided transitions.
+     */
+    public fun transition(enter: EnterTransition?, exit: ExitTransition?): Map<String, Any> =
+        if (enter == null || exit == null) emptyMap()
+        else mapOf(ENTER_TRANSITION_KEY to enter, EXIT_TRANSITION_KEY to exit)
+
+    /**
+     * Function to be called on the [Record.featureMap] to notify the [NavDisplay] that the content
+     * should be displayed inside of a [Dialog]
+     */
+    public fun isDialog(boolean: Boolean): Map<String, Any> =
+        if (!boolean) emptyMap() else mapOf(DIALOG_KEY to true)
+
+    internal const val ENTER_TRANSITION_KEY = "enterTransition"
+    internal const val EXIT_TRANSITION_KEY = "exitTransition"
+    internal const val DIALOG_KEY = "dialog"
+}
+
+/**
+ * Display for Composable content that displays a single pane of content at a time, but can move
+ * that content in and out with customized transitions.
+ *
+ * The AnimatedNavDisplay displays the content associated with the last key on the back stack in
+ * most circumstances. If that content wants to be displayed as a dialog, as communicated by adding
+ * [NavDisplay.isDialog] to a [Record.featureMap], then the last key's content is a dialog and the
+ * second to last key is a displayed in the background.
+ *
+ * @param backstack the collection of keys that represents the state that needs to be handled
+ * @param wrapperManager the manager that combines all of the [NavContentWrapper]s
+ * @param modifier the modifier to be applied to the layout.
+ * @param contentAlignment The [Alignment] of the [AnimatedContent]
+ * @param onBack a callback for handling system back presses
+ * @param recordProvider lambda used to construct each possible [Record]
+ * @sample androidx.navigation3.samples.AnimatedNav
+ */
+@Composable
+public fun AnimatedNavDisplay(
+    backstack: List<Any>,
+    wrapperManager: NavWrapperManager,
+    modifier: Modifier = Modifier,
+    contentAlignment: Alignment = Alignment.TopStart,
+    sizeTransform: SizeTransform? = null,
+    onBack: () -> Unit = {},
+    recordProvider: (key: Any) -> Record
+) {
+    BackHandler(backstack.size > 1, onBack)
+    wrapperManager.PrepareBackStack(backStack = backstack)
+    val key = backstack.last()
+    val record = recordProvider.invoke(key)
+
+    // Incoming record defines transitions, otherwise it defaults to a fade
+    val enterTransition =
+        record.featureMap[AnimatedNavDisplay.ENTER_TRANSITION_KEY] as? EnterTransition
+            ?: fadeIn(animationSpec = tween(700))
+    val exitTransition =
+        record.featureMap[AnimatedNavDisplay.EXIT_TRANSITION_KEY] as? ExitTransition
+            ?: fadeOut(animationSpec = tween(700))
+
+    // if there is a dialog, we should create a transition with the next to last entry instead.
+    val transition =
+        if (record.featureMap[AnimatedNavDisplay.DIALOG_KEY] == true) {
+            if (backstack.size > 1) {
+                val previousKey = backstack[backstack.size - 2]
+                updateTransition(targetState = previousKey, label = previousKey.toString())
+            } else {
+                null
+            }
+        } else {
+            updateTransition(targetState = key, label = key.toString())
+        }
+
+    transition?.AnimatedContent(
+        modifier = modifier,
+        transitionSpec = {
+            ContentTransform(
+                targetContentEnter = enterTransition,
+                initialContentExit = exitTransition,
+                sizeTransform = sizeTransform
+            )
+        },
+        contentAlignment = contentAlignment
+    ) { innerKey ->
+        wrapperManager.ContentForRecord(recordProvider.invoke(innerKey))
+    }
+
+    if (record.featureMap[AnimatedNavDisplay.DIALOG_KEY] == true) {
+        Dialog(onBack) { wrapperManager.ContentForRecord(record) }
+    }
+}
diff --git a/pdf/integration-tests/testapp/src/main/res/layout/fragment_host.xml b/pdf/integration-tests/testapp/src/main/res/layout/fragment_host.xml
index e6deb4d..9621c5c 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
@@ -28,7 +28,7 @@
         android:layout_height="wrap_content"
         android:layout_marginBottom="80dp"
         android:layout_marginEnd="16dp"
-        android:contentDescription="@string/action_edit"
+        android:contentDescription="@string/action_search"
         android:layout_gravity="bottom|end"
         android:visibility="gone"
         android:src="@drawable/ic_action_search"
diff --git a/pdf/pdf-viewer/src/androidTest/kotlin/androidx/pdf/view/PdfViewPaginationTest.kt b/pdf/pdf-viewer/src/androidTest/kotlin/androidx/pdf/view/PdfViewPaginationTest.kt
index 551a72a..3413f8c 100644
--- a/pdf/pdf-viewer/src/androidTest/kotlin/androidx/pdf/view/PdfViewPaginationTest.kt
+++ b/pdf/pdf-viewer/src/androidTest/kotlin/androidx/pdf/view/PdfViewPaginationTest.kt
@@ -22,6 +22,7 @@
 import android.view.View
 import android.view.View.MeasureSpec
 import androidx.pdf.PdfDocument
+import androidx.test.annotation.UiThreadTest
 import androidx.test.core.app.ApplicationProvider
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.LargeTest
@@ -65,6 +66,7 @@
     }
 
     @Test
+    @UiThreadTest
     fun testPageVisibility_withoutPdfDocument() {
         val pdfView = setupPdfViewOnMain(500, 1000, null)
 
diff --git a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/view/ToolBoxView.kt b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/view/ToolBoxView.kt
new file mode 100644
index 0000000..8aab598
--- /dev/null
+++ b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/view/ToolBoxView.kt
@@ -0,0 +1,85 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.pdf.view
+
+import android.content.Context
+import android.graphics.drawable.Drawable
+import android.util.AttributeSet
+import android.view.LayoutInflater
+import android.view.View
+import androidx.annotation.RestrictTo
+import androidx.pdf.PdfDocument
+import androidx.pdf.R
+import androidx.pdf.util.AnnotationUtils
+import androidx.pdf.util.Intents.startActivity
+import androidx.pdf.util.Uris
+import com.google.android.material.floatingactionbutton.FloatingActionButton
+
+@RestrictTo(RestrictTo.Scope.LIBRARY)
+public open class ToolBoxView
+@JvmOverloads
+constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) :
+    View(context, attrs, defStyleAttr) {
+
+    private val editButton: FloatingActionButton
+    private var pdfDocument: PdfDocument? = null
+    private var editClickListener: OnClickListener? = null
+
+    init {
+        LayoutInflater.from(context).inflate(R.layout.toolbox_view, null, false)
+        editButton = findViewById(R.id.edit_fab)
+
+        editButton.setOnClickListener {
+            handleEditFabClick()
+            editClickListener?.onClick(this)
+        }
+    }
+
+    public fun setPdfDocument(pdfDocument: PdfDocument?) {
+        this.pdfDocument = pdfDocument
+    }
+
+    public fun setEditIconDrawable(drawable: Drawable?) {
+        editButton.setImageDrawable(drawable)
+    }
+
+    public fun setOnEditClickListener(listener: OnClickListener) {
+        editClickListener = listener
+    }
+
+    private fun handleEditFabClick() {
+
+        pdfDocument?.let {
+            val uri = it.uri
+
+            val intent =
+                AnnotationUtils.getAnnotationIntent(uri).apply {
+                    setData(uri)
+                    putExtra(EXTRA_PDF_FILE_NAME, Uris.extractName(uri, context.contentResolver))
+                    putExtra(EXTRA_STARTING_PAGE, 0)
+                }
+            startActivity(context, "", intent)
+        }
+    }
+
+    public companion object {
+        public const val EXTRA_PDF_FILE_NAME: String =
+            "androidx.pdf.viewer.fragment.extra.PDF_FILE_NAME"
+        public const val EXTRA_STARTING_PAGE: String =
+            "androidx.pdf.viewer.fragment.extra.STARTING_PAGE"
+    }
+}
diff --git a/pdf/pdf-viewer/src/main/res/layout/fastscroll_handle.xml b/pdf/pdf-viewer/src/main/res/layout/fastscroll_handle.xml
index 4bc4ceb..900bc7f 100644
--- a/pdf/pdf-viewer/src/main/res/layout/fastscroll_handle.xml
+++ b/pdf/pdf-viewer/src/main/res/layout/fastscroll_handle.xml
@@ -19,7 +19,9 @@
     android:layout_height="40dp"
     android:background="@drawable/fastscroll_background"
     android:elevation="4dp"
-    android:importantForAccessibility="no"
+    android:focusable="true"
+    android:clickable="true"
+    android:contentDescription="@string/scrollbar_description"
     android:scaleType="center"
     android:src="@drawable/drag_indicator"
     android:translationX="8dp" />
\ No newline at end of file
diff --git a/pdf/pdf-viewer/src/main/res/layout/page_indicator.xml b/pdf/pdf-viewer/src/main/res/layout/page_indicator.xml
index 06d456f..2f9004d 100644
--- a/pdf/pdf-viewer/src/main/res/layout/page_indicator.xml
+++ b/pdf/pdf-viewer/src/main/res/layout/page_indicator.xml
@@ -24,6 +24,8 @@
     android:background="@drawable/page_indicator_background"
     android:elevation="1dp"
     android:focusableInTouchMode="true"
+    android:focusable="true"
+    android:clickable="true"
     android:gravity="center"
     android:paddingLeft="12dp"
     android:paddingRight="12dp"
diff --git a/pdf/pdf-viewer/src/main/res/layout/toolbox_view.xml b/pdf/pdf-viewer/src/main/res/layout/toolbox_view.xml
new file mode 100644
index 0000000..661a3dd
--- /dev/null
+++ b/pdf/pdf-viewer/src/main/res/layout/toolbox_view.xml
@@ -0,0 +1,39 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<!--
+  Copyright 2023 The Android Open Source Project
+
+  Licensed under the Apache License, Version 2.0 (the "License");
+  you may not use this file except in compliance with the License.
+  You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+  -->
+
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    android:gravity="center">
+
+    <com.google.android.material.floatingactionbutton.FloatingActionButton
+        android:id="@+id/edit_fab"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:contentDescription="@string/action_edit"
+        android:layout_gravity="bottom|end"
+        android:visibility="gone"
+        android:src="@drawable/edit_fab"
+        app:backgroundTint="?attr/colorPrimaryContainer"
+        app:tint="?attr/colorOnPrimaryContainer" />
+
+
+</LinearLayout>
+
+
diff --git a/pdf/pdf-viewer/src/main/res/values/strings.xml b/pdf/pdf-viewer/src/main/res/values/strings.xml
index 5387396..f4d7e82 100644
--- a/pdf/pdf-viewer/src/main/res/values/strings.xml
+++ b/pdf/pdf-viewer/src/main/res/values/strings.xml
@@ -125,6 +125,12 @@
     <!-- Content description for edit fab button -->
     <string name="action_edit">Edit file</string>
 
+    <!-- Content description for search fab button -->
+    <string name="action_search">Search file</string>
+
+    <!-- Content description for scroll bar -->
+    <string name="scrollbar_description">Scrollbar</string>
+
     <!-- Text for an error indicator that is displayed when the file is password protected and the
         password dialog has been dismissed without entering a correct password. [CHAR LIMIT=100] -->
     <string name="password_not_entered">Enter password to unlock</string>
diff --git a/savedstate/savedstate/api/current.txt b/savedstate/savedstate/api/current.txt
index 3e3d6f9..c1e51e3 100644
--- a/savedstate/savedstate/api/current.txt
+++ b/savedstate/savedstate/api/current.txt
@@ -11,6 +11,8 @@
     method public inline operator boolean contains(String key);
     method public boolean contentDeepEquals(android.os.Bundle other);
     method public int contentDeepHashCode();
+    method public inline android.os.IBinder getBinder(String key);
+    method public inline android.os.IBinder getBinderOrElse(String key, kotlin.jvm.functions.Function0<? extends android.os.IBinder> defaultValue);
     method public inline boolean getBoolean(String key);
     method public inline boolean[] getBooleanArray(String key);
     method public inline boolean[] getBooleanArrayOrElse(String key, kotlin.jvm.functions.Function0<boolean[]> defaultValue);
@@ -19,6 +21,12 @@
     method public inline char[] getCharArray(String key);
     method public inline char[] getCharArrayOrElse(String key, kotlin.jvm.functions.Function0<char[]> defaultValue);
     method public inline char getCharOrElse(String key, kotlin.jvm.functions.Function0<java.lang.Character> defaultValue);
+    method public inline CharSequence getCharSequence(String key);
+    method public inline CharSequence[] getCharSequenceArray(String key);
+    method public inline CharSequence[] getCharSequenceArrayOrElse(String key, kotlin.jvm.functions.Function0<java.lang.CharSequence[]> defaultValue);
+    method public inline java.util.List<java.lang.CharSequence> getCharSequenceList(String key);
+    method public inline java.util.List<java.lang.CharSequence> getCharSequenceListOrElse(String key, kotlin.jvm.functions.Function0<? extends java.util.List<? extends java.lang.CharSequence>> defaultValue);
+    method public inline CharSequence getCharSequenceOrElse(String key, kotlin.jvm.functions.Function0<? extends java.lang.CharSequence> defaultValue);
     method public inline double getDouble(String key);
     method public inline double[] getDoubleArray(String key);
     method public inline double[] getDoubleArrayOrElse(String key, kotlin.jvm.functions.Function0<double[]> defaultValue);
@@ -38,11 +46,21 @@
     method public inline long[] getLongArrayOrElse(String key, kotlin.jvm.functions.Function0<long[]> defaultValue);
     method public inline long getLongOrElse(String key, kotlin.jvm.functions.Function0<java.lang.Long> defaultValue);
     method public inline <reified T extends android.os.Parcelable> T getParcelable(String key);
+    method public inline <reified T extends android.os.Parcelable> T[] getParcelableArray(String key);
+    method public inline <reified T extends android.os.Parcelable> T[] getParcelableArrayOrElse(String key, kotlin.jvm.functions.Function0<T[]> defaultValue);
     method public inline <reified T extends android.os.Parcelable> java.util.List<T> getParcelableList(String key);
     method public inline <reified T extends android.os.Parcelable> java.util.List<T> getParcelableListOrElse(String key, kotlin.jvm.functions.Function0<? extends java.util.List<? extends T>> defaultValue);
     method public inline <reified T extends android.os.Parcelable> T getParcelableOrElse(String key, kotlin.jvm.functions.Function0<? extends T> defaultValue);
     method public inline android.os.Bundle getSavedState(String key);
     method public inline android.os.Bundle getSavedStateOrElse(String key, kotlin.jvm.functions.Function0<android.os.Bundle> defaultValue);
+    method public inline <reified T extends java.io.Serializable> T getSerializable(String key);
+    method public inline <reified T extends java.io.Serializable> T getSerializableOrElse(String key, kotlin.jvm.functions.Function0<? extends T> defaultValue);
+    method public inline android.util.Size getSize(String key);
+    method public inline android.util.SizeF getSizeF(String key);
+    method public inline android.util.SizeF getSizeFOrElse(String key, kotlin.jvm.functions.Function0<android.util.SizeF> defaultValue);
+    method public inline android.util.Size getSizeOrElse(String key, kotlin.jvm.functions.Function0<android.util.Size> defaultValue);
+    method public inline <reified T extends android.os.Parcelable> android.util.SparseArray<T> getSparseParcelableArray(String key);
+    method public inline <reified T extends android.os.Parcelable> android.util.SparseArray<T> getSparseParcelableArrayOrElse(String key, kotlin.jvm.functions.Function0<? extends android.util.SparseArray<T>> defaultValue);
     method public inline String getString(String key);
     method public inline String[] getStringArray(String key);
     method public inline String[] getStringArrayOrElse(String key, kotlin.jvm.functions.Function0<java.lang.String[]> defaultValue);
@@ -95,10 +113,14 @@
   @kotlin.jvm.JvmInline public final value class SavedStateWriter {
     method public inline void clear();
     method public inline void putAll(android.os.Bundle values);
+    method public inline void putBinder(String key, android.os.IBinder value);
     method public inline void putBoolean(String key, boolean value);
     method public inline void putBooleanArray(String key, boolean[] values);
     method public inline void putChar(String key, char value);
     method public inline void putCharArray(String key, char[] values);
+    method public inline void putCharSequence(String key, CharSequence value);
+    method public inline void putCharSequenceArray(String key, CharSequence[] values);
+    method public inline void putCharSequenceList(String key, java.util.List<? extends java.lang.CharSequence> values);
     method public inline void putDouble(String key, double value);
     method public inline void putDoubleArray(String key, double[] values);
     method public inline void putFloat(String key, float value);
@@ -110,8 +132,13 @@
     method public inline void putLongArray(String key, long[] values);
     method public inline void putNull(String key);
     method public inline <reified T extends android.os.Parcelable> void putParcelable(String key, T value);
+    method public inline <reified T extends android.os.Parcelable> void putParcelableArray(String key, T[] values);
     method public inline <reified T extends android.os.Parcelable> void putParcelableList(String key, java.util.List<? extends T> values);
     method public inline void putSavedState(String key, android.os.Bundle value);
+    method public inline <reified T extends java.io.Serializable> void putSerializable(String key, T value);
+    method public inline void putSize(String key, android.util.Size value);
+    method public inline void putSizeF(String key, android.util.SizeF value);
+    method public inline <reified T extends android.os.Parcelable> void putSparseParcelableArray(String key, android.util.SparseArray<T> values);
     method public inline void putString(String key, String value);
     method public inline void putStringArray(String key, String[] values);
     method public inline void putStringList(String key, java.util.List<java.lang.String> values);
diff --git a/savedstate/savedstate/api/restricted_current.txt b/savedstate/savedstate/api/restricted_current.txt
index 94ea74b..eb9ae61 100644
--- a/savedstate/savedstate/api/restricted_current.txt
+++ b/savedstate/savedstate/api/restricted_current.txt
@@ -12,6 +12,8 @@
     method public inline operator boolean contains(String key);
     method public boolean contentDeepEquals(android.os.Bundle other);
     method public int contentDeepHashCode();
+    method public inline android.os.IBinder getBinder(String key);
+    method public inline android.os.IBinder getBinderOrElse(String key, kotlin.jvm.functions.Function0<? extends android.os.IBinder> defaultValue);
     method public inline boolean getBoolean(String key);
     method public inline boolean[] getBooleanArray(String key);
     method public inline boolean[] getBooleanArrayOrElse(String key, kotlin.jvm.functions.Function0<boolean[]> defaultValue);
@@ -20,6 +22,12 @@
     method public inline char[] getCharArray(String key);
     method public inline char[] getCharArrayOrElse(String key, kotlin.jvm.functions.Function0<char[]> defaultValue);
     method public inline char getCharOrElse(String key, kotlin.jvm.functions.Function0<java.lang.Character> defaultValue);
+    method public inline CharSequence getCharSequence(String key);
+    method public inline CharSequence[] getCharSequenceArray(String key);
+    method public inline CharSequence[] getCharSequenceArrayOrElse(String key, kotlin.jvm.functions.Function0<java.lang.CharSequence[]> defaultValue);
+    method public inline java.util.List<java.lang.CharSequence> getCharSequenceList(String key);
+    method public inline java.util.List<java.lang.CharSequence> getCharSequenceListOrElse(String key, kotlin.jvm.functions.Function0<? extends java.util.List<? extends java.lang.CharSequence>> defaultValue);
+    method public inline CharSequence getCharSequenceOrElse(String key, kotlin.jvm.functions.Function0<? extends java.lang.CharSequence> defaultValue);
     method public inline double getDouble(String key);
     method public inline double[] getDoubleArray(String key);
     method public inline double[] getDoubleArrayOrElse(String key, kotlin.jvm.functions.Function0<double[]> defaultValue);
@@ -39,11 +47,21 @@
     method public inline long[] getLongArrayOrElse(String key, kotlin.jvm.functions.Function0<long[]> defaultValue);
     method public inline long getLongOrElse(String key, kotlin.jvm.functions.Function0<java.lang.Long> defaultValue);
     method public inline <reified T extends android.os.Parcelable> T getParcelable(String key);
+    method public inline <reified T extends android.os.Parcelable> T[] getParcelableArray(String key);
+    method public inline <reified T extends android.os.Parcelable> T[] getParcelableArrayOrElse(String key, kotlin.jvm.functions.Function0<T[]> defaultValue);
     method public inline <reified T extends android.os.Parcelable> java.util.List<T> getParcelableList(String key);
     method public inline <reified T extends android.os.Parcelable> java.util.List<T> getParcelableListOrElse(String key, kotlin.jvm.functions.Function0<? extends java.util.List<? extends T>> defaultValue);
     method public inline <reified T extends android.os.Parcelable> T getParcelableOrElse(String key, kotlin.jvm.functions.Function0<? extends T> defaultValue);
     method public inline android.os.Bundle getSavedState(String key);
     method public inline android.os.Bundle getSavedStateOrElse(String key, kotlin.jvm.functions.Function0<android.os.Bundle> defaultValue);
+    method public inline <reified T extends java.io.Serializable> T getSerializable(String key);
+    method public inline <reified T extends java.io.Serializable> T getSerializableOrElse(String key, kotlin.jvm.functions.Function0<? extends T> defaultValue);
+    method public inline android.util.Size getSize(String key);
+    method public inline android.util.SizeF getSizeF(String key);
+    method public inline android.util.SizeF getSizeFOrElse(String key, kotlin.jvm.functions.Function0<android.util.SizeF> defaultValue);
+    method public inline android.util.Size getSizeOrElse(String key, kotlin.jvm.functions.Function0<android.util.Size> defaultValue);
+    method public inline <reified T extends android.os.Parcelable> android.util.SparseArray<T> getSparseParcelableArray(String key);
+    method public inline <reified T extends android.os.Parcelable> android.util.SparseArray<T> getSparseParcelableArrayOrElse(String key, kotlin.jvm.functions.Function0<? extends android.util.SparseArray<T>> defaultValue);
     method public inline String getString(String key);
     method public inline String[] getStringArray(String key);
     method public inline String[] getStringArrayOrElse(String key, kotlin.jvm.functions.Function0<java.lang.String[]> defaultValue);
@@ -115,10 +133,14 @@
     ctor @kotlin.PublishedApi internal SavedStateWriter(@kotlin.PublishedApi android.os.Bundle source);
     method public inline void clear();
     method public inline void putAll(android.os.Bundle values);
+    method public inline void putBinder(String key, android.os.IBinder value);
     method public inline void putBoolean(String key, boolean value);
     method public inline void putBooleanArray(String key, boolean[] values);
     method public inline void putChar(String key, char value);
     method public inline void putCharArray(String key, char[] values);
+    method public inline void putCharSequence(String key, CharSequence value);
+    method public inline void putCharSequenceArray(String key, CharSequence[] values);
+    method public inline void putCharSequenceList(String key, java.util.List<? extends java.lang.CharSequence> values);
     method public inline void putDouble(String key, double value);
     method public inline void putDoubleArray(String key, double[] values);
     method public inline void putFloat(String key, float value);
@@ -130,8 +152,13 @@
     method public inline void putLongArray(String key, long[] values);
     method public inline void putNull(String key);
     method public inline <reified T extends android.os.Parcelable> void putParcelable(String key, T value);
+    method public inline <reified T extends android.os.Parcelable> void putParcelableArray(String key, T[] values);
     method public inline <reified T extends android.os.Parcelable> void putParcelableList(String key, java.util.List<? extends T> values);
     method public inline void putSavedState(String key, android.os.Bundle value);
+    method public inline <reified T extends java.io.Serializable> void putSerializable(String key, T value);
+    method public inline void putSize(String key, android.util.Size value);
+    method public inline void putSizeF(String key, android.util.SizeF value);
+    method public inline <reified T extends android.os.Parcelable> void putSparseParcelableArray(String key, android.util.SparseArray<T> values);
     method public inline void putString(String key, String value);
     method public inline void putStringArray(String key, String[] values);
     method public inline void putStringList(String key, java.util.List<java.lang.String> values);
diff --git a/savedstate/savedstate/bcv/native/current.txt b/savedstate/savedstate/bcv/native/current.txt
index e9f880e..2491285 100644
--- a/savedstate/savedstate/bcv/native/current.txt
+++ b/savedstate/savedstate/bcv/native/current.txt
@@ -66,6 +66,12 @@
     final inline fun getCharArray(kotlin/String): kotlin/CharArray // androidx.savedstate/SavedStateReader.getCharArray|getCharArray(kotlin.String){}[0]
     final inline fun getCharArrayOrElse(kotlin/String, kotlin/Function0<kotlin/CharArray>): kotlin/CharArray // androidx.savedstate/SavedStateReader.getCharArrayOrElse|getCharArrayOrElse(kotlin.String;kotlin.Function0<kotlin.CharArray>){}[0]
     final inline fun getCharOrElse(kotlin/String, kotlin/Function0<kotlin/Char>): kotlin/Char // androidx.savedstate/SavedStateReader.getCharOrElse|getCharOrElse(kotlin.String;kotlin.Function0<kotlin.Char>){}[0]
+    final inline fun getCharSequence(kotlin/String): kotlin/CharSequence // androidx.savedstate/SavedStateReader.getCharSequence|getCharSequence(kotlin.String){}[0]
+    final inline fun getCharSequenceArray(kotlin/String): kotlin/Array<kotlin/CharSequence> // androidx.savedstate/SavedStateReader.getCharSequenceArray|getCharSequenceArray(kotlin.String){}[0]
+    final inline fun getCharSequenceArrayOrElse(kotlin/String, kotlin/Function0<kotlin/Array<kotlin/CharSequence>>): kotlin/Array<kotlin/CharSequence> // androidx.savedstate/SavedStateReader.getCharSequenceArrayOrElse|getCharSequenceArrayOrElse(kotlin.String;kotlin.Function0<kotlin.Array<kotlin.CharSequence>>){}[0]
+    final inline fun getCharSequenceList(kotlin/String): kotlin.collections/List<kotlin/CharSequence> // androidx.savedstate/SavedStateReader.getCharSequenceList|getCharSequenceList(kotlin.String){}[0]
+    final inline fun getCharSequenceListOrElse(kotlin/String, kotlin/Function0<kotlin.collections/List<kotlin/CharSequence>>): kotlin.collections/List<kotlin/CharSequence> // androidx.savedstate/SavedStateReader.getCharSequenceListOrElse|getCharSequenceListOrElse(kotlin.String;kotlin.Function0<kotlin.collections.List<kotlin.CharSequence>>){}[0]
+    final inline fun getCharSequenceOrElse(kotlin/String, kotlin/Function0<kotlin/CharSequence>): kotlin/CharSequence // androidx.savedstate/SavedStateReader.getCharSequenceOrElse|getCharSequenceOrElse(kotlin.String;kotlin.Function0<kotlin.CharSequence>){}[0]
     final inline fun getDouble(kotlin/String): kotlin/Double // androidx.savedstate/SavedStateReader.getDouble|getDouble(kotlin.String){}[0]
     final inline fun getDoubleArray(kotlin/String): kotlin/DoubleArray // androidx.savedstate/SavedStateReader.getDoubleArray|getDoubleArray(kotlin.String){}[0]
     final inline fun getDoubleArrayOrElse(kotlin/String, kotlin/Function0<kotlin/DoubleArray>): kotlin/DoubleArray // androidx.savedstate/SavedStateReader.getDoubleArrayOrElse|getDoubleArrayOrElse(kotlin.String;kotlin.Function0<kotlin.DoubleArray>){}[0]
@@ -112,6 +118,9 @@
     final inline fun putBooleanArray(kotlin/String, kotlin/BooleanArray) // androidx.savedstate/SavedStateWriter.putBooleanArray|putBooleanArray(kotlin.String;kotlin.BooleanArray){}[0]
     final inline fun putChar(kotlin/String, kotlin/Char) // androidx.savedstate/SavedStateWriter.putChar|putChar(kotlin.String;kotlin.Char){}[0]
     final inline fun putCharArray(kotlin/String, kotlin/CharArray) // androidx.savedstate/SavedStateWriter.putCharArray|putCharArray(kotlin.String;kotlin.CharArray){}[0]
+    final inline fun putCharSequence(kotlin/String, kotlin/CharSequence) // androidx.savedstate/SavedStateWriter.putCharSequence|putCharSequence(kotlin.String;kotlin.CharSequence){}[0]
+    final inline fun putCharSequenceArray(kotlin/String, kotlin/Array<kotlin/CharSequence>) // androidx.savedstate/SavedStateWriter.putCharSequenceArray|putCharSequenceArray(kotlin.String;kotlin.Array<kotlin.CharSequence>){}[0]
+    final inline fun putCharSequenceList(kotlin/String, kotlin.collections/List<kotlin/CharSequence>) // androidx.savedstate/SavedStateWriter.putCharSequenceList|putCharSequenceList(kotlin.String;kotlin.collections.List<kotlin.CharSequence>){}[0]
     final inline fun putDouble(kotlin/String, kotlin/Double) // androidx.savedstate/SavedStateWriter.putDouble|putDouble(kotlin.String;kotlin.Double){}[0]
     final inline fun putDoubleArray(kotlin/String, kotlin/DoubleArray) // androidx.savedstate/SavedStateWriter.putDoubleArray|putDoubleArray(kotlin.String;kotlin.DoubleArray){}[0]
     final inline fun putFloat(kotlin/String, kotlin/Float) // androidx.savedstate/SavedStateWriter.putFloat|putFloat(kotlin.String;kotlin.Float){}[0]
diff --git a/savedstate/savedstate/src/androidMain/kotlin/androidx/savedstate/SavedStateReader.android.kt b/savedstate/savedstate/src/androidMain/kotlin/androidx/savedstate/SavedStateReader.android.kt
index b291dac..d3d59e0 100644
--- a/savedstate/savedstate/src/androidMain/kotlin/androidx/savedstate/SavedStateReader.android.kt
+++ b/savedstate/savedstate/src/androidMain/kotlin/androidx/savedstate/SavedStateReader.android.kt
@@ -20,9 +20,17 @@
 
 package androidx.savedstate
 
+import android.os.IBinder
 import android.os.Parcelable
+import android.util.Size
+import android.util.SizeF
+import android.util.SparseArray
 import androidx.core.os.BundleCompat.getParcelable
+import androidx.core.os.BundleCompat.getParcelableArray
 import androidx.core.os.BundleCompat.getParcelableArrayList
+import androidx.core.os.BundleCompat.getSerializable
+import androidx.core.os.BundleCompat.getSparseParcelableArray
+import java.io.Serializable
 
 @JvmInline
 actual value class SavedStateReader
@@ -31,6 +39,33 @@
     @PublishedApi internal actual val source: SavedState,
 ) {
 
+    /**
+     * Retrieves a [IBinder] object associated with the specified key. Throws an
+     * [IllegalStateException] if the key doesn't exist.
+     *
+     * @param key The key to retrieve the value for.
+     * @return The [IBinder] object associated with the key.
+     * @throws IllegalStateException If the key is not found.
+     */
+    inline fun getBinder(key: String): IBinder {
+        if (key !in this) keyNotFoundError(key)
+        return source.getBinder(key) ?: valueNotFoundError(key)
+    }
+
+    /**
+     * Retrieves a [IBinder] object associated with the specified key, or a default value if the key
+     * doesn't exist.
+     *
+     * @param key The key to retrieve the value for.
+     * @param defaultValue A function providing the default [IBinder] if the key is not found.
+     * @return The [IBinder] object associated with the key, or the default value if the key is not
+     *   found.
+     */
+    inline fun getBinderOrElse(key: String, defaultValue: () -> IBinder): IBinder {
+        if (key !in this) defaultValue()
+        return source.getBinder(key) ?: defaultValue()
+    }
+
     actual inline fun getBoolean(key: String): Boolean {
         if (key !in this) keyNotFoundError(key)
         return source.getBoolean(key, DEFAULT_BOOLEAN)
@@ -46,6 +81,19 @@
         return source.getChar(key, DEFAULT_CHAR)
     }
 
+    actual inline fun getCharSequence(key: String): CharSequence {
+        if (key !in this) keyNotFoundError(key)
+        return source.getCharSequence(key) ?: valueNotFoundError(key)
+    }
+
+    actual inline fun getCharSequenceOrElse(
+        key: String,
+        defaultValue: () -> CharSequence
+    ): CharSequence {
+        if (key !in this) defaultValue()
+        return source.getCharSequence(key) ?: defaultValue()
+    }
+
     actual inline fun getCharOrElse(key: String, defaultValue: () -> Char): Char {
         if (key !in this) defaultValue()
         return source.getChar(key, defaultValue())
@@ -118,6 +166,90 @@
         return getParcelable(source, key, T::class.java) ?: defaultValue()
     }
 
+    /**
+     * Retrieves a [Serializable] object associated with the specified key. Throws an
+     * [IllegalStateException] if the key doesn't exist.
+     *
+     * @param key The key to retrieve the value for.
+     * @return The [Serializable] object associated with the key.
+     * @throws IllegalStateException If the key is not found.
+     */
+    inline fun <reified T : Serializable> getSerializable(key: String): T {
+        if (key !in this) keyNotFoundError(key)
+        return getSerializable(source, key, T::class.java) ?: valueNotFoundError(key)
+    }
+
+    /**
+     * Retrieves a [Serializable] object associated with the specified key, or a default value if
+     * the key doesn't exist.
+     *
+     * @param key The key to retrieve the value for.
+     * @param defaultValue A function providing the default [Serializable] if the key is not found.
+     * @return The [Serializable] object associated with the key, or the default value if the key is
+     *   not found.
+     */
+    inline fun <reified T : Serializable> getSerializableOrElse(
+        key: String,
+        defaultValue: () -> T
+    ): T {
+        if (key !in this) defaultValue()
+        return getSerializable(source, key, T::class.java) ?: defaultValue()
+    }
+
+    /**
+     * Retrieves a [Size] object associated with the specified key. Throws an
+     * [IllegalStateException] if the key doesn't exist.
+     *
+     * @param key The key to retrieve the value for.
+     * @return The [Size] object associated with the key.
+     * @throws IllegalStateException If the key is not found.
+     */
+    inline fun getSize(key: String): Size {
+        if (key !in this) keyNotFoundError(key)
+        return source.getSize(key) ?: valueNotFoundError(key)
+    }
+
+    /**
+     * Retrieves a [Size] object associated with the specified key, or a default value if the key
+     * doesn't exist.
+     *
+     * @param key The key to retrieve the value for.
+     * @param defaultValue A function providing the default [Size] if the key is not found.
+     * @return The [Size] object associated with the key, or the default value if the key is not
+     *   found.
+     */
+    inline fun getSizeOrElse(key: String, defaultValue: () -> Size): Size {
+        if (key !in this) defaultValue()
+        return source.getSize(key) ?: defaultValue()
+    }
+
+    /**
+     * Retrieves a [SizeF] object associated with the specified key. Throws an
+     * [IllegalStateException] if the key doesn't exist.
+     *
+     * @param key The key to retrieve the value for.
+     * @return The [SizeF] object associated with the key.
+     * @throws IllegalStateException If the key is not found.
+     */
+    inline fun getSizeF(key: String): SizeF {
+        if (key !in this) keyNotFoundError(key)
+        return source.getSizeF(key) ?: valueNotFoundError(key)
+    }
+
+    /**
+     * Retrieves a [SizeF] object associated with the specified key, or a default value if the key
+     * doesn't exist.
+     *
+     * @param key The key to retrieve the value for.
+     * @param defaultValue A function providing the default [SizeF] if the key is not found.
+     * @return The [SizeF] object associated with the key, or the default value if the key is not
+     *   found.
+     */
+    inline fun getSizeFOrElse(key: String, defaultValue: () -> SizeF): SizeF {
+        if (key !in this) defaultValue()
+        return source.getSizeF(key) ?: defaultValue()
+    }
+
     actual inline fun getString(key: String): String {
         if (key !in this) keyNotFoundError(key)
         return source.getString(key) ?: valueNotFoundError(key)
@@ -138,6 +270,19 @@
         return source.getIntegerArrayList(key) ?: defaultValue()
     }
 
+    actual inline fun getCharSequenceList(key: String): List<CharSequence> {
+        if (key !in this) keyNotFoundError(key)
+        return source.getCharSequenceArrayList(key) ?: valueNotFoundError(key)
+    }
+
+    actual inline fun getCharSequenceListOrElse(
+        key: String,
+        defaultValue: () -> List<CharSequence>
+    ): List<CharSequence> {
+        if (key !in this) defaultValue()
+        return source.getCharSequenceArrayList(key) ?: defaultValue()
+    }
+
     actual inline fun getStringList(key: String): List<String> {
         if (key !in this) keyNotFoundError(key)
         return source.getStringArrayList(key) ?: valueNotFoundError(key)
@@ -170,7 +315,7 @@
      *
      * @param key The [key] to retrieve the value for.
      * @param defaultValue A function providing the default value if the [key] is not found or the
-     *   retrieved value is not a list of [Parcelable].
+     *   retrieved value is not a [List] of [Parcelable].
      * @return The list of elements of [Parcelable] associated with the [key], or the default value
      *   if the [key] is not found.
      */
@@ -205,6 +350,21 @@
         return source.getCharArray(key) ?: defaultValue()
     }
 
+    @Suppress("ArrayReturn")
+    actual inline fun getCharSequenceArray(key: String): Array<CharSequence> {
+        if (key !in this) keyNotFoundError(key)
+        return source.getCharSequenceArray(key) ?: valueNotFoundError(key)
+    }
+
+    @Suppress("ArrayReturn")
+    actual inline fun getCharSequenceArrayOrElse(
+        key: String,
+        defaultValue: () -> Array<CharSequence>
+    ): Array<CharSequence> {
+        if (key !in this) defaultValue()
+        return source.getCharSequenceArray(key) ?: defaultValue()
+    }
+
     actual inline fun getDoubleArray(key: String): DoubleArray {
         if (key !in this) keyNotFoundError(key)
         return source.getDoubleArray(key) ?: valueNotFoundError(key)
@@ -261,6 +421,75 @@
         return source.getStringArray(key) ?: defaultValue()
     }
 
+    /**
+     * Retrieves an [Array] of elements of [Parcelable] associated with the specified [key]. Throws
+     * an [IllegalStateException] if the [key] doesn't exist.
+     *
+     * @param key The [key] to retrieve the value for.
+     * @return The [Array] of elements of [Parcelable] associated with the [key].
+     * @throws IllegalStateException If the [key] is not found.
+     */
+    @Suppress("ArrayReturn")
+    inline fun <reified T : Parcelable> getParcelableArray(key: String): Array<T> {
+        if (key !in this) keyNotFoundError(key)
+        @Suppress("UNCHECKED_CAST")
+        return getParcelableArray(source, key, T::class.java) as? Array<T>
+            ?: valueNotFoundError(key)
+    }
+
+    /**
+     * Retrieves a [Array] of elements of [Parcelable] associated with the specified [key], or a
+     * default value if the [key] doesn't exist.
+     *
+     * @param key The [key] to retrieve the value for.
+     * @param defaultValue A function providing the default value if the [key] is not found or the
+     *   retrieved value is not a [Array] of [Parcelable].
+     * @return The [Array] of elements of [Parcelable] associated with the [key], or the default
+     *   value if the [key] is not found.
+     */
+    @Suppress("ArrayReturn")
+    inline fun <reified T : Parcelable> getParcelableArrayOrElse(
+        key: String,
+        defaultValue: () -> Array<T>
+    ): Array<T> {
+        if (key !in this) defaultValue()
+        @Suppress("UNCHECKED_CAST")
+        return getParcelableArray(source, key, T::class.java) as? Array<T> ?: defaultValue()
+    }
+
+    /**
+     * Retrieves an [SparseArray] of elements of [Parcelable] associated with the specified [key].
+     * Throws an [IllegalStateException] if the [key] doesn't exist.
+     *
+     * @param key The [key] to retrieve the value for.
+     * @return The [SparseArray] of elements of [Parcelable] associated with the [key].
+     * @throws IllegalStateException If the [key] is not found.
+     */
+    inline fun <reified T : Parcelable> getSparseParcelableArray(key: String): SparseArray<T> {
+        if (key !in this) keyNotFoundError(key)
+        return getSparseParcelableArray(source, key, T::class.java) as? SparseArray<T>
+            ?: valueNotFoundError(key)
+    }
+
+    /**
+     * Retrieves a [SparseArray] of elements of [Parcelable] associated with the specified [key], or
+     * a default value if the [key] doesn't exist.
+     *
+     * @param key The [key] to retrieve the value for.
+     * @param defaultValue A function providing the default value if the [key] is not found or the
+     *   retrieved value is not a [SparseArray] of [Parcelable].
+     * @return The [SparseArray] of elements of [Parcelable] associated with the [key], or the
+     *   default value if the [key] is not found.
+     */
+    inline fun <reified T : Parcelable> getSparseParcelableArrayOrElse(
+        key: String,
+        defaultValue: () -> SparseArray<T>
+    ): SparseArray<T> {
+        if (key !in this) defaultValue()
+        return getSparseParcelableArray(source, key, T::class.java) as? SparseArray<T>
+            ?: defaultValue()
+    }
+
     actual inline fun getSavedState(key: String): SavedState {
         if (key !in this) keyNotFoundError(key)
         return source.getBundle(key) ?: valueNotFoundError(key)
diff --git a/savedstate/savedstate/src/androidMain/kotlin/androidx/savedstate/SavedStateWriter.android.kt b/savedstate/savedstate/src/androidMain/kotlin/androidx/savedstate/SavedStateWriter.android.kt
index db7d120..a2795d0 100644
--- a/savedstate/savedstate/src/androidMain/kotlin/androidx/savedstate/SavedStateWriter.android.kt
+++ b/savedstate/savedstate/src/androidMain/kotlin/androidx/savedstate/SavedStateWriter.android.kt
@@ -20,7 +20,12 @@
 
 package androidx.savedstate
 
+import android.os.IBinder
 import android.os.Parcelable
+import android.util.Size
+import android.util.SizeF
+import android.util.SparseArray
+import java.io.Serializable
 
 @JvmInline
 actual value class SavedStateWriter
@@ -29,6 +34,16 @@
     @PublishedApi internal actual val source: SavedState,
 ) {
 
+    /**
+     * Stores an [IBinder] value associated with the specified key in the [IBinder].
+     *
+     * @param key The key to associate the value with.
+     * @param value The [IBinder] value to store.
+     */
+    inline fun putBinder(key: String, value: IBinder) {
+        source.putBinder(key, value)
+    }
+
     actual inline fun putBoolean(key: String, value: Boolean) {
         source.putBoolean(key, value)
     }
@@ -37,6 +52,10 @@
         source.putChar(key, value)
     }
 
+    actual inline fun putCharSequence(key: String, value: CharSequence) {
+        source.putCharSequence(key, value)
+    }
+
     actual inline fun putDouble(key: String, value: Double) {
         source.putDouble(key, value)
     }
@@ -67,6 +86,36 @@
         source.putParcelable(key, value)
     }
 
+    /**
+     * Stores an [Serializable] value associated with the specified key in the [Serializable].
+     *
+     * @param key The key to associate the value with.
+     * @param value The [Serializable] value to store.
+     */
+    inline fun <reified T : Serializable> putSerializable(key: String, value: T) {
+        source.putSerializable(key, value)
+    }
+
+    /**
+     * Stores an [Size] value associated with the specified key in the [Size].
+     *
+     * @param key The key to associate the value with.
+     * @param value The [Size] value to store.
+     */
+    inline fun putSize(key: String, value: Size) {
+        source.putSize(key, value)
+    }
+
+    /**
+     * Stores an [SizeF] value associated with the specified key in the [SizeF].
+     *
+     * @param key The key to associate the value with.
+     * @param value The [SizeF] value to store.
+     */
+    inline fun putSizeF(key: String, value: SizeF) {
+        source.putSizeF(key, value)
+    }
+
     actual inline fun putString(key: String, value: String) {
         source.putString(key, value)
     }
@@ -75,16 +124,20 @@
         source.putIntegerArrayList(key, values.toArrayListUnsafe())
     }
 
+    actual inline fun putCharSequenceList(key: String, values: List<CharSequence>) {
+        source.putCharSequenceArrayList(key, values.toArrayListUnsafe())
+    }
+
     actual inline fun putStringList(key: String, values: List<String>) {
         source.putStringArrayList(key, values.toArrayListUnsafe())
     }
 
     /**
-     * Stores a list of elements of [Parcelable] associated with the specified key in the
+     * Stores a [List] of elements of [Parcelable] associated with the specified key in the
      * [SavedState].
      *
      * @param key The key to associate the value with.
-     * @param values The list of elements to store.
+     * @param values The [List] of elements to store.
      */
     inline fun <reified T : Parcelable> putParcelableList(key: String, values: List<T>) {
         source.putParcelableArrayList(key, values.toArrayListUnsafe())
@@ -98,6 +151,13 @@
         source.putCharArray(key, values)
     }
 
+    actual inline fun putCharSequenceArray(
+        key: String,
+        @Suppress("ArrayReturn") values: Array<CharSequence>
+    ) {
+        source.putCharSequenceArray(key, values)
+    }
+
     actual inline fun putDoubleArray(key: String, values: DoubleArray) {
         source.putDoubleArray(key, values)
     }
@@ -118,6 +178,34 @@
         source.putStringArray(key, values)
     }
 
+    /**
+     * Stores a [Array] of elements of [Parcelable] associated with the specified key in the
+     * [SavedState].
+     *
+     * @param key The key to associate the value with.
+     * @param values The [Array] of elements to store.
+     */
+    inline fun <reified T : Parcelable> putParcelableArray(
+        key: String,
+        @Suppress("ArrayReturn") values: Array<T>
+    ) {
+        source.putParcelableArray(key, values)
+    }
+
+    /**
+     * Stores a [SparseArray] of elements of [Parcelable] associated with the specified key in the
+     * [SavedState].
+     *
+     * @param key The key to associate the value with.
+     * @param values The [SparseArray] of elements to store.
+     */
+    inline fun <reified T : Parcelable> putSparseParcelableArray(
+        key: String,
+        values: SparseArray<T>
+    ) {
+        source.putSparseParcelableArray(key, values)
+    }
+
     actual inline fun putSavedState(key: String, value: SavedState) {
         source.putBundle(key, value)
     }
diff --git a/savedstate/savedstate/src/androidUnitTest/kotlin/androidx/savedstate/ParcelableSavedStateTest.android.kt b/savedstate/savedstate/src/androidUnitTest/kotlin/androidx/savedstate/ParcelableSavedStateTest.android.kt
deleted file mode 100644
index 0a0dd72..0000000
--- a/savedstate/savedstate/src/androidUnitTest/kotlin/androidx/savedstate/ParcelableSavedStateTest.android.kt
+++ /dev/null
@@ -1,153 +0,0 @@
-/*
- * Copyright 2024 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package androidx.savedstate
-
-import android.os.Parcel
-import android.os.Parcelable
-import androidx.kruth.assertThat
-import androidx.kruth.assertThrows
-import kotlin.test.Test
-
-internal class ParcelableSavedStateTest : RobolectricTest() {
-
-    @Test
-    fun getParcelable_whenSet_returns() {
-        val underTest = savedState { putParcelable(KEY_1, PARCELABLE_VALUE_1) }
-        val actual = underTest.read { getParcelable<TestParcelable>(KEY_1) }
-
-        assertThat(actual).isEqualTo(PARCELABLE_VALUE_1)
-    }
-
-    @Test
-    fun getParcelable_whenNotSet_throws() {
-        assertThrows<IllegalArgumentException> {
-            savedState().read { getParcelable<TestParcelable>(KEY_1) }
-        }
-    }
-
-    @Test
-    fun getParcelable_whenSet_differentType_returnsDefault() {
-        val underTest = savedState { putInt(KEY_1, Int.MAX_VALUE) }
-
-        assertThrows<IllegalStateException> {
-            underTest.read { getParcelable<TestParcelable>(KEY_1) }
-        }
-    }
-
-    @Test
-    fun getParcelableOrElse_whenSet_returns() {
-        val underTest = savedState { putParcelable(KEY_1, PARCELABLE_VALUE_1) }
-        val actual = underTest.read { getParcelableOrElse(KEY_1) { PARCELABLE_VALUE_2 } }
-
-        assertThat(actual).isEqualTo(PARCELABLE_VALUE_1)
-    }
-
-    @Test
-    fun getParcelableOrElse_whenNotSet_returnsElse() {
-        val actual = savedState().read { getParcelableOrElse(KEY_1) { PARCELABLE_VALUE_1 } }
-
-        assertThat(actual).isEqualTo(PARCELABLE_VALUE_1)
-    }
-
-    @Test
-    fun getParcelableOrElse_whenSet_differentType_returnsDefault() {
-        val underTest = savedState { putInt(KEY_1, Int.MAX_VALUE) }
-        val actual = underTest.read { getParcelableOrElse(KEY_1) { PARCELABLE_VALUE_1 } }
-
-        assertThat(actual).isEqualTo(PARCELABLE_VALUE_1)
-    }
-
-    @Test
-    fun getParcelableList_whenSet_returns() {
-        val expected = List(size = 5) { idx -> TestParcelable(idx) }
-
-        val underTest = savedState { putParcelableList(KEY_1, expected) }
-        val actual = underTest.read { getParcelableList<TestParcelable>(KEY_1) }
-
-        assertThat(actual).isEqualTo(expected)
-    }
-
-    @Test
-    fun getList_ofParcelable_whenNotSet_throws() {
-        assertThrows<IllegalArgumentException> {
-            savedState().read { getParcelableList<TestParcelable>(KEY_1) }
-        }
-    }
-
-    @Test
-    fun getList_whenSet_differentType_throws() {
-        val underTest = savedState { putInt(KEY_1, Int.MAX_VALUE) }
-
-        assertThrows<IllegalStateException> {
-            underTest.read { getParcelableList<TestParcelable>(KEY_1) }
-        }
-    }
-
-    @Test
-    fun getListOrElse_ofParcelable_whenSet_returns() {
-        val expected = List(size = 5) { idx -> TestParcelable(idx) }
-
-        val underTest = savedState { putParcelableList(KEY_1, expected) }
-        val actual =
-            underTest.read { getParcelableListOrElse<TestParcelable>(KEY_1) { emptyList() } }
-
-        assertThat(actual).isEqualTo(expected)
-    }
-
-    @Test
-    fun getListOrElse_ofParcelable_whenNotSet_returnsElse() {
-        val actual =
-            savedState().read { getParcelableListOrElse<TestParcelable>(KEY_1) { emptyList() } }
-
-        assertThat(actual).isEqualTo(emptyList<TestParcelable>())
-    }
-
-    @Test
-    fun getListOrElse_whenSet_differentType_throws() {
-        val underTest = savedState { putInt(KEY_1, Int.MAX_VALUE) }
-        val actual = underTest.read { getParcelableListOrElse(KEY_1) { emptyList() } }
-
-        assertThat(actual).isEqualTo(emptyList<Parcelable>())
-    }
-
-    private companion object {
-        const val KEY_1 = "KEY_1"
-        val PARCELABLE_VALUE_1 = TestParcelable(value = Int.MIN_VALUE)
-        val PARCELABLE_VALUE_2 = TestParcelable(value = Int.MAX_VALUE)
-    }
-
-    internal data class TestParcelable(val value: Int) : Parcelable {
-
-        override fun describeContents(): Int = 0
-
-        override fun writeToParcel(dest: Parcel, flags: Int) {
-            dest.writeInt(value)
-        }
-
-        companion object {
-            @Suppress("unused")
-            @JvmField
-            val CREATOR =
-                object : Parcelable.Creator<TestParcelable> {
-                    override fun createFromParcel(source: Parcel) =
-                        TestParcelable(value = source.readInt())
-
-                    override fun newArray(size: Int) = arrayOfNulls<TestParcelable>(size)
-                }
-        }
-    }
-}
diff --git a/savedstate/savedstate/src/androidUnitTest/kotlin/androidx/savedstate/SavedStateAndroidTest.android.kt b/savedstate/savedstate/src/androidUnitTest/kotlin/androidx/savedstate/SavedStateAndroidTest.android.kt
new file mode 100644
index 0000000..91f3395
--- /dev/null
+++ b/savedstate/savedstate/src/androidUnitTest/kotlin/androidx/savedstate/SavedStateAndroidTest.android.kt
@@ -0,0 +1,483 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.savedstate
+
+import android.os.IBinder
+import android.os.Parcel
+import android.os.Parcelable
+import android.util.Size
+import android.util.SizeF
+import android.util.SparseArray
+import androidx.kruth.assertThat
+import androidx.kruth.assertThrows
+import java.io.FileDescriptor
+import java.io.Serializable
+import kotlin.test.Test
+
+internal class ParcelableSavedStateTest : RobolectricTest() {
+
+    @Test
+    fun getBinder_whenSet_returns() {
+        val underTest = savedState { putBinder(KEY_1, BINDER_VALUE_1) }
+        val actual = underTest.read { getBinder(KEY_1) }
+
+        assertThat(actual).isEqualTo(BINDER_VALUE_1)
+    }
+
+    @Test
+    fun getBinder_whenNotSet_throws() {
+        assertThrows<IllegalArgumentException> { savedState().read { getBinder(KEY_1) } }
+    }
+
+    @Test
+    fun getBinder_whenSet_differentType_returnsDefault() {
+        val underTest = savedState { putInt(KEY_1, Int.MAX_VALUE) }
+
+        assertThrows<IllegalStateException> { underTest.read { getBinder(KEY_1) } }
+    }
+
+    @Test
+    fun getBinderOrElse_whenSet_returns() {
+        val underTest = savedState { putBinder(KEY_1, BINDER_VALUE_1) }
+        val actual = underTest.read { getBinderOrElse(KEY_1) { BINDER_VALUE_2 } }
+
+        assertThat(actual).isEqualTo(BINDER_VALUE_1)
+    }
+
+    @Test
+    fun getBinderOrElse_whenNotSet_returnsElse() {
+        val actual = savedState().read { getBinderOrElse(KEY_1) { BINDER_VALUE_2 } }
+
+        assertThat(actual).isEqualTo(BINDER_VALUE_2)
+    }
+
+    @Test
+    fun getBinderOrElse_whenSet_differentType_returnsDefault() {
+        val underTest = savedState { putInt(KEY_1, Int.MAX_VALUE) }
+        val actual = underTest.read { getBinderOrElse(KEY_1) { BINDER_VALUE_2 } }
+
+        assertThat(actual).isEqualTo(BINDER_VALUE_2)
+    }
+
+    @Test
+    fun getSize_whenSet_returns() {
+        val underTest = savedState { putSize(KEY_1, SIZE_IN_PIXEL_VALUE_1) }
+        val actual = underTest.read { getSize(KEY_1) }
+
+        assertThat(actual).isEqualTo(SIZE_IN_PIXEL_VALUE_1)
+    }
+
+    @Test
+    fun getSize_whenNotSet_throws() {
+        assertThrows<IllegalArgumentException> { savedState().read { getSize(KEY_1) } }
+    }
+
+    @Test
+    fun getSize_whenSet_differentType_throws() {
+        val underTest = savedState { putInt(KEY_1, Int.MAX_VALUE) }
+
+        assertThrows<IllegalStateException> { underTest.read { getSize(KEY_1) } }
+    }
+
+    @Test
+    fun getSizeOrElse_whenSet_returns() {
+        val underTest = savedState { putSize(KEY_1, SIZE_IN_PIXEL_VALUE_1) }
+        val actual = underTest.read { getSizeOrElse(KEY_1) { SIZE_IN_PIXEL_VALUE_2 } }
+
+        assertThat(actual).isEqualTo(SIZE_IN_PIXEL_VALUE_1)
+    }
+
+    @Test
+    fun getSizeOrElse_whenNotSet_returnsElse() {
+        val actual = savedState().read { getSizeOrElse(KEY_1) { SIZE_IN_PIXEL_VALUE_2 } }
+
+        assertThat(actual).isEqualTo(SIZE_IN_PIXEL_VALUE_2)
+    }
+
+    @Test
+    fun getSizeOrElse_whenSet_differentType_returnsDefault() {
+        val underTest = savedState { putInt(KEY_1, Int.MAX_VALUE) }
+        val actual = underTest.read { getSizeOrElse(KEY_1) { SIZE_IN_PIXEL_VALUE_2 } }
+
+        assertThat(actual).isEqualTo(SIZE_IN_PIXEL_VALUE_2)
+    }
+
+    @Test
+    fun getSizeF_whenSet_returns() {
+        val underTest = savedState { putSizeF(KEY_1, SIZE_IN_FLOAT_VALUE_1) }
+        val actual = underTest.read { getSizeF(KEY_1) }
+
+        assertThat(actual).isEqualTo(SIZE_IN_FLOAT_VALUE_1)
+    }
+
+    @Test
+    fun getSizeF_whenNotSet_throws() {
+        assertThrows<IllegalArgumentException> { savedState().read { getSizeF(KEY_1) } }
+    }
+
+    @Test
+    fun getSizeF_whenSet_differentType_throws() {
+        val underTest = savedState { putInt(KEY_1, Int.MAX_VALUE) }
+
+        assertThrows<IllegalStateException> { underTest.read { getSizeF(KEY_1) } }
+    }
+
+    @Test
+    fun getSizeFOrElse_whenSet_returns() {
+        val underTest = savedState { putSizeF(KEY_1, SIZE_IN_FLOAT_VALUE_1) }
+        val actual = underTest.read { getSizeFOrElse(KEY_1) { SIZE_IN_FLOAT_VALUE_2 } }
+
+        assertThat(actual).isEqualTo(SIZE_IN_FLOAT_VALUE_1)
+    }
+
+    @Test
+    fun getSizeFOrElse_whenNotSet_returnsElse() {
+        val actual = savedState().read { getSizeFOrElse(KEY_1) { SIZE_IN_FLOAT_VALUE_2 } }
+
+        assertThat(actual).isEqualTo(SIZE_IN_FLOAT_VALUE_2)
+    }
+
+    @Test
+    fun getSizeFOrElse_whenSet_differentType_returnsDefault() {
+        val underTest = savedState { putInt(KEY_1, Int.MAX_VALUE) }
+        val actual = underTest.read { getSizeFOrElse(KEY_1) { SIZE_IN_FLOAT_VALUE_2 } }
+
+        assertThat(actual).isEqualTo(SIZE_IN_FLOAT_VALUE_2)
+    }
+
+    @Test
+    fun getParcelable_whenSet_returns() {
+        val underTest = savedState { putParcelable(KEY_1, PARCELABLE_VALUE_1) }
+        val actual = underTest.read { getParcelable<TestParcelable>(KEY_1) }
+
+        assertThat(actual).isEqualTo(PARCELABLE_VALUE_1)
+    }
+
+    @Test
+    fun getParcelable_whenNotSet_throws() {
+        assertThrows<IllegalArgumentException> {
+            savedState().read { getParcelable<TestParcelable>(KEY_1) }
+        }
+    }
+
+    @Test
+    fun getParcelable_whenSet_differentType_throws() {
+        val underTest = savedState { putInt(KEY_1, Int.MAX_VALUE) }
+
+        assertThrows<IllegalStateException> {
+            underTest.read { getParcelable<TestParcelable>(KEY_1) }
+        }
+    }
+
+    @Test
+    fun getParcelableOrElse_whenSet_returns() {
+        val underTest = savedState { putParcelable(KEY_1, PARCELABLE_VALUE_1) }
+        val actual = underTest.read { getParcelableOrElse(KEY_1) { PARCELABLE_VALUE_2 } }
+
+        assertThat(actual).isEqualTo(PARCELABLE_VALUE_1)
+    }
+
+    @Test
+    fun getParcelableOrElse_whenNotSet_returnsElse() {
+        val actual = savedState().read { getParcelableOrElse(KEY_1) { PARCELABLE_VALUE_1 } }
+
+        assertThat(actual).isEqualTo(PARCELABLE_VALUE_1)
+    }
+
+    @Test
+    fun getParcelableOrElse_whenSet_differentType_returnsDefault() {
+        val underTest = savedState { putInt(KEY_1, Int.MAX_VALUE) }
+        val actual = underTest.read { getParcelableOrElse(KEY_1) { PARCELABLE_VALUE_1 } }
+
+        assertThat(actual).isEqualTo(PARCELABLE_VALUE_1)
+    }
+
+    @Test
+    fun getParcelableList_whenSet_returns() {
+        val expected = List(size = 5) { idx -> TestParcelable(idx) }
+
+        val underTest = savedState { putParcelableList(KEY_1, expected) }
+        val actual = underTest.read { getParcelableList<TestParcelable>(KEY_1) }
+
+        assertThat(actual).isEqualTo(expected)
+    }
+
+    @Test
+    fun getParcelableList_whenNotSet_throws() {
+        assertThrows<IllegalArgumentException> {
+            savedState().read { getParcelableList<TestParcelable>(KEY_1) }
+        }
+    }
+
+    @Test
+    fun getParcelableList_whenSet_differentType_throws() {
+        val underTest = savedState { putInt(KEY_1, Int.MAX_VALUE) }
+
+        assertThrows<IllegalStateException> {
+            underTest.read { getParcelableList<TestParcelable>(KEY_1) }
+        }
+    }
+
+    @Test
+    fun getParcelableListOrElse_whenSet_returns() {
+        val expected = List(size = 5) { idx -> TestParcelable(idx) }
+
+        val underTest = savedState { putParcelableList(KEY_1, expected) }
+        val actual =
+            underTest.read { getParcelableListOrElse<TestParcelable>(KEY_1) { emptyList() } }
+
+        assertThat(actual).isEqualTo(expected)
+    }
+
+    @Test
+    fun getParcelableListOrElse_whenNotSet_returnsElse() {
+        val actual =
+            savedState().read { getParcelableListOrElse<TestParcelable>(KEY_1) { emptyList() } }
+
+        assertThat(actual).isEqualTo(emptyList<TestParcelable>())
+    }
+
+    @Test
+    fun getParcelableListOrElse_whenSet_differentType_throws() {
+        val underTest = savedState { putInt(KEY_1, Int.MAX_VALUE) }
+        val actual = underTest.read { getParcelableListOrElse(KEY_1) { emptyList() } }
+
+        assertThat(actual).isEqualTo(emptyList<Parcelable>())
+    }
+
+    @Test
+    fun getParcelableArray_whenSet_returns() {
+        val expected = Array(size = 5) { idx -> TestParcelable(idx) }
+
+        val underTest = savedState { putParcelableArray(KEY_1, expected) }
+        val actual = underTest.read { getParcelableArray<TestParcelable>(KEY_1) }
+
+        assertThat(actual).isEqualTo(expected)
+    }
+
+    @Test
+    fun getParcelableArray_ofParcelable_whenNotSet_throws() {
+        assertThrows<IllegalArgumentException> {
+            savedState().read { getParcelableArray<TestParcelable>(KEY_1) }
+        }
+    }
+
+    @Test
+    fun getParcelableArray_whenSet_differentType_throws() {
+        val underTest = savedState { putInt(KEY_1, Int.MAX_VALUE) }
+
+        assertThrows<IllegalStateException> {
+            underTest.read { getParcelableArray<TestParcelable>(KEY_1) }
+        }
+    }
+
+    @Test
+    fun getParcelableArrayOrElse_whenSet_returns() {
+        val expected = Array(size = 5) { idx -> TestParcelable(idx) }
+
+        val underTest = savedState { putParcelableArray(KEY_1, expected) }
+        val actual =
+            underTest.read { getParcelableArrayOrElse<TestParcelable>(KEY_1) { emptyArray() } }
+
+        assertThat(actual).isEqualTo(expected)
+    }
+
+    @Test
+    fun getParcelableArrayOrElse_ofParcelable_whenNotSet_returnsElse() {
+        val actual =
+            savedState().read { getParcelableArrayOrElse<TestParcelable>(KEY_1) { emptyArray() } }
+
+        assertThat(actual).isEqualTo(emptyArray<TestParcelable>())
+    }
+
+    @Test
+    fun getParcelableArrayOrElse_whenSet_differentType_throws() {
+        val underTest = savedState { putInt(KEY_1, Int.MAX_VALUE) }
+        val actual = underTest.read { getParcelableArrayOrElse(KEY_1) { emptyArray() } }
+
+        assertThat(actual).isEqualTo(emptyArray<Parcelable>())
+    }
+
+    @Test
+    fun getSparseParcelableArray_whenSet_returns() {
+        val expected = SPARSE_PARCELABLE_ARRAY
+
+        val underTest = savedState { putSparseParcelableArray(KEY_1, expected) }
+        val actual = underTest.read { getSparseParcelableArray<TestParcelable>(KEY_1) }
+
+        assertThat(actual).isEqualTo(expected)
+    }
+
+    @Test
+    fun getSparseParcelableArray_ofParcelable_whenNotSet_throws() {
+        assertThrows<IllegalArgumentException> {
+            savedState().read { getSparseParcelableArray<TestParcelable>(KEY_1) }
+        }
+    }
+
+    @Test
+    fun getSparseParcelableArray_whenSet_differentType_throws() {
+        val underTest = savedState { putInt(KEY_1, Int.MAX_VALUE) }
+
+        assertThrows<IllegalStateException> {
+            underTest.read { getSparseParcelableArray<TestParcelable>(KEY_1) }
+        }
+    }
+
+    @Test
+    fun getSparseParcelableArrayOrElse_whenSet_returns() {
+        val expected = SPARSE_PARCELABLE_ARRAY
+
+        val underTest = savedState { putSparseParcelableArray(KEY_1, expected) }
+        val actual =
+            underTest.read {
+                getSparseParcelableArrayOrElse<TestParcelable>(KEY_1) { SparseArray() }
+            }
+
+        assertThat(actual).isEqualTo(expected)
+    }
+
+    @Test
+    fun getSparseParcelableArrayOrElse_ofParcelable_whenNotSet_returnsElse() {
+        val expected = SPARSE_PARCELABLE_ARRAY
+
+        val actual =
+            savedState().read { getSparseParcelableArrayOrElse<TestParcelable>(KEY_1) { expected } }
+
+        assertThat(actual).isEqualTo(expected)
+    }
+
+    @Test
+    fun getSparseParcelableArrayOrElse_whenSet_differentType_throws() {
+        val expected = SPARSE_PARCELABLE_ARRAY
+
+        val underTest = savedState { putInt(KEY_1, Int.MAX_VALUE) }
+        val actual = underTest.read { getSparseParcelableArrayOrElse(KEY_1) { expected } }
+
+        assertThat(actual).isEqualTo(expected)
+    }
+
+    @Test
+    fun getSerializable_whenSet_returns() {
+        val underTest = savedState { putSerializable(KEY_1, SERIALIZABLE_VALUE_1) }
+        val actual = underTest.read { getSerializable<TestSerializable>(KEY_1) }
+
+        assertThat(actual).isEqualTo(SERIALIZABLE_VALUE_1)
+    }
+
+    @Test
+    fun getSerializable_whenNotSet_throws() {
+        assertThrows<IllegalArgumentException> {
+            savedState().read { getSerializable<TestSerializable>(KEY_1) }
+        }
+    }
+
+    @Test
+    fun getSerializable_whenSet_differentType_throws() {
+        val underTest = savedState { putInt(KEY_1, Int.MAX_VALUE) }
+
+        assertThrows<IllegalStateException> {
+            underTest.read { getSerializable<TestSerializable>(KEY_1) }
+        }
+    }
+
+    @Test
+    fun getSerializableOrElse_whenSet_returns() {
+        val underTest = savedState { putSerializable(KEY_1, SERIALIZABLE_VALUE_1) }
+        val actual = underTest.read { getSerializableOrElse(KEY_1) { SERIALIZABLE_VALUE_2 } }
+
+        assertThat(actual).isEqualTo(SERIALIZABLE_VALUE_1)
+    }
+
+    @Test
+    fun getSerializableOrElse_whenNotSet_returnsElse() {
+        val actual = savedState().read { getSerializableOrElse(KEY_1) { SERIALIZABLE_VALUE_2 } }
+
+        assertThat(actual).isEqualTo(SERIALIZABLE_VALUE_2)
+    }
+
+    @Test
+    fun getSerializableOrElse_whenSet_differentType_returnsDefault() {
+        val underTest = savedState { putInt(KEY_1, Int.MAX_VALUE) }
+        val actual = underTest.read { getSerializableOrElse(KEY_1) { SERIALIZABLE_VALUE_1 } }
+
+        assertThat(actual).isEqualTo(SERIALIZABLE_VALUE_1)
+    }
+
+    private companion object {
+        const val KEY_1 = "KEY_1"
+        val SIZE_IN_PIXEL_VALUE_1 = Size(/* width= */ Int.MIN_VALUE, /* height */ Int.MIN_VALUE)
+        val SIZE_IN_PIXEL_VALUE_2 = Size(/* width= */ Int.MAX_VALUE, /* height */ Int.MAX_VALUE)
+        val SIZE_IN_FLOAT_VALUE_1 =
+            SizeF(/* width= */ Float.MIN_VALUE, /* height */ Float.MIN_VALUE)
+        val SIZE_IN_FLOAT_VALUE_2 =
+            SizeF(/* width= */ Float.MAX_VALUE, /* height */ Float.MAX_VALUE)
+        val BINDER_VALUE_1 = TestBinder(value = Int.MIN_VALUE)
+        val BINDER_VALUE_2 = TestBinder(value = Int.MAX_VALUE)
+        val PARCELABLE_VALUE_1 = TestParcelable(value = Int.MIN_VALUE)
+        val PARCELABLE_VALUE_2 = TestParcelable(value = Int.MAX_VALUE)
+        val SERIALIZABLE_VALUE_1 = TestSerializable(value = Int.MIN_VALUE)
+        val SERIALIZABLE_VALUE_2 = TestSerializable(value = Int.MAX_VALUE)
+        val SPARSE_PARCELABLE_ARRAY =
+            SparseArray<TestParcelable>(/* initialCapacity= */ 5).apply {
+                repeat(times = 5) { idx -> put(idx, TestParcelable(idx)) }
+            }
+    }
+
+    internal data class TestBinder(val value: Int) : IBinder {
+        override fun getInterfaceDescriptor() = error("")
+
+        override fun pingBinder() = error("")
+
+        override fun isBinderAlive() = error("")
+
+        override fun queryLocalInterface(descriptor: String) = error("")
+
+        override fun dump(fd: FileDescriptor, args: Array<out String>?) = error("")
+
+        override fun dumpAsync(fd: FileDescriptor, args: Array<out String>?) = error("")
+
+        override fun transact(code: Int, data: Parcel, reply: Parcel?, flags: Int) = error("")
+
+        override fun linkToDeath(recipient: IBinder.DeathRecipient, flags: Int) = error("")
+
+        override fun unlinkToDeath(recipient: IBinder.DeathRecipient, flags: Int) = error("")
+    }
+
+    internal data class TestParcelable(val value: Int) : Parcelable {
+
+        override fun describeContents(): Int = 0
+
+        override fun writeToParcel(dest: Parcel, flags: Int) {
+            dest.writeInt(value)
+        }
+
+        companion object {
+            @Suppress("unused")
+            @JvmField
+            val CREATOR =
+                object : Parcelable.Creator<TestParcelable> {
+                    override fun createFromParcel(source: Parcel) =
+                        TestParcelable(value = source.readInt())
+
+                    override fun newArray(size: Int) = arrayOfNulls<TestParcelable>(size)
+                }
+        }
+    }
+
+    internal data class TestSerializable(val value: Int) : Serializable
+}
diff --git a/savedstate/savedstate/src/commonMain/kotlin/androidx/savedstate/SavedStateReader.kt b/savedstate/savedstate/src/commonMain/kotlin/androidx/savedstate/SavedStateReader.kt
index 5249fa0..a18e93e 100644
--- a/savedstate/savedstate/src/commonMain/kotlin/androidx/savedstate/SavedStateReader.kt
+++ b/savedstate/savedstate/src/commonMain/kotlin/androidx/savedstate/SavedStateReader.kt
@@ -25,10 +25,15 @@
 import kotlin.jvm.JvmName
 
 @PublishedApi internal const val DEFAULT_BOOLEAN: Boolean = false
+
 @PublishedApi internal const val DEFAULT_CHAR: Char = 0.toChar()
+
 @PublishedApi internal const val DEFAULT_FLOAT: Float = 0F
+
 @PublishedApi internal const val DEFAULT_DOUBLE: Double = 0.0
+
 @PublishedApi internal const val DEFAULT_INT: Int = 0
+
 @PublishedApi internal const val DEFAULT_LONG: Long = 0L
 
 /**
@@ -87,6 +92,30 @@
     public inline fun getCharOrElse(key: String, defaultValue: () -> Char): Char
 
     /**
+     * Retrieves a [CharSequence] value associated with the specified [key]. Throws an
+     * [IllegalStateException] if the [key] doesn't exist.
+     *
+     * @param key The [key] to retrieve the value for.
+     * @return The [CharSequence] value associated with the [key].
+     * @throws IllegalStateException If the [key] is not found.
+     */
+    public inline fun getCharSequence(key: String): CharSequence
+
+    /**
+     * Retrieves a [CharSequence] value associated with the specified [key], or a default value if
+     * the [key] doesn't exist.
+     *
+     * @param key The [key] to retrieve the value for.
+     * @param defaultValue A function providing the default value if the [key] is not found.
+     * @return The [CharSequence] value associated with the [key], or the default value if the [key]
+     *   is not found.
+     */
+    public inline fun getCharSequenceOrElse(
+        key: String,
+        defaultValue: () -> CharSequence
+    ): CharSequence
+
+    /**
      * Retrieves a [Double] value associated with the specified [key]. Throws an
      * [IllegalStateException] if the [key] doesn't exist.
      *
@@ -239,6 +268,31 @@
     ): List<String>
 
     /**
+     * Retrieves a [List] of elements of [CharArray] associated with the specified [key]. Throws an
+     * [IllegalStateException] if the [key] doesn't exist.
+     *
+     * @param key The [key] to retrieve the value for.
+     * @return The [List] of elements of [CharArray] associated with the [key].
+     * @throws IllegalStateException If the [key] is not found.
+     */
+    public inline fun getCharSequenceList(key: String): List<CharSequence>
+
+    /**
+     * Retrieves a [List] of elements of [CharSequence] associated with the specified [key], or a
+     * default value if the [key] doesn't exist.
+     *
+     * @param key The [key] to retrieve the value for.
+     * @param defaultValue A function providing the default value if the [key] is not found or the
+     *   retrieved value is not a list of [CharSequence].
+     * @return The list of elements of [CharSequence] associated with the [key], or the default
+     *   value if the [key] is not found.
+     */
+    public inline fun getCharSequenceListOrElse(
+        key: String,
+        defaultValue: () -> List<CharSequence>
+    ): List<CharSequence>
+
+    /**
      * Retrieves a [BooleanArray] value associated with the specified [key]. Throws an
      * [IllegalStateException] if the [key] doesn't exist.
      *
@@ -284,6 +338,30 @@
     public inline fun getCharArrayOrElse(key: String, defaultValue: () -> CharArray): CharArray
 
     /**
+     * Retrieves a [CharArray] value associated with the specified [key]. Throws an
+     * [IllegalStateException] if the [key] doesn't exist.
+     *
+     * @param key The [key] to retrieve the value for.
+     * @return The [CharArray] value associated with the [key].
+     * @throws IllegalStateException If the [key] is not found.
+     */
+    public inline fun getCharSequenceArray(key: String): Array<CharSequence>
+
+    /**
+     * Retrieves a [CharArray] value associated with the specified [key], or a default value if the
+     * [key] doesn't exist.
+     *
+     * @param key The [key] to retrieve the value for.
+     * @param defaultValue A function providing the default value if the [key] is not found.
+     * @return The [CharArray] value associated with the [key], or the default value if the [key] is
+     *   not found.
+     */
+    public inline fun getCharSequenceArrayOrElse(
+        key: String,
+        defaultValue: () -> Array<CharSequence>
+    ): Array<CharSequence>
+
+    /**
      * Retrieves a [DoubleArray] value associated with the specified [key]. Throws an
      * [IllegalStateException] if the [key] doesn't exist.
      *
diff --git a/savedstate/savedstate/src/commonMain/kotlin/androidx/savedstate/SavedStateWriter.kt b/savedstate/savedstate/src/commonMain/kotlin/androidx/savedstate/SavedStateWriter.kt
index 13b5c8f..11cd15c04 100644
--- a/savedstate/savedstate/src/commonMain/kotlin/androidx/savedstate/SavedStateWriter.kt
+++ b/savedstate/savedstate/src/commonMain/kotlin/androidx/savedstate/SavedStateWriter.kt
@@ -45,9 +45,23 @@
      */
     public inline fun putBoolean(key: String, value: Boolean)
 
+    /**
+     * Stores a char value associated with the specified key in the [SavedState].
+     *
+     * @param key The key to associate the value with.
+     * @param value The char value to store.
+     */
     public inline fun putChar(key: String, value: Char)
 
     /**
+     * Stores a char sequence value associated with the specified key in the [SavedState].
+     *
+     * @param key The key to associate the value with.
+     * @param value The char sequence value to store.
+     */
+    public inline fun putCharSequence(key: String, value: CharSequence)
+
+    /**
      * Stores a double value associated with the specified key in the [SavedState].
      *
      * @param key The key to associate the value with.
@@ -103,6 +117,15 @@
     public inline fun putIntList(key: String, values: List<Int>)
 
     /**
+     * Stores a list of elements of [CharSequence] associated with the specified key in the
+     * [SavedState].
+     *
+     * @param key The key to associate the value with.
+     * @param values The list of elements to store.
+     */
+    public inline fun putCharSequenceList(key: String, values: List<CharSequence>)
+
+    /**
      * Stores a list of elements of [String] associated with the specified key in the [SavedState].
      *
      * @param key The key to associate the value with.
@@ -129,6 +152,15 @@
     public inline fun putCharArray(key: String, values: CharArray)
 
     /**
+     * Stores an [Array] of elements of [CharSequence] associated with the specified key in the
+     * [SavedState].
+     *
+     * @param key The key to associate the value with.
+     * @param values The array of elements to store.
+     */
+    public inline fun putCharSequenceArray(key: String, values: Array<CharSequence>)
+
+    /**
      * Stores an [Array] of elements of [Double] associated with the specified key in the
      * [SavedState].
      *
diff --git a/savedstate/savedstate/src/commonTest/kotlin/androidx/savedstate/SavedStateTest.kt b/savedstate/savedstate/src/commonTest/kotlin/androidx/savedstate/SavedStateTest.kt
index fc57a73..d19506c 100644
--- a/savedstate/savedstate/src/commonTest/kotlin/androidx/savedstate/SavedStateTest.kt
+++ b/savedstate/savedstate/src/commonTest/kotlin/androidx/savedstate/SavedStateTest.kt
@@ -332,6 +332,49 @@
     }
 
     @Test
+    fun getCharSequence_whenSet_returns() {
+        val underTest = savedState { putCharSequence(KEY_1, CHAR_SEQUENCE_VALUE_1) }
+        val actual = underTest.read { getCharSequence(KEY_1) }
+
+        assertThat(actual).isEqualTo(CHAR_SEQUENCE_VALUE_1)
+    }
+
+    @Test
+    fun getCharSequence_whenNotSet_throws() {
+        assertThrows<IllegalArgumentException> { savedState().read { getCharSequence(KEY_1) } }
+    }
+
+    @Test
+    fun getCharSequence_whenSet_differentType_throws() {
+        val underTest = savedState { putInt(KEY_1, Int.MAX_VALUE) }
+
+        assertThrows<IllegalStateException> { underTest.read { getString(KEY_1) } }
+    }
+
+    @Test
+    fun getCharSequenceOrElse_whenSet_returns() {
+        val underTest = savedState { putCharSequence(KEY_1, CHAR_SEQUENCE_VALUE_1) }
+        val actual = underTest.read { getCharSequenceOrElse(KEY_1) { CHAR_SEQUENCE_VALUE_2 } }
+
+        assertThat(actual).isEqualTo(CHAR_SEQUENCE_VALUE_1)
+    }
+
+    @Test
+    fun getCharSequenceOrElse_whenNotSet_returnsElse() {
+        val actual = savedState().read { getCharSequenceOrElse(KEY_1) { CHAR_SEQUENCE_VALUE_2 } }
+
+        assertThat(actual).isEqualTo(CHAR_SEQUENCE_VALUE_2)
+    }
+
+    @Test
+    fun getCharSequenceOrElse_whenSet_differentType_returnsElse() {
+        val underTest = savedState { putInt(KEY_1, Int.MAX_VALUE) }
+        val actual = underTest.read { getCharSequenceOrElse(KEY_1) { CHAR_SEQUENCE_VALUE_2 } }
+
+        assertThat(actual).isEqualTo(CHAR_SEQUENCE_VALUE_2)
+    }
+
+    @Test
     fun getDouble_whenSet_returns() {
         val underTest = savedState { putDouble(KEY_1, Double.MAX_VALUE) }
         val actual = underTest.read { getDouble(KEY_1) }
@@ -625,6 +668,53 @@
     }
 
     @Test
+    fun getCharSequenceList_whenSet_returns() {
+        val underTest = savedState { putCharSequenceList(KEY_1, CHAR_SEQUENCE_LIST) }
+        val actual = underTest.read { getCharSequenceList(KEY_1) }
+
+        assertThat(actual).isEqualTo(CHAR_SEQUENCE_LIST)
+    }
+
+    @Test
+    fun getCharSequenceList_whenNotSet_throws() {
+        assertThrows<IllegalArgumentException> { savedState().read { getCharSequenceList(KEY_1) } }
+    }
+
+    @Test
+    fun getCharSequenceList_whenSet_differentType_throws() {
+        val expected = Int.MAX_VALUE
+
+        val underTest = savedState { putInt(KEY_1, expected) }
+
+        assertThrows<IllegalStateException> { underTest.read { getCharSequenceList(KEY_1) } }
+    }
+
+    @Test
+    fun getCharSequenceListOrElse_whenSet_returns() {
+        val underTest = savedState { putCharSequenceList(KEY_1, CHAR_SEQUENCE_LIST) }
+        val actual = underTest.read { getCharSequenceListOrElse(KEY_1) { emptyList() } }
+
+        assertThat(actual).isEqualTo(CHAR_SEQUENCE_LIST)
+    }
+
+    @Test
+    fun getCharSequenceListOrElse_whenNotSet_returnsElse() {
+        val actual = savedState().read { getCharSequenceListOrElse(KEY_1) { emptyList() } }
+
+        assertThat(actual).isEqualTo(emptyList<CharSequence>())
+    }
+
+    @Test
+    fun getCharSequenceListOrElse_whenSet_differentType_returnsElse() {
+        val expected = Int.MAX_VALUE
+
+        val underTest = savedState { putInt(KEY_1, expected) }
+        val actual = underTest.read { getCharSequenceListOrElse(KEY_1) { emptyList() } }
+
+        assertThat(actual).isEqualTo(emptyList<CharSequence>())
+    }
+
+    @Test
     fun getStringList_whenSet_returns() {
         val underTest = savedState { putStringList(KEY_1, LIST_STRING_VALUE) }
         val actual = underTest.read { getStringList(KEY_1) }
@@ -778,6 +868,59 @@
     }
 
     @Test
+    fun getCharSequenceArray_whenSet_returns() {
+        val expected = Array<CharSequence>(size = 5) { idx -> idx.toString() }
+
+        val underTest = savedState { putCharSequenceArray(KEY_1, expected) }
+        val actual = underTest.read { getCharSequenceArray(KEY_1) }
+
+        assertThat(actual).isEqualTo(expected)
+    }
+
+    @Test
+    fun getCharSequenceArray_whenNotSet_throws() {
+        assertThrows<IllegalArgumentException> { savedState().read { getCharSequenceArray(KEY_1) } }
+    }
+
+    @Test
+    fun getCharSequenceArray_whenSet_differentType_throws() {
+        val expected = Int.MAX_VALUE
+
+        val underTest = savedState { putInt(KEY_1, expected) }
+
+        assertThrows<IllegalStateException> { underTest.read { getCharSequenceArray(KEY_1) } }
+    }
+
+    @Test
+    fun getCharSequenceArrayOrElse_whenSet_returns() {
+        val expected = CHAR_SEQUENCE_ARRAY
+
+        val underTest = savedState { putCharSequenceArray(KEY_1, expected) }
+        val actual = underTest.read { getCharSequenceArrayOrElse(KEY_1) { emptyArray() } }
+
+        assertThat(actual).isEqualTo(expected)
+    }
+
+    @Test
+    fun getCharSequenceArrayOrElse_whenNotSet_returnsElse() {
+        val expected = CHAR_SEQUENCE_ARRAY
+
+        val actual = savedState().read { getCharSequenceArrayOrElse(KEY_1) { expected } }
+
+        assertThat(actual).isEqualTo(expected)
+    }
+
+    @Test
+    fun getCharSequenceArrayOrElse_whenSet_differentType_returnsElse() {
+        val expected = CHAR_SEQUENCE_ARRAY
+
+        val underTest = savedState { putInt(KEY_1, Int.MAX_VALUE) }
+        val actual = underTest.read { getCharSequenceArrayOrElse(KEY_1) { expected } }
+
+        assertThat(actual).isEqualTo(expected)
+    }
+
+    @Test
     fun getDoubleArray_whenSet_returns() {
         val expected = DoubleArray(size = 5) { idx -> idx.toDouble() }
 
@@ -1109,6 +1252,10 @@
         val LIST_INT_VALUE = List(size = 5) { idx -> idx }
         val LIST_STRING_VALUE = List(size = 5) { idx -> "index=$idx" }
         val SAVED_STATE_VALUE = savedState()
+        val CHAR_SEQUENCE_VALUE_1: CharSequence = Int.MIN_VALUE.toString()
+        val CHAR_SEQUENCE_VALUE_2: CharSequence = Int.MAX_VALUE.toString()
+        val CHAR_SEQUENCE_ARRAY = Array<CharSequence>(size = 5) { idx -> "index=$idx" }
+        val CHAR_SEQUENCE_LIST = List<CharSequence>(size = 5) { idx -> "index=$idx" }
 
         private fun createDefaultSavedState(): SavedState {
             var key = 0
diff --git a/savedstate/savedstate/src/nonAndroidMain/kotlin/androidx/savedstate/SavedStateReader.nonAndroid.kt b/savedstate/savedstate/src/nonAndroidMain/kotlin/androidx/savedstate/SavedStateReader.nonAndroid.kt
index 5a36f36..b18561a 100644
--- a/savedstate/savedstate/src/nonAndroidMain/kotlin/androidx/savedstate/SavedStateReader.nonAndroid.kt
+++ b/savedstate/savedstate/src/nonAndroidMain/kotlin/androidx/savedstate/SavedStateReader.nonAndroid.kt
@@ -51,6 +51,19 @@
         return source.map[key] as? Char ?: defaultValue()
     }
 
+    actual inline fun getCharSequence(key: String): CharSequence {
+        if (key !in this) keyNotFoundError(key)
+        return source.map[key] as? CharSequence ?: valueNotFoundError(key)
+    }
+
+    actual inline fun getCharSequenceOrElse(
+        key: String,
+        defaultValue: () -> CharSequence
+    ): CharSequence {
+        if (key !in this) defaultValue()
+        return source.map[key] as? CharSequence ?: defaultValue()
+    }
+
     actual inline fun getDouble(key: String): Double {
         if (key !in this) keyNotFoundError(key)
         return source.map[key] as? Double ?: DEFAULT_DOUBLE
@@ -101,31 +114,42 @@
         return source.map[key] as? String ?: defaultValue()
     }
 
-    @Suppress("UNCHECKED_CAST")
+    actual inline fun getCharSequenceList(key: String): List<CharSequence> {
+        if (key !in this) keyNotFoundError(key)
+        @Suppress("UNCHECKED_CAST")
+        return source.map[key] as? List<CharSequence> ?: valueNotFoundError(key)
+    }
+
+    actual inline fun getCharSequenceListOrElse(
+        key: String,
+        defaultValue: () -> List<CharSequence>
+    ): List<CharSequence> {
+        if (key !in this) defaultValue()
+        @Suppress("UNCHECKED_CAST") return source.map[key] as? List<CharSequence> ?: defaultValue()
+    }
+
     actual inline fun getIntList(key: String): List<Int> {
         if (key !in this) keyNotFoundError(key)
-        return source.map[key] as? List<Int> ?: valueNotFoundError(key)
+        @Suppress("UNCHECKED_CAST") return source.map[key] as? List<Int> ?: valueNotFoundError(key)
     }
 
-    @Suppress("UNCHECKED_CAST")
     actual inline fun getIntListOrElse(key: String, defaultValue: () -> List<Int>): List<Int> {
         if (key !in this) defaultValue()
-        return source.map[key] as? List<Int> ?: defaultValue()
+        @Suppress("UNCHECKED_CAST") return source.map[key] as? List<Int> ?: defaultValue()
     }
 
-    @Suppress("UNCHECKED_CAST")
     actual inline fun getStringList(key: String): List<String> {
         if (key !in this) keyNotFoundError(key)
+        @Suppress("UNCHECKED_CAST")
         return source.map[key] as? List<String> ?: valueNotFoundError(key)
     }
 
-    @Suppress("UNCHECKED_CAST")
     actual inline fun getStringListOrElse(
         key: String,
         defaultValue: () -> List<String>
     ): List<String> {
         if (key !in this) defaultValue()
-        return source.map[key] as? List<String> ?: defaultValue()
+        @Suppress("UNCHECKED_CAST") return source.map[key] as? List<String> ?: defaultValue()
     }
 
     actual inline fun getCharArray(key: String): CharArray {
@@ -138,6 +162,20 @@
         return source.map[key] as? CharArray ?: defaultValue()
     }
 
+    actual inline fun getCharSequenceArray(key: String): Array<CharSequence> {
+        if (key !in this) keyNotFoundError(key)
+        @Suppress("UNCHECKED_CAST")
+        return source.map[key] as? Array<CharSequence> ?: valueNotFoundError(key)
+    }
+
+    actual inline fun getCharSequenceArrayOrElse(
+        key: String,
+        defaultValue: () -> Array<CharSequence>
+    ): Array<CharSequence> {
+        if (key !in this) defaultValue()
+        @Suppress("UNCHECKED_CAST") return source.map[key] as? Array<CharSequence> ?: defaultValue()
+    }
+
     actual inline fun getBooleanArray(key: String): BooleanArray {
         if (key !in this) keyNotFoundError(key)
         return source.map[key] as? BooleanArray ?: valueNotFoundError(key)
diff --git a/savedstate/savedstate/src/nonAndroidMain/kotlin/androidx/savedstate/SavedStateWriter.nonAndroid.kt b/savedstate/savedstate/src/nonAndroidMain/kotlin/androidx/savedstate/SavedStateWriter.nonAndroid.kt
index 95cecc4..88fac06 100644
--- a/savedstate/savedstate/src/nonAndroidMain/kotlin/androidx/savedstate/SavedStateWriter.nonAndroid.kt
+++ b/savedstate/savedstate/src/nonAndroidMain/kotlin/androidx/savedstate/SavedStateWriter.nonAndroid.kt
@@ -39,6 +39,10 @@
         source.map[key] = value
     }
 
+    actual inline fun putCharSequence(key: String, value: CharSequence) {
+        source.map[key] = value
+    }
+
     actual inline fun putDouble(key: String, value: Double) {
         source.map[key] = value
     }
@@ -63,6 +67,10 @@
         source.map[key] = value
     }
 
+    actual inline fun putCharSequenceList(key: String, values: List<CharSequence>) {
+        source.map[key] = values
+    }
+
     actual inline fun putIntList(key: String, values: List<Int>) {
         source.map[key] = values
     }
@@ -79,6 +87,10 @@
         source.map[key] = values
     }
 
+    actual inline fun putCharSequenceArray(key: String, values: Array<CharSequence>) {
+        source.map[key] = values
+    }
+
     actual inline fun putDoubleArray(key: String, values: DoubleArray) {
         source.map[key] = values
     }
diff --git a/settings.gradle b/settings.gradle
index 010367a..a6fe1d8 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -463,8 +463,8 @@
 includeProject(":car:app:app-testing", [BuildType.MAIN])
 includeProject(":cardview:cardview", [BuildType.MAIN])
 includeProject(":collection:collection", [BuildType.MAIN, BuildType.INFRAROGUE, BuildType.KMP])
-includeProject(":collection:collection-benchmark", [BuildType.MAIN, BuildType.INFRAROGUE, BuildType.KMP])
-includeProject(":collection:collection-benchmark-kmp", [BuildType.MAIN, BuildType.INFRAROGUE, BuildType.KMP])
+includeProject(":collection:collection-benchmark", [BuildType.INFRAROGUE, BuildType.KMP])
+includeProject(":collection:collection-benchmark-kmp", [BuildType.INFRAROGUE, BuildType.KMP])
 includeProject(":collection:collection-ktx", [BuildType.MAIN, BuildType.INFRAROGUE, BuildType.KMP])
 includeProject(":collection:integration-tests:testapp", [BuildType.MAIN, BuildType.INFRAROGUE, BuildType.KMP])
 includeProject(":compose:animation", [BuildType.COMPOSE])
diff --git a/slidingpanelayout/slidingpanelayout/api/current.txt b/slidingpanelayout/slidingpanelayout/api/current.txt
index e49a46f..b591f76 100644
--- a/slidingpanelayout/slidingpanelayout/api/current.txt
+++ b/slidingpanelayout/slidingpanelayout/api/current.txt
@@ -46,6 +46,7 @@
     method public final void setUserResizeBehavior(androidx.slidingpanelayout.widget.SlidingPaneLayout.UserResizeBehavior userResizeBehavior);
     method public final void setUserResizingDividerDrawable(android.graphics.drawable.Drawable? drawable);
     method public final void setUserResizingDividerDrawable(@DrawableRes int resId);
+    method public final void setUserResizingDividerTint(android.content.res.ColorStateList? colorStateList);
     method public final void setUserResizingEnabled(boolean);
     method @Deprecated public void smoothSlideClosed();
     method @Deprecated public void smoothSlideOpen();
diff --git a/slidingpanelayout/slidingpanelayout/api/res-current.txt b/slidingpanelayout/slidingpanelayout/api/res-current.txt
index 0d72e80..88c866a 100644
--- a/slidingpanelayout/slidingpanelayout/api/res-current.txt
+++ b/slidingpanelayout/slidingpanelayout/api/res-current.txt
@@ -3,3 +3,4 @@
 attr isUserResizingEnabled
 attr userResizeBehavior
 attr userResizingDividerDrawable
+attr userResizingDividerTint
diff --git a/slidingpanelayout/slidingpanelayout/api/restricted_current.txt b/slidingpanelayout/slidingpanelayout/api/restricted_current.txt
index e49a46f..b591f76 100644
--- a/slidingpanelayout/slidingpanelayout/api/restricted_current.txt
+++ b/slidingpanelayout/slidingpanelayout/api/restricted_current.txt
@@ -46,6 +46,7 @@
     method public final void setUserResizeBehavior(androidx.slidingpanelayout.widget.SlidingPaneLayout.UserResizeBehavior userResizeBehavior);
     method public final void setUserResizingDividerDrawable(android.graphics.drawable.Drawable? drawable);
     method public final void setUserResizingDividerDrawable(@DrawableRes int resId);
+    method public final void setUserResizingDividerTint(android.content.res.ColorStateList? colorStateList);
     method public final void setUserResizingEnabled(boolean);
     method @Deprecated public void smoothSlideClosed();
     method @Deprecated public void smoothSlideOpen();
diff --git a/slidingpanelayout/slidingpanelayout/src/androidTest/java/androidx/slidingpanelayout/widget/UserResizeDividerTintTest.kt b/slidingpanelayout/slidingpanelayout/src/androidTest/java/androidx/slidingpanelayout/widget/UserResizeDividerTintTest.kt
new file mode 100644
index 0000000..fe06388
--- /dev/null
+++ b/slidingpanelayout/slidingpanelayout/src/androidTest/java/androidx/slidingpanelayout/widget/UserResizeDividerTintTest.kt
@@ -0,0 +1,94 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.slidingpanelayout.widget
+
+import android.content.res.ColorStateList
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.ColorFilter
+import android.graphics.PixelFormat
+import android.graphics.drawable.Drawable
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import androidx.test.platform.app.InstrumentationRegistry
+import com.google.common.truth.Truth.assertWithMessage
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+@SmallTest
+class UserResizeDividerTintTest {
+    @Test
+    fun userResizingDividerTint_tintIsSetToDrawable() {
+        val context = InstrumentationRegistry.getInstrumentation().context
+        val view = SlidingPaneLayout(context)
+        val drawable = TestDrawable()
+
+        view.setUserResizingDividerDrawable(drawable)
+        val tint = ColorStateList.valueOf(Color.RED)
+        view.setUserResizingDividerTint(tint)
+
+        assertWithMessage("userResizingDividerTint is set to drawable")
+            .that(drawable.tint)
+            .isEqualTo(tint)
+    }
+
+    @Test
+    fun userResizingDividerTint_setDrawableAfterTint_tintIsNotSetToDrawable() {
+        val context = InstrumentationRegistry.getInstrumentation().context
+        val view = SlidingPaneLayout(context)
+        val drawable = TestDrawable()
+
+        val tint = ColorStateList.valueOf(Color.RED)
+        view.setUserResizingDividerTint(tint)
+
+        view.setUserResizingDividerDrawable(drawable)
+        assertWithMessage("userResizingDividerTint is not set to drawable")
+            .that(drawable.tint)
+            .isNull()
+    }
+
+    @Test
+    fun userResizingDividerTint_setTintToNull() {
+        val context = InstrumentationRegistry.getInstrumentation().context
+        val view = SlidingPaneLayout(context)
+        val drawable = TestDrawable().apply { setTintList(ColorStateList.valueOf(Color.RED)) }
+        view.setUserResizingDividerDrawable(drawable)
+
+        view.setUserResizingDividerTint(null)
+        assertWithMessage("userResizingDividerTint is set to null").that(drawable.tint).isNull()
+    }
+}
+
+private class TestDrawable : Drawable() {
+    var tint: ColorStateList? = null
+        private set
+
+    override fun draw(canvas: Canvas) {}
+
+    override fun setAlpha(alpha: Int) {}
+
+    override fun setColorFilter(colorFilter: ColorFilter?) {}
+
+    @Suppress("DeprecatedCallableAddReplaceWith")
+    @Deprecated("Deprecated in Java")
+    override fun getOpacity(): Int = PixelFormat.TRANSLUCENT
+
+    override fun setTintList(tint: ColorStateList?) {
+        this.tint = tint
+    }
+}
diff --git a/slidingpanelayout/slidingpanelayout/src/androidTest/res/layout/user_resize_divider_tint.xml b/slidingpanelayout/slidingpanelayout/src/androidTest/res/layout/user_resize_divider_tint.xml
new file mode 100644
index 0000000..4d7a361
--- /dev/null
+++ b/slidingpanelayout/slidingpanelayout/src/androidTest/res/layout/user_resize_divider_tint.xml
@@ -0,0 +1,26 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+  Copyright 2023 The Android Open Source Project
+
+  Licensed under the Apache License, Version 2.0 (the "License");
+  you may not use this file except in compliance with the License.
+  You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+  -->
+
+<androidx.slidingpanelayout.widget.SlidingPaneLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    app:isUserResizingEnabled="true"
+    app:userResizingDividerDrawable="@android:drawable/ic_menu_add"
+    app:userResizingDividerTint="@android:color/primary_text_dark">
+
+</androidx.slidingpanelayout.widget.SlidingPaneLayout>
\ No newline at end of file
diff --git a/slidingpanelayout/slidingpanelayout/src/main/java/androidx/slidingpanelayout/widget/SlidingPaneLayout.kt b/slidingpanelayout/slidingpanelayout/src/main/java/androidx/slidingpanelayout/widget/SlidingPaneLayout.kt
index 5b023fe..729b2b9 100644
--- a/slidingpanelayout/slidingpanelayout/src/main/java/androidx/slidingpanelayout/widget/SlidingPaneLayout.kt
+++ b/slidingpanelayout/slidingpanelayout/src/main/java/androidx/slidingpanelayout/widget/SlidingPaneLayout.kt
@@ -17,6 +17,7 @@
 package androidx.slidingpanelayout.widget
 
 import android.content.Context
+import android.content.res.ColorStateList
 import android.graphics.Canvas
 import android.graphics.Rect
 import android.graphics.drawable.Drawable
@@ -427,6 +428,16 @@
         setUserResizingDividerDrawable(ContextCompat.getDrawable(context, resId))
     }
 
+    /**
+     * The tint color for the resizing divider [Drawable] which is set by
+     * [setUserResizingDividerDrawable]. This may also be set from `userResizingDividerTint` XML
+     * attribute during the view inflation. Note: the tint is not retained after calling
+     * [setUserResizingDividerDrawable].
+     */
+    fun setUserResizingDividerTint(colorStateList: ColorStateList?) {
+        userResizingDividerDrawable?.apply { setTintList(colorStateList) }
+    }
+
     /** `true` if the user is currently dragging the [user resizing divider][isUserResizable] */
     val isDividerDragging: Boolean
         get() = draggableDividerHandler.isDragging
@@ -581,6 +592,11 @@
                 getBoolean(R.styleable.SlidingPaneLayout_isUserResizingEnabled, false)
             userResizingDividerDrawable =
                 getDrawable(R.styleable.SlidingPaneLayout_userResizingDividerDrawable)
+            // It won't override the tint on drawable if userResizingDividerTint is not specified.
+            getColorStateList(R.styleable.SlidingPaneLayout_userResizingDividerTint)?.apply {
+                setUserResizingDividerTint(this)
+            }
+
             isChildClippingToResizeDividerEnabled =
                 getBoolean(
                     R.styleable.SlidingPaneLayout_isChildClippingToResizeDividerEnabled,
diff --git a/slidingpanelayout/slidingpanelayout/src/main/res/drawable/slidingpanelayout_divider.xml b/slidingpanelayout/slidingpanelayout/src/main/res/drawable/slidingpanelayout_divider.xml
index c5fd33b..f50ac30 100644
--- a/slidingpanelayout/slidingpanelayout/src/main/res/drawable/slidingpanelayout_divider.xml
+++ b/slidingpanelayout/slidingpanelayout/src/main/res/drawable/slidingpanelayout_divider.xml
@@ -15,10 +15,12 @@
   limitations under the License.
   -->
 
-<shape
-    xmlns:android="http://schemas.android.com/apk/res/android"
-    android:shape="rectangle">
-    <solid android:color="#c0555555"/>
-    <size android:width="8dp" android:height="80dp"/>
-    <corners android:radius="4dp"/>
-</shape>
+<selector xmlns:android="http://schemas.android.com/apk/res/android">
+    <!-- Pressed state -->
+    <item
+        android:state_pressed="true"
+        android:drawable="@drawable/slidingpanelayout_divider_pressed" />
+    <!-- Default state -->
+    <item
+        android:drawable="@drawable/slidingpanelayout_divider_default"/>
+</selector>
\ No newline at end of file
diff --git a/slidingpanelayout/slidingpanelayout/src/main/res/drawable/slidingpanelayout_divider_default.xml b/slidingpanelayout/slidingpanelayout/src/main/res/drawable/slidingpanelayout_divider_default.xml
new file mode 100644
index 0000000..cdcc39b1
--- /dev/null
+++ b/slidingpanelayout/slidingpanelayout/src/main/res/drawable/slidingpanelayout_divider_default.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+  Copyright 2024 The Android Open Source Project
+
+  Licensed under the Apache License, Version 2.0 (the "License");
+  you may not use this file except in compliance with the License.
+  You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+  -->
+
+<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
+    <size android:height="48dp" android:width="4dp" />
+    <corners android:radius="2dp" />
+    <solid android:color="#ff444746" />
+</shape>
\ No newline at end of file
diff --git a/slidingpanelayout/slidingpanelayout/src/main/res/drawable/slidingpanelayout_divider_pressed.xml b/slidingpanelayout/slidingpanelayout/src/main/res/drawable/slidingpanelayout_divider_pressed.xml
new file mode 100644
index 0000000..4612c86
--- /dev/null
+++ b/slidingpanelayout/slidingpanelayout/src/main/res/drawable/slidingpanelayout_divider_pressed.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+  Copyright 2024 The Android Open Source Project
+
+  Licensed under the Apache License, Version 2.0 (the "License");
+  you may not use this file except in compliance with the License.
+  You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+  -->
+
+<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
+    <size android:height="52dp" android:width="12dp" />
+    <corners android:radius="6dp" />
+    <solid android:color="#ff1f1f1f" />
+</shape>
\ No newline at end of file
diff --git a/slidingpanelayout/slidingpanelayout/src/main/res/values/attrs.xml b/slidingpanelayout/slidingpanelayout/src/main/res/values/attrs.xml
index 59f02be..14c032e 100644
--- a/slidingpanelayout/slidingpanelayout/src/main/res/values/attrs.xml
+++ b/slidingpanelayout/slidingpanelayout/src/main/res/values/attrs.xml
@@ -19,6 +19,7 @@
         <attr name="isOverlappingEnabled" format="boolean"/>
         <attr name="isUserResizingEnabled" format="boolean"/>
         <attr name="userResizingDividerDrawable" format="reference"/>
+        <attr name="userResizingDividerTint" format="color"/>
         <attr name="isChildClippingToResizeDividerEnabled" format="boolean"/>
         <attr name="userResizeBehavior" format="enum">
             <enum name="relayoutWhenComplete" value="0"/>
diff --git a/slidingpanelayout/slidingpanelayout/src/main/res/values/public.xml b/slidingpanelayout/slidingpanelayout/src/main/res/values/public.xml
index 497b058..c8e7a8f 100644
--- a/slidingpanelayout/slidingpanelayout/src/main/res/values/public.xml
+++ b/slidingpanelayout/slidingpanelayout/src/main/res/values/public.xml
@@ -18,6 +18,7 @@
     <public type="attr" name="isOverlappingEnabled"/>
     <public type="attr" name="isUserResizingEnabled"/>
     <public type="attr" name="userResizingDividerDrawable"/>
+    <public type="attr" name="userResizingDividerTint"/>
     <public type="attr" name="isChildClippingToResizeDividerEnabled"/>
     <public type="attr" name="userResizeBehavior"/>
 </resources>
diff --git a/wear/compose/compose-foundation/src/androidTest/kotlin/androidx/wear/compose/foundation/lazy/TransformingLazyColumnTest.kt b/wear/compose/compose-foundation/src/androidTest/kotlin/androidx/wear/compose/foundation/lazy/TransformingLazyColumnTest.kt
index 09b2995..5dbd105 100644
--- a/wear/compose/compose-foundation/src/androidTest/kotlin/androidx/wear/compose/foundation/lazy/TransformingLazyColumnTest.kt
+++ b/wear/compose/compose-foundation/src/androidTest/kotlin/androidx/wear/compose/foundation/lazy/TransformingLazyColumnTest.kt
@@ -393,6 +393,19 @@
         testTransformingLazyColumnRotary(false, 0)
     }
 
+    @Test
+    fun supportsEmptyItems() {
+        rule.setContent {
+            TransformingLazyColumn(
+                state = rememberTransformingLazyColumnState(),
+                modifier = Modifier.testTag(lazyListTag),
+            ) {
+                items(10) {}
+            }
+        }
+        rule.onNodeWithTag(lazyListTag).assertIsDisplayed()
+    }
+
     @OptIn(ExperimentalTestApi::class)
     private fun testTransformingLazyColumnRotary(
         userScrollEnabled: Boolean,
diff --git a/wear/compose/compose-foundation/src/androidTest/kotlin/androidx/wear/compose/foundation/rotary/HapticsTest.kt b/wear/compose/compose-foundation/src/androidTest/kotlin/androidx/wear/compose/foundation/rotary/HapticsTest.kt
new file mode 100644
index 0000000..df23e98
--- /dev/null
+++ b/wear/compose/compose-foundation/src/androidTest/kotlin/androidx/wear/compose/foundation/rotary/HapticsTest.kt
@@ -0,0 +1,303 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.wear.compose.foundation.rotary
+
+import android.os.Build
+import android.view.ScrollFeedbackProvider
+import android.view.ViewConfiguration
+import androidx.compose.foundation.gestures.ScrollableDefaults
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.text.BasicText
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performRotaryScrollInput
+import androidx.compose.ui.unit.dp
+import androidx.test.filters.SdkSuppress
+import androidx.wear.compose.foundation.lazy.ScalingLazyColumn
+import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState
+import androidx.wear.compose.foundation.rotary.RotaryScrollTest.Companion.TEST_TAG
+import com.google.common.truth.Truth
+import org.junit.Rule
+import org.junit.Test
+
+@SdkSuppress(minSdkVersion = Build.VERSION_CODES.VANILLA_ICE_CREAM)
+@OptIn(ExperimentalTestApi::class)
+class HapticsTest {
+    @get:Rule val rule = createComposeRule()
+    private val focusRequester = FocusRequester()
+
+    @Test
+    fun platformHaptics_scrollProgressCalled_once() {
+
+        val mockedScrollFeedbackProvider = MockedScrollFeedbackProvider()
+        rule.setContent { SLCRotaryFling(mockedScrollFeedbackProvider) }
+        rule.runOnIdle { focusRequester.requestFocus() }
+
+        rule.onNodeWithTag(TEST_TAG).performRotaryScrollInput { rotateToScrollVertically(10f) }
+
+        Truth.assertThat(mockedScrollFeedbackProvider.onScrollProgressCounter).isEqualTo(1)
+        Truth.assertThat(mockedScrollFeedbackProvider.onSnapToItemCounter).isEqualTo(0)
+        Truth.assertThat(mockedScrollFeedbackProvider.onScrollLimitCounter).isEqualTo(0)
+    }
+
+    @Test
+    fun platformHaptics_scrollProgressCalled_multipleTimes() {
+
+        val mockedScrollFeedbackProvider = MockedScrollFeedbackProvider()
+        rule.setContent { SLCRotaryFling(mockedScrollFeedbackProvider) }
+        rule.runOnIdle { focusRequester.requestFocus() }
+
+        rule.onNodeWithTag(TEST_TAG).performRotaryScrollInput {
+            rotateToScrollVertically(10f)
+            advanceEventTime(10)
+            rotateToScrollVertically(10f)
+            advanceEventTime(10)
+            rotateToScrollVertically(10f)
+        }
+
+        Truth.assertThat(mockedScrollFeedbackProvider.onScrollProgressCounter).isEqualTo(3)
+        Truth.assertThat(mockedScrollFeedbackProvider.onSnapToItemCounter).isEqualTo(0)
+        Truth.assertThat(mockedScrollFeedbackProvider.onScrollLimitCounter).isEqualTo(0)
+    }
+
+    @Test
+    fun platformHaptics_scrollLimitCalled() {
+
+        val mockedScrollFeedbackProvider = MockedScrollFeedbackProvider()
+        rule.setContent { SLCRotaryFling(mockedScrollFeedbackProvider) }
+        rule.runOnIdle { focusRequester.requestFocus() }
+
+        rule.onNodeWithTag(TEST_TAG).performRotaryScrollInput {
+            // Scroll the rotary forwards and then backwards so that we'll reach the edge of the
+            // list.
+            rotateToScrollVertically(10f)
+            advanceEventTime(10)
+            rotateToScrollVertically(-11f)
+        }
+
+        Truth.assertThat(mockedScrollFeedbackProvider.onScrollLimitCounter).isEqualTo(1)
+    }
+
+    @Test
+    fun platformHaptics_snapToItemCalled_once_highRes() {
+
+        val mockedScrollFeedbackProvider = MockedScrollFeedbackProvider()
+        rule.setContent { SLCHighResRotarySnap(mockedScrollFeedbackProvider) }
+        rule.runOnIdle { focusRequester.requestFocus() }
+
+        rule.onNodeWithTag(TEST_TAG).performRotaryScrollInput { rotateToScrollVertically(100f) }
+        Truth.assertThat(mockedScrollFeedbackProvider.onSnapToItemCounter).isEqualTo(1)
+    }
+
+    @Test
+    fun platformHaptics_snapToItemCalled_multipleTimes_highRes() {
+
+        val mockedScrollFeedbackProvider = MockedScrollFeedbackProvider()
+        rule.setContent { SLCHighResRotarySnap(mockedScrollFeedbackProvider) }
+        rule.runOnIdle { focusRequester.requestFocus() }
+
+        rule.onNodeWithTag(TEST_TAG).performRotaryScrollInput {
+            rotateToScrollVertically(100f)
+            advanceEventTime(10)
+            rotateToScrollVertically(100f)
+            advanceEventTime(10)
+            rotateToScrollVertically(100f)
+        }
+        Truth.assertThat(mockedScrollFeedbackProvider.onSnapToItemCounter).isEqualTo(3)
+    }
+
+    @Test
+    fun platformHaptics_snapToItemCalled_once_lowRes() {
+
+        val mockedScrollFeedbackProvider = MockedScrollFeedbackProvider()
+        rule.setContent { SLCLowResRotarySnap(mockedScrollFeedbackProvider) }
+        rule.runOnIdle { focusRequester.requestFocus() }
+
+        rule.onNodeWithTag(TEST_TAG).performRotaryScrollInput { rotateToScrollVertically(100f) }
+        Truth.assertThat(mockedScrollFeedbackProvider.onSnapToItemCounter).isEqualTo(1)
+    }
+
+    @Test
+    fun platformHaptics_snapToItemCalled_multipleTimes_lowRes() {
+
+        val mockedScrollFeedbackProvider = MockedScrollFeedbackProvider()
+        rule.setContent { SLCLowResRotarySnap(mockedScrollFeedbackProvider) }
+        rule.runOnIdle { focusRequester.requestFocus() }
+
+        rule.onNodeWithTag(TEST_TAG).performRotaryScrollInput {
+            rotateToScrollVertically(100f)
+            advanceEventTime(10)
+            rotateToScrollVertically(100f)
+            advanceEventTime(10)
+            rotateToScrollVertically(100f)
+        }
+        Truth.assertThat(mockedScrollFeedbackProvider.onSnapToItemCounter).isEqualTo(3)
+    }
+
+    @Composable
+    private fun SLCRotaryFling(
+        scrollFeedbackProvider: ScrollFeedbackProvider,
+    ) {
+        val scrollableState = rememberScalingLazyListState()
+        val viewConfiguration = ViewConfiguration.get(LocalContext.current)
+        val flingBehavior = ScrollableDefaults.flingBehavior()
+        ScalingLazyColumn(
+            state = scrollableState,
+            rotaryScrollableBehavior = null,
+            modifier =
+                Modifier.size(200.dp)
+                    .testTag(TEST_TAG)
+                    .rotaryScrollable(
+                        behavior =
+                            FlingRotaryScrollableBehavior(
+                                isLowRes = false,
+                                rotaryHaptics =
+                                    PlatformRotaryHapticHandler(
+                                        scrollableState,
+                                        scrollFeedbackProvider
+                                    ),
+                                rotaryFlingHandlerFactory = {
+                                    RotaryFlingHandler(
+                                        scrollableState = scrollableState,
+                                        flingBehavior = flingBehavior,
+                                        viewConfiguration = viewConfiguration,
+                                        flingTimeframe = 20
+                                    )
+                                },
+                                scrollHandlerFactory = { RotaryScrollHandler(scrollableState) }
+                            ),
+                        focusRequester = focusRequester,
+                        reverseDirection = false
+                    )
+        ) {
+            items(300) { BasicText(text = "Item #$it") }
+        }
+    }
+
+    @Composable
+    private fun SLCHighResRotarySnap(
+        scrollFeedbackProvider: ScrollFeedbackProvider,
+    ) {
+        val scrollableState = rememberScalingLazyListState()
+        val layoutInfoProvider =
+            remember(scrollableState) {
+                ScalingLazyColumnRotarySnapLayoutInfoProvider(scrollableState)
+            }
+        ScalingLazyColumn(
+            state = scrollableState,
+            // We need to switch off default rotary behavior
+            rotaryScrollableBehavior = null,
+            modifier =
+                Modifier.size(200.dp)
+                    .testTag(TEST_TAG)
+                    .rotaryScrollable(
+                        behavior =
+                            HighResSnapRotaryScrollableBehavior(
+                                rotaryHaptics =
+                                    PlatformRotaryHapticHandler(
+                                        scrollableState,
+                                        scrollFeedbackProvider
+                                    ),
+                                scrollDistanceDivider =
+                                    RotarySnapSensitivity.DEFAULT.resistanceFactor,
+                                thresholdHandlerFactory = {
+                                    ThresholdHandler(
+                                        RotarySnapSensitivity.DEFAULT.minThresholdDivider,
+                                        RotarySnapSensitivity.DEFAULT.maxThresholdDivider
+                                    ) {
+                                        50f
+                                    }
+                                },
+                                snapHandlerFactory = {
+                                    RotarySnapHandler(scrollableState, layoutInfoProvider, 0)
+                                },
+                                scrollHandlerFactory = { RotaryScrollHandler(scrollableState) },
+                            ),
+                        focusRequester = focusRequester,
+                        reverseDirection = false
+                    )
+        ) {
+            items(300) { BasicText(text = "Item #$it") }
+        }
+    }
+
+    @Composable
+    private fun SLCLowResRotarySnap(
+        scrollFeedbackProvider: ScrollFeedbackProvider,
+    ) {
+        val scrollableState = rememberScalingLazyListState()
+        val layoutInfoProvider =
+            remember(scrollableState) {
+                ScalingLazyColumnRotarySnapLayoutInfoProvider(scrollableState)
+            }
+        ScalingLazyColumn(
+            state = scrollableState,
+            // We need to switch off default rotary behavior
+            rotaryScrollableBehavior = null,
+            modifier =
+                Modifier.size(200.dp)
+                    .testTag(TEST_TAG)
+                    .rotaryScrollable(
+                        behavior =
+                            LowResSnapRotaryScrollableBehavior(
+                                rotaryHaptics =
+                                    PlatformRotaryHapticHandler(
+                                        scrollableState,
+                                        scrollFeedbackProvider
+                                    ),
+                                snapHandlerFactory = {
+                                    RotarySnapHandler(scrollableState, layoutInfoProvider, 0)
+                                },
+                            ),
+                        focusRequester = focusRequester,
+                        reverseDirection = false
+                    )
+        ) {
+            items(300) { BasicText(text = "Item #$it") }
+        }
+    }
+
+    class MockedScrollFeedbackProvider() : ScrollFeedbackProvider {
+        var onSnapToItemCounter = 0
+        var onScrollLimitCounter = 0
+        var onScrollProgressCounter = 0
+
+        override fun onSnapToItem(inputDeviceId: Int, source: Int, axis: Int) {
+            onSnapToItemCounter++
+        }
+
+        override fun onScrollLimit(inputDeviceId: Int, source: Int, axis: Int, isStart: Boolean) {
+            onScrollLimitCounter++
+        }
+
+        override fun onScrollProgress(
+            inputDeviceId: Int,
+            source: Int,
+            axis: Int,
+            deltaInPixels: Int
+        ) {
+            onScrollProgressCounter++
+        }
+    }
+}
diff --git a/wear/compose/compose-foundation/src/main/java/androidx/wear/compose/foundation/BasicSwipeToDismissBox.kt b/wear/compose/compose-foundation/src/main/java/androidx/wear/compose/foundation/BasicSwipeToDismissBox.kt
index 4ef9228..6e73e61 100644
--- a/wear/compose/compose-foundation/src/main/java/androidx/wear/compose/foundation/BasicSwipeToDismissBox.kt
+++ b/wear/compose/compose-foundation/src/main/java/androidx/wear/compose/foundation/BasicSwipeToDismissBox.kt
@@ -483,65 +483,69 @@
     swipeToDismissBoxState: SwipeToDismissBoxState,
     edgeWidth: Dp = SwipeToDismissBoxDefaults.EdgeWidth
 ): Modifier =
-    composed(
-        inspectorInfo =
-            debugInspectorInfo {
-                name = "edgeSwipeToDismiss"
-                properties["swipeToDismissBoxState"] = swipeToDismissBoxState
-                properties["edgeWidth"] = edgeWidth
-            }
-    ) {
-        // Tracks the current swipe status
-        val edgeSwipeState = remember { mutableStateOf(EdgeSwipeState.WaitingForTouch) }
-        val nestedScrollConnection =
-            remember(swipeToDismissBoxState) {
-                swipeToDismissBoxState.edgeNestedScrollConnection(edgeSwipeState)
-            }
+    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
+        this // Edge swipe to dismiss doesn't work on API >= 35 for now
+    } else
+        composed(
+            inspectorInfo =
+                debugInspectorInfo {
+                    name = "edgeSwipeToDismiss"
+                    properties["swipeToDismissBoxState"] = swipeToDismissBoxState
+                    properties["edgeWidth"] = edgeWidth
+                }
+        ) {
+            // Tracks the current swipe status
+            val edgeSwipeState = remember { mutableStateOf(EdgeSwipeState.WaitingForTouch) }
+            val nestedScrollConnection =
+                remember(swipeToDismissBoxState) {
+                    swipeToDismissBoxState.edgeNestedScrollConnection(edgeSwipeState)
+                }
 
-        val nestedPointerInput: suspend PointerInputScope.() -> Unit = {
-            coroutineScope {
-                awaitPointerEventScope {
-                    while (isActive) {
-                        awaitPointerEvent(PointerEventPass.Initial).changes.fastForEach { change ->
-                            // By default swipeState is WaitingForTouch.
-                            // If it is in this state and a first touch hit an edge area, we
-                            // set swipeState to EdgeClickedWaitingForDirection.
-                            // After that to track which direction the swipe will go, we check
-                            // the next touch. If it lands to the left of the first, we consider
-                            // it as a swipe left and set the state to SwipingToPage. Otherwise,
-                            // set the state to SwipingToDismiss
-                            when (edgeSwipeState.value) {
-                                EdgeSwipeState.SwipeToDismissInProgress,
-                                EdgeSwipeState.WaitingForTouch -> {
-                                    edgeSwipeState.value =
-                                        if (change.position.x < edgeWidth.toPx())
-                                            EdgeSwipeState.EdgeClickedWaitingForDirection
-                                        else EdgeSwipeState.SwipingToPage
+            val nestedPointerInput: suspend PointerInputScope.() -> Unit = {
+                coroutineScope {
+                    awaitPointerEventScope {
+                        while (isActive) {
+                            awaitPointerEvent(PointerEventPass.Initial).changes.fastForEach { change
+                                ->
+                                // By default swipeState is WaitingForTouch.
+                                // If it is in this state and a first touch hit an edge area, we
+                                // set swipeState to EdgeClickedWaitingForDirection.
+                                // After that to track which direction the swipe will go, we check
+                                // the next touch. If it lands to the left of the first, we consider
+                                // it as a swipe left and set the state to SwipingToPage. Otherwise,
+                                // set the state to SwipingToDismiss
+                                when (edgeSwipeState.value) {
+                                    EdgeSwipeState.SwipeToDismissInProgress,
+                                    EdgeSwipeState.WaitingForTouch -> {
+                                        edgeSwipeState.value =
+                                            if (change.position.x < edgeWidth.toPx())
+                                                EdgeSwipeState.EdgeClickedWaitingForDirection
+                                            else EdgeSwipeState.SwipingToPage
+                                    }
+                                    EdgeSwipeState.EdgeClickedWaitingForDirection -> {
+                                        edgeSwipeState.value =
+                                            if (change.position.x < change.previousPosition.x)
+                                                EdgeSwipeState.SwipingToPage
+                                            else EdgeSwipeState.SwipingToDismiss
+                                    }
+                                    else -> {} // Do nothing
                                 }
-                                EdgeSwipeState.EdgeClickedWaitingForDirection -> {
+                                // When finger is up - reset swipeState to WaitingForTouch
+                                // or to SwipeToDismissInProgress if current
+                                // state is SwipingToDismiss
+                                if (change.changedToUp()) {
                                     edgeSwipeState.value =
-                                        if (change.position.x < change.previousPosition.x)
-                                            EdgeSwipeState.SwipingToPage
-                                        else EdgeSwipeState.SwipingToDismiss
+                                        if (edgeSwipeState.value == EdgeSwipeState.SwipingToDismiss)
+                                            EdgeSwipeState.SwipeToDismissInProgress
+                                        else EdgeSwipeState.WaitingForTouch
                                 }
-                                else -> {} // Do nothing
-                            }
-                            // When finger is up - reset swipeState to WaitingForTouch
-                            // or to SwipeToDismissInProgress if current
-                            // state is SwipingToDismiss
-                            if (change.changedToUp()) {
-                                edgeSwipeState.value =
-                                    if (edgeSwipeState.value == EdgeSwipeState.SwipingToDismiss)
-                                        EdgeSwipeState.SwipeToDismissInProgress
-                                    else EdgeSwipeState.WaitingForTouch
                             }
                         }
                     }
                 }
             }
+            pointerInput(edgeWidth, nestedPointerInput).nestedScroll(nestedScrollConnection)
         }
-        pointerInput(edgeWidth, nestedPointerInput).nestedScroll(nestedScrollConnection)
-    }
 
 /** An enum which represents a current state of swipe action. */
 internal enum class EdgeSwipeState {
diff --git a/wear/compose/compose-foundation/src/main/java/androidx/wear/compose/foundation/lazy/TransformingLazyColumnContentPaddingMeasurementStrategy.kt b/wear/compose/compose-foundation/src/main/java/androidx/wear/compose/foundation/lazy/TransformingLazyColumnContentPaddingMeasurementStrategy.kt
index 4333e24..7077c64 100644
--- a/wear/compose/compose-foundation/src/main/java/androidx/wear/compose/foundation/lazy/TransformingLazyColumnContentPaddingMeasurementStrategy.kt
+++ b/wear/compose/compose-foundation/src/main/java/androidx/wear/compose/foundation/lazy/TransformingLazyColumnContentPaddingMeasurementStrategy.kt
@@ -248,7 +248,7 @@
                 bottomItemScrollProgress(
                     // TODO: artemiy - Investigate why this is needed.
                     if (idx == 0) previousOffset - itemSpacing else previousOffset,
-                    item.placeable.height,
+                    item.measuredHeight,
                     containerConstraints.maxHeight
                 )
             item.offset = previousOffset
@@ -267,7 +267,7 @@
                 topItemScrollProgress(
                     // TODO: artemiy - Investigate why this is needed.
                     if (idx == 0) bottomLineOffset + 2 * itemSpacing else bottomLineOffset,
-                    visibleItems[idx].placeable.height,
+                    visibleItems[idx].measuredHeight,
                     containerConstraints.maxHeight
                 )
             visibleItems[idx].offset = bottomLineOffset - visibleItems[idx].transformedHeight
diff --git a/wear/compose/compose-foundation/src/main/java/androidx/wear/compose/foundation/lazy/TransformingLazyColumnMeasuredItem.kt b/wear/compose/compose-foundation/src/main/java/androidx/wear/compose/foundation/lazy/TransformingLazyColumnMeasuredItem.kt
index a560127..ff074c1 100644
--- a/wear/compose/compose-foundation/src/main/java/androidx/wear/compose/foundation/lazy/TransformingLazyColumnMeasuredItem.kt
+++ b/wear/compose/compose-foundation/src/main/java/androidx/wear/compose/foundation/lazy/TransformingLazyColumnMeasuredItem.kt
@@ -26,8 +26,8 @@
 internal data class TransformingLazyColumnMeasuredItem(
     /** The index of the item in the list. */
     override val index: Int,
-    /** The [Placeable] representing the content of the item. */
-    val placeable: Placeable,
+    /** The [Placeable] representing the content of the item, or null if no composable is inside. */
+    val placeable: Placeable?,
     /** The constraints of the container holding the item. */
     val containerConstraints: Constraints,
     /** The vertical offset of the item from the top of the list after transformations applied. */
@@ -55,15 +55,17 @@
     /** The height of the item after transformations applied. */
     override val transformedHeight: Int
         get() =
-            (placeable.parentData as? HeightProviderParentData)?.let {
-                it.heightProvider(placeable.height, scrollProgress)
-            } ?: placeable.height
+            placeable?.let { p ->
+                (p.parentData as? HeightProviderParentData)?.let {
+                    it.heightProvider(p.height, scrollProgress)
+                } ?: p.height
+            } ?: 0
 
-    override val measuredHeight = placeable.height
+    override val measuredHeight = placeable?.height ?: 0
 
     fun place(scope: Placeable.PlacementScope) =
         with(scope) {
-            placeable.placeWithLayer(
+            placeable?.placeWithLayer(
                 x =
                     leftPadding +
                         horizontalAlignment.align(
@@ -78,8 +80,8 @@
     fun pinToCenter() {
         scrollProgress =
             bottomItemScrollProgress(
-                containerConstraints.maxHeight / 2 - placeable.height / 2,
-                placeable.height,
+                containerConstraints.maxHeight / 2 - measuredHeight / 2,
+                measuredHeight,
                 containerConstraints.maxHeight
             )
         offset = containerConstraints.maxHeight / 2 - transformedHeight / 2
diff --git a/wear/compose/compose-foundation/src/main/java/androidx/wear/compose/foundation/lazy/TransformingLazyColumnMeasurement.kt b/wear/compose/compose-foundation/src/main/java/androidx/wear/compose/foundation/lazy/TransformingLazyColumnMeasurement.kt
index 9196266..78373d9 100644
--- a/wear/compose/compose-foundation/src/main/java/androidx/wear/compose/foundation/lazy/TransformingLazyColumnMeasurement.kt
+++ b/wear/compose/compose-foundation/src/main/java/androidx/wear/compose/foundation/lazy/TransformingLazyColumnMeasurement.kt
@@ -76,13 +76,13 @@
             val measuredItemProvider = MeasuredItemProvider { index, offset, scrollProgress ->
                 val placeables = measure(index, childConstraints)
                 // TODO(artemiy): Add support for multiple items.
-                val placeable = placeables.last()
+                val placeable = placeables.lastOrNull()
                 TransformingLazyColumnMeasuredItem(
                     index = index,
                     placeable = placeable,
                     offset = offset,
                     containerConstraints = containerConstraints,
-                    scrollProgress = scrollProgress(placeable.height),
+                    scrollProgress = scrollProgress(placeable?.height ?: 0),
                     horizontalAlignment = horizontalAlignment,
                     layoutDirection = layoutDirection,
                     key = itemProvider.getKey(index),
diff --git a/wear/compose/compose-foundation/src/main/java/androidx/wear/compose/foundation/rotary/Haptics.kt b/wear/compose/compose-foundation/src/main/java/androidx/wear/compose/foundation/rotary/Haptics.kt
index d5515df..28bce2c 100644
--- a/wear/compose/compose-foundation/src/main/java/androidx/wear/compose/foundation/rotary/Haptics.kt
+++ b/wear/compose/compose-foundation/src/main/java/androidx/wear/compose/foundation/rotary/Haptics.kt
@@ -20,7 +20,10 @@
 import android.content.pm.PackageManager
 import android.os.Build
 import android.provider.Settings
+import android.view.InputDevice
+import android.view.ScrollFeedbackProvider
 import android.view.View
+import androidx.annotation.RequiresApi
 import androidx.annotation.VisibleForTesting
 import androidx.compose.foundation.gestures.ScrollableState
 import androidx.compose.runtime.Composable
@@ -29,6 +32,7 @@
 import androidx.compose.ui.platform.LocalView
 import com.google.wear.input.WearHapticFeedbackConstants
 import kotlin.math.abs
+import kotlin.math.roundToInt
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.channels.BufferOverflow
 import kotlinx.coroutines.channels.Channel
@@ -43,13 +47,13 @@
 internal interface RotaryHapticHandler {
 
     /** Handles haptics when scroll is used */
-    fun handleScrollHaptic(timestamp: Long, deltaInPixels: Float)
+    fun handleScrollHaptic(timestamp: Long, deltaInPixels: Float, inputDeviceId: Int, axis: Int)
 
     /** Handles haptics when scroll with snap is used */
-    fun handleSnapHaptic(timestamp: Long, deltaInPixels: Float)
+    fun handleSnapHaptic(timestamp: Long, deltaInPixels: Float, inputDeviceId: Int, axis: Int)
 
     /** Handles haptics when edge of the list is reached */
-    fun handleLimitHaptic(isStart: Boolean)
+    fun handleLimitHaptic(isStart: Boolean, inputDeviceId: Int, axis: Int)
 }
 
 @Composable
@@ -58,11 +62,10 @@
     hapticsEnabled: Boolean
 ): RotaryHapticHandler =
     if (hapticsEnabled) {
-        // TODO(b/319103162): Add platform haptics once AndroidX updates to Android VanillaIceCream
-        rememberCustomRotaryHapticHandler(scrollableState)
-    } else {
-        rememberDisabledRotaryHapticHandler()
-    }
+        if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
+            rememberCustomRotaryHapticHandler(scrollableState)
+        else rememberPlatformRotaryHapticHandler(scrollableState)
+    } else rememberDisabledRotaryHapticHandler()
 
 /**
  * Remembers custom rotary haptic handler.
@@ -178,9 +181,14 @@
     private var currScrollPosition = 0f
     private var prevHapticsPosition = 0f
 
-    override fun handleScrollHaptic(timestamp: Long, deltaInPixels: Float) {
+    override fun handleScrollHaptic(
+        timestamp: Long,
+        deltaInPixels: Float,
+        inputDeviceId: Int,
+        axis: Int
+    ) {
         if (scrollableState.reachedTheLimit(deltaInPixels)) {
-            handleLimitHaptic(scrollableState.canScrollBackward)
+            handleLimitHaptic(scrollableState.canScrollBackward, inputDeviceId, axis)
         } else {
             overscrollHapticTriggered = false
             currScrollPosition += deltaInPixels
@@ -193,16 +201,21 @@
         }
     }
 
-    override fun handleSnapHaptic(timestamp: Long, deltaInPixels: Float) {
+    override fun handleSnapHaptic(
+        timestamp: Long,
+        deltaInPixels: Float,
+        inputDeviceId: Int,
+        axis: Int
+    ) {
         if (scrollableState.reachedTheLimit(deltaInPixels)) {
-            handleLimitHaptic(scrollableState.canScrollBackward)
+            handleLimitHaptic(scrollableState.canScrollBackward, inputDeviceId, axis)
         } else {
             overscrollHapticTriggered = false
             hapticsChannel.trySend(RotaryHapticsType.ScrollItemFocus)
         }
     }
 
-    override fun handleLimitHaptic(isStart: Boolean) {
+    override fun handleLimitHaptic(isStart: Boolean, inputDeviceId: Int, axis: Int) {
         if (!overscrollHapticTriggered) {
             hapticsChannel.trySend(RotaryHapticsType.ScrollLimit)
             overscrollHapticTriggered = true
@@ -240,20 +253,85 @@
 @Composable
 private fun rememberDisabledRotaryHapticHandler(): RotaryHapticHandler = remember {
     object : RotaryHapticHandler {
-        override fun handleScrollHaptic(timestamp: Long, deltaInPixels: Float) {
+        override fun handleScrollHaptic(
+            timestamp: Long,
+            deltaInPixels: Float,
+            inputDeviceId: Int,
+            axis: Int
+        ) {
             // Do nothing
         }
 
-        override fun handleSnapHaptic(timestamp: Long, deltaInPixels: Float) {
+        override fun handleSnapHaptic(
+            timestamp: Long,
+            deltaInPixels: Float,
+            inputDeviceId: Int,
+            axis: Int
+        ) {
             // Do nothing
         }
 
-        override fun handleLimitHaptic(isStart: Boolean) {
+        override fun handleLimitHaptic(isStart: Boolean, inputDeviceId: Int, axis: Int) {
             // Do nothing
         }
     }
 }
 
+@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
+@Composable
+private fun rememberPlatformRotaryHapticHandler(
+    scrollableState: ScrollableState,
+): RotaryHapticHandler {
+    val view = LocalView.current
+    return remember(scrollableState, view) {
+        PlatformRotaryHapticHandler(scrollableState, ScrollFeedbackProvider.createProvider(view))
+    }
+}
+
+@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
+@VisibleForTesting
+internal class PlatformRotaryHapticHandler(
+    private val scrollableState: ScrollableState,
+    private val scrollFeedbackProvider: ScrollFeedbackProvider,
+) : RotaryHapticHandler {
+
+    override fun handleScrollHaptic(
+        timestamp: Long,
+        deltaInPixels: Float,
+        inputDeviceId: Int,
+        axis: Int
+    ) {
+        if (scrollableState.reachedTheLimit(deltaInPixels)) {
+            handleLimitHaptic(scrollableState.canScrollBackward, inputDeviceId, axis)
+        } else {
+            scrollFeedbackProvider.onScrollProgress(
+                inputDeviceId,
+                InputDevice.SOURCE_ROTARY_ENCODER,
+                axis,
+                deltaInPixels.roundToInt()
+            )
+        }
+    }
+
+    override fun handleSnapHaptic(
+        timestamp: Long,
+        deltaInPixels: Float,
+        inputDeviceId: Int,
+        axis: Int
+    ) {
+        scrollFeedbackProvider.onSnapToItem(inputDeviceId, InputDevice.SOURCE_ROTARY_ENCODER, axis)
+    }
+
+    override fun handleLimitHaptic(isStart: Boolean, inputDeviceId: Int, axis: Int) {
+        scrollFeedbackProvider.onScrollLimit(
+            inputDeviceId,
+            InputDevice.SOURCE_ROTARY_ENCODER,
+            axis,
+            isStart
+        )
+    }
+}
+
 /** Rotary haptic feedback */
 private class RotaryHapticFeedbackProvider(
     private val view: View,
diff --git a/wear/compose/compose-foundation/src/main/java/androidx/wear/compose/foundation/rotary/RotaryScrollable.kt b/wear/compose/compose-foundation/src/main/java/androidx/wear/compose/foundation/rotary/RotaryScrollable.kt
index aabef5a..0fa7b85d 100644
--- a/wear/compose/compose-foundation/src/main/java/androidx/wear/compose/foundation/rotary/RotaryScrollable.kt
+++ b/wear/compose/compose-foundation/src/main/java/androidx/wear/compose/foundation/rotary/RotaryScrollable.kt
@@ -16,6 +16,7 @@
 
 package androidx.wear.compose.foundation.rotary
 
+import android.view.MotionEvent
 import android.view.ViewConfiguration
 import androidx.compose.animation.core.AnimationState
 import androidx.compose.animation.core.CubicBezierEasing
@@ -852,7 +853,7 @@
         rotaryScrollDistance += delta
         debugLog { "Rotary scroll distance: $rotaryScrollDistance" }
 
-        rotaryHaptics.handleScrollHaptic(timestampMillis, delta)
+        rotaryHaptics.handleScrollHaptic(timestampMillis, delta, inputDeviceId, AxisScroll)
 
         previousScrollEventTime = timestampMillis
         scrollHandler.scrollToTarget(this, rotaryScrollDistance)
@@ -863,7 +864,9 @@
                 debugLog { "Calling beforeFling section" }
                 resetScrolling()
             },
-            edgeReached = { velocity -> rotaryHaptics.handleLimitHaptic(velocity > 0f) }
+            edgeReached = { velocity ->
+                rotaryHaptics.handleLimitHaptic(velocity > 0f, inputDeviceId, AxisScroll)
+            }
         )
     }
 
@@ -969,7 +972,7 @@
                     "Accumulated snap delta: $accumulatedSnapDelta"
             }
             if (edgeNotReached(snapDistanceInItems)) {
-                rotaryHaptics.handleSnapHaptic(timestampMillis, delta)
+                rotaryHaptics.handleSnapHaptic(timestampMillis, delta, inputDeviceId, AxisScroll)
             }
 
             snapHandler.updateSnapTarget(snapDistanceInItems, sequentialSnap)
@@ -1077,7 +1080,7 @@
         if (abs(accumulatedSnapDelta) > 1f) {
 
             val snapDistanceInItems = sign(accumulatedSnapDelta).toInt()
-            rotaryHaptics.handleSnapHaptic(timestampMillis, delta)
+            rotaryHaptics.handleSnapHaptic(timestampMillis, delta, inputDeviceId, AxisScroll)
             val sequentialSnap = snapJob.isActive
             debugLog {
                 "Snap threshold reached: snapDistanceInItems:$snapDistanceInItems, " +
@@ -1299,6 +1302,8 @@
     HIGH(5f, 7.5f, 5f),
 }
 
+private const val AxisScroll = MotionEvent.AXIS_SCROLL
+
 /** Debug logging that can be enabled. */
 private const val DEBUG = false
 
diff --git a/wear/compose/compose-material3/api/current.txt b/wear/compose/compose-material3/api/current.txt
index 82db331..8a2e7dc 100644
--- a/wear/compose/compose-material3/api/current.txt
+++ b/wear/compose/compose-material3/api/current.txt
@@ -495,6 +495,19 @@
     method public static boolean isDynamicColorSchemeEnabled(android.content.Context context);
   }
 
+  public final class EdgeButtonDefaults {
+    method public float getExtraSmallIconSize();
+    method public float getLargeIconSize();
+    method public float getMediumIconSize();
+    method public float getSmallIconSize();
+    method public float iconSizeFor(float edgeButtonSize);
+    property public final float ExtraSmallIconSize;
+    property public final float LargeIconSize;
+    property public final float MediumIconSize;
+    property public final float SmallIconSize;
+    field public static final androidx.wear.compose.material3.EdgeButtonDefaults INSTANCE;
+  }
+
   public final class EdgeButtonKt {
     method @androidx.compose.runtime.Composable public static void EdgeButton(kotlin.jvm.functions.Function0<kotlin.Unit> onClick, optional androidx.compose.ui.Modifier modifier, optional float buttonSize, optional boolean enabled, optional androidx.wear.compose.material3.ButtonColors colors, optional androidx.compose.foundation.BorderStroke? border, optional androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.RowScope,kotlin.Unit> content);
   }
diff --git a/wear/compose/compose-material3/api/restricted_current.txt b/wear/compose/compose-material3/api/restricted_current.txt
index 82db331..8a2e7dc 100644
--- a/wear/compose/compose-material3/api/restricted_current.txt
+++ b/wear/compose/compose-material3/api/restricted_current.txt
@@ -495,6 +495,19 @@
     method public static boolean isDynamicColorSchemeEnabled(android.content.Context context);
   }
 
+  public final class EdgeButtonDefaults {
+    method public float getExtraSmallIconSize();
+    method public float getLargeIconSize();
+    method public float getMediumIconSize();
+    method public float getSmallIconSize();
+    method public float iconSizeFor(float edgeButtonSize);
+    property public final float ExtraSmallIconSize;
+    property public final float LargeIconSize;
+    property public final float MediumIconSize;
+    property public final float SmallIconSize;
+    field public static final androidx.wear.compose.material3.EdgeButtonDefaults INSTANCE;
+  }
+
   public final class EdgeButtonKt {
     method @androidx.compose.runtime.Composable public static void EdgeButton(kotlin.jvm.functions.Function0<kotlin.Unit> onClick, optional androidx.compose.ui.Modifier modifier, optional float buttonSize, optional boolean enabled, optional androidx.wear.compose.material3.ButtonColors colors, optional androidx.compose.foundation.BorderStroke? border, optional androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.RowScope,kotlin.Unit> content);
   }
diff --git a/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/ButtonDemo.kt b/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/ButtonDemo.kt
index 219dc41e..cbef4a3 100644
--- a/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/ButtonDemo.kt
+++ b/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/ButtonDemo.kt
@@ -444,7 +444,13 @@
         item {
             ChildButton(
                 onClick = { /* Do something */ },
-                label = { Text("Child Button") },
+                label = {
+                    Text(
+                        "Child Button",
+                        textAlign = TextAlign.Center,
+                        modifier = Modifier.fillMaxWidth()
+                    )
+                },
                 enabled = false,
                 modifier = Modifier.fillMaxWidth(),
             )
@@ -554,7 +560,7 @@
             }
         }
         item { ListHeader { Text("Icon and Label") } }
-        item { CompactButtonSample() }
+        item { CompactButtonSample(modifier = Modifier.fillMaxWidth()) }
         item {
             CompactButton(
                 onClick = { /* Do something */ },
@@ -628,12 +634,13 @@
         item { ListHeader { Text("Long Click") } }
         item {
             CompactButtonWithOnLongClickSample(
+                modifier = Modifier.fillMaxWidth(),
                 onClickHandler = { showOnClickToast(context) },
                 onLongClickHandler = { showOnLongClickToast(context) }
             )
         }
         item { ListHeader { Text("Expandable") } }
-        item { OutlinedCompactButtonSample() }
+        item { OutlinedCompactButtonSample(modifier = Modifier.fillMaxWidth()) }
     }
 }
 
diff --git a/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/EdgeButtonDemo.kt b/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/EdgeButtonDemo.kt
index f690adf..d5cc344 100644
--- a/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/EdgeButtonDemo.kt
+++ b/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/EdgeButtonDemo.kt
@@ -44,6 +44,7 @@
 import androidx.wear.compose.material3.ButtonDefaults
 import androidx.wear.compose.material3.Card
 import androidx.wear.compose.material3.EdgeButton
+import androidx.wear.compose.material3.EdgeButtonDefaults
 import androidx.wear.compose.material3.EdgeButtonSize
 import androidx.wear.compose.material3.RadioButton
 import androidx.wear.compose.material3.ScreenScaffold
@@ -86,7 +87,7 @@
                 verticalArrangement = Arrangement.spacedBy(4.dp),
                 contentPadding =
                     ScreenScaffoldDefaults.contentPaddingWithEdgeButton(
-                        edgeButtonSize = EdgeButtonSize.Medium,
+                        edgeButtonSize = EdgeButtonSize.Large,
                         10.dp,
                         20.dp,
                         10.dp,
@@ -237,7 +238,7 @@
                         ButtonDefaults.outlinedButtonBorder(enabled = true)
                     else null
             ) {
-                CheckIcon()
+                CheckIcon(modifier = Modifier.size(EdgeButtonDefaults.iconSizeFor(sizes[size])))
             }
         }
     }
@@ -281,7 +282,12 @@
                     enabled = colors[selectedColor].first != "Disabled"
                 ) {
                     if (selectedType == 0) {
-                        CheckIcon()
+                        CheckIcon(
+                            modifier =
+                                Modifier.size(
+                                    EdgeButtonDefaults.iconSizeFor(sizes[selectedSize].second)
+                                )
+                        )
                     } else {
                         Text("Ok")
                     }
diff --git a/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/TransformingLazyColumnDemo.kt b/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/TransformingLazyColumnDemo.kt
index 4a0c611..4523210 100644
--- a/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/TransformingLazyColumnDemo.kt
+++ b/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/TransformingLazyColumnDemo.kt
@@ -125,7 +125,7 @@
             ListHeader { Text("Buttons", style = MaterialTheme.typography.labelLarge) }
         }
 
-        item { SimpleButtonSample() }
+        item { SimpleButtonSample(modifier = Modifier.fillMaxWidth()) }
         item { ButtonSample() }
         item { ButtonLargeIconSample() }
         item { ButtonExtraLargeIconSample() }
@@ -137,9 +137,9 @@
         item { OutlinedButtonSample() }
         item { SimpleChildButtonSample() }
         item { ChildButtonSample() }
-        item { CompactButtonSample() }
-        item { FilledTonalCompactButtonSample() }
-        item { OutlinedCompactButtonSample() }
+        item { CompactButtonSample(modifier = Modifier.fillMaxWidth()) }
+        item { FilledTonalCompactButtonSample(modifier = Modifier.fillMaxWidth()) }
+        item { OutlinedCompactButtonSample(modifier = Modifier.fillMaxWidth()) }
         item { ButtonBackgroundImage(painterResource(R.drawable.backgroundimage), enabled = true) }
         item { ButtonBackgroundImage(painterResource(R.drawable.backgroundimage), enabled = false) }
         item { ListHeader { Text("Complex Buttons") } }
diff --git a/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/AlertDialogSample.kt b/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/AlertDialogSample.kt
index 6308c19..43fb374 100644
--- a/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/AlertDialogSample.kt
+++ b/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/AlertDialogSample.kt
@@ -36,6 +36,7 @@
 import androidx.wear.compose.material3.FilledTonalButton
 import androidx.wear.compose.material3.Icon
 import androidx.wear.compose.material3.MaterialTheme
+import androidx.wear.compose.material3.SwitchButton
 import androidx.wear.compose.material3.Text
 
 @Sampled
@@ -122,6 +123,8 @@
 @Composable
 fun AlertDialogWithContentGroupsSample() {
     var showDialog by remember { mutableStateOf(false) }
+    var weatherEnabled by remember { mutableStateOf(false) }
+    var calendarEnabled by remember { mutableStateOf(false) }
 
     Box(Modifier.fillMaxSize()) {
         FilledTonalButton(
@@ -147,16 +150,18 @@
         }
     ) {
         item {
-            FilledTonalButton(
+            SwitchButton(
                 modifier = Modifier.fillMaxWidth(),
-                onClick = {},
+                checked = weatherEnabled,
+                onCheckedChange = { weatherEnabled = it },
                 label = { Text("Weather") }
             )
         }
         item {
-            FilledTonalButton(
+            SwitchButton(
                 modifier = Modifier.fillMaxWidth(),
-                onClick = {},
+                checked = calendarEnabled,
+                onCheckedChange = { calendarEnabled = it },
                 label = { Text("Calendar") }
             )
         }
diff --git a/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/AnimatedTextSample.kt b/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/AnimatedTextSample.kt
index e3389e9..3d10692 100644
--- a/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/AnimatedTextSample.kt
+++ b/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/AnimatedTextSample.kt
@@ -19,13 +19,17 @@
 import androidx.annotation.Sampled
 import androidx.compose.animation.core.Animatable
 import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.padding
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.mutableIntStateOf
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.rememberCoroutineScope
 import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
 import androidx.compose.ui.text.font.FontVariation
+import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.sp
 import androidx.wear.compose.material3.AnimatedText
 import androidx.wear.compose.material3.Button
@@ -87,39 +91,34 @@
                     FontVariation.weight(500),
                 ),
             startFontSize = 30.sp,
-            endFontSize = 40.sp,
+            endFontSize = 30.sp,
         )
-    val firstNumber = remember { mutableIntStateOf(0) }
-    val firstAnimatable = remember { Animatable(0f) }
-    val secondNumber = remember { mutableIntStateOf(0) }
-    val secondAnimatable = remember { Animatable(0f) }
-    Column(horizontalAlignment = Alignment.CenterHorizontally) {
-        AnimatedText(
-            text = "${firstNumber.value}",
-            fontRegistry = animatedTextFontRegistry,
-            progressFraction = { firstAnimatable.value },
-        )
+    val number = remember { mutableIntStateOf(0) }
+    val textAnimatable = remember { Animatable(0f) }
+    Row(verticalAlignment = Alignment.CenterVertically) {
         Button(
+            modifier = Modifier.padding(horizontal = 16.dp),
             onClick = {
-                firstNumber.value += 1
+                number.value -= 1
                 scope.launch {
-                    firstAnimatable.animateTo(1f)
-                    firstAnimatable.animateTo(0f)
+                    textAnimatable.animateTo(1f)
+                    textAnimatable.animateTo(0f)
                 }
             },
-            label = { Text("+") }
+            label = { Text("-") }
         )
         AnimatedText(
-            text = "${secondNumber.value}",
+            text = "${number.value}",
             fontRegistry = animatedTextFontRegistry,
-            progressFraction = { secondAnimatable.value },
+            progressFraction = { textAnimatable.value },
         )
         Button(
+            modifier = Modifier.padding(horizontal = 16.dp),
             onClick = {
-                secondNumber.value += 1
+                number.value += 1
                 scope.launch {
-                    secondAnimatable.animateTo(1f)
-                    secondAnimatable.animateTo(0f)
+                    textAnimatable.animateTo(1f)
+                    textAnimatable.animateTo(0f)
                 }
             },
             label = { Text("+") }
@@ -132,17 +131,17 @@
 fun AnimatedTextSampleSharedFontRegistry() {
     val animatedTextFontRegistry =
         rememberAnimatedTextFontRegistry(
-            // Variation axes at the start of the animation, width 10, weight 200
+            // Variation axes at the start of the animation, width 50, weight 300
             startFontVariationSettings =
                 FontVariation.Settings(
-                    FontVariation.width(10f),
-                    FontVariation.weight(200),
+                    FontVariation.width(50f),
+                    FontVariation.weight(300),
                 ),
-            // Variation axes at the end of the animation, width 100, weight 500
+            // Variation axes at the end of the animation are the same as the start axes
             endFontVariationSettings =
                 FontVariation.Settings(
-                    FontVariation.width(100f),
-                    FontVariation.weight(500),
+                    FontVariation.width(50f),
+                    FontVariation.weight(300),
                 ),
             startFontSize = 15.sp,
             endFontSize = 25.sp,
diff --git a/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/ButtonGroupSample.kt b/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/ButtonGroupSample.kt
index b328822..a467fe1 100644
--- a/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/ButtonGroupSample.kt
+++ b/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/ButtonGroupSample.kt
@@ -40,16 +40,12 @@
         ButtonGroup(Modifier.fillMaxWidth()) {
             buttonGroupItem(interactionSource = interactionSourceLeft) {
                 Button(onClick = {}, interactionSource = interactionSourceLeft) {
-                    Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
-                        Text("Left")
-                    }
+                    Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Text("L") }
                 }
             }
             buttonGroupItem(interactionSource = interactionSourceRight) {
                 Button(onClick = {}, interactionSource = interactionSourceRight) {
-                    Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
-                        Text("Right")
-                    }
+                    Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Text("R") }
                 }
             }
         }
diff --git a/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/ButtonSample.kt b/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/ButtonSample.kt
index 40d5816..39798a1 100644
--- a/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/ButtonSample.kt
+++ b/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/ButtonSample.kt
@@ -24,6 +24,7 @@
 import androidx.compose.runtime.Composable
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.style.TextAlign
 import androidx.compose.ui.text.style.TextOverflow
 import androidx.wear.compose.material3.Button
 import androidx.wear.compose.material3.ButtonDefaults
@@ -37,7 +38,7 @@
 @Sampled
 @Composable
 fun SimpleButtonSample(modifier: Modifier = Modifier) {
-    Button(onClick = { /* Do something */ }, label = { Text("Button") }, modifier = modifier)
+    Button(onClick = { /* Do something */ }, label = { Text("Simple Button") }, modifier = modifier)
 }
 
 @Sampled
@@ -196,7 +197,9 @@
 fun SimpleChildButtonSample(modifier: Modifier = Modifier.fillMaxWidth()) {
     ChildButton(
         onClick = { /* Do something */ },
-        label = { Text("Child Button") },
+        label = {
+            Text("Child Button", textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth())
+        },
         modifier = modifier,
     )
 }
@@ -221,7 +224,7 @@
 
 @Sampled
 @Composable
-fun CompactButtonSample(modifier: Modifier = Modifier.fillMaxWidth()) {
+fun CompactButtonSample(modifier: Modifier = Modifier) {
     CompactButton(
         onClick = { /* Do something */ },
         icon = {
@@ -242,7 +245,7 @@
 fun CompactButtonWithOnLongClickSample(
     onClickHandler: () -> Unit,
     onLongClickHandler: () -> Unit,
-    modifier: Modifier = Modifier.fillMaxWidth()
+    modifier: Modifier = Modifier
 ) {
     CompactButton(
         onClick = onClickHandler,
@@ -255,7 +258,7 @@
 
 @Sampled
 @Composable
-fun FilledTonalCompactButtonSample(modifier: Modifier = Modifier.fillMaxWidth()) {
+fun FilledTonalCompactButtonSample(modifier: Modifier = Modifier) {
     CompactButton(
         onClick = { /* Do something */ },
         icon = {
@@ -274,7 +277,7 @@
 
 @Sampled
 @Composable
-fun OutlinedCompactButtonSample(modifier: Modifier = Modifier.fillMaxWidth()) {
+fun OutlinedCompactButtonSample(modifier: Modifier = Modifier) {
     CompactButton(
         onClick = { /* Do something */ },
         icon = {
diff --git a/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/CardSample.kt b/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/CardSample.kt
index d553884..f30ea5d 100644
--- a/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/CardSample.kt
+++ b/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/CardSample.kt
@@ -77,7 +77,7 @@
         onClick = { /* Do something */ },
         appName = { Text("App name") },
         title = { Text("Card title") },
-        time = { Text("now") },
+        time = { Text("Now") },
     ) {
         Text("Card content")
     }
@@ -96,10 +96,11 @@
                 modifier =
                     Modifier.size(CardDefaults.AppImageSize)
                         .wrapContentSize(align = Alignment.Center),
+                tint = MaterialTheme.colorScheme.primary
             )
         },
         title = { Text("Card title") },
-        time = { Text("now") },
+        time = { Text("Now") },
     ) {
         Text("Card content")
     }
@@ -122,10 +123,11 @@
                 modifier =
                     Modifier.size(CardDefaults.AppImageSize)
                         .wrapContentSize(align = Alignment.Center),
+                tint = MaterialTheme.colorScheme.primary
             )
         },
         title = { Text("With image") },
-        time = { Text("now") },
+        time = { Text("Now") },
     ) {
         Spacer(modifier = Modifier.height(4.dp))
         Row(modifier = Modifier.fillMaxWidth()) {
@@ -147,7 +149,7 @@
     TitleCard(
         onClick = { /* Do something */ },
         title = { Text("Title card") },
-        time = { Text("now") },
+        time = { Text("Now") },
     ) {
         Text("Card content")
     }
@@ -158,7 +160,7 @@
 fun TitleCardWithSubtitleAndTimeSample() {
     TitleCard(
         onClick = { /* Do something */ },
-        time = { Text("now") },
+        time = { Text("Now") },
         title = { Text("Title card") },
         subtitle = { Text("Subtitle") }
     )
@@ -170,7 +172,7 @@
     TitleCard(
         onClick = { /* Do something */ },
         title = { Text("Title card") },
-        time = { Text("now") },
+        time = { Text("Now") },
         modifier = Modifier.semantics { contentDescription = "Background image" }
     ) {
         Spacer(Modifier.height(4.dp))
@@ -206,7 +208,7 @@
     TitleCard(
         onClick = { /* Do something */ },
         title = { Text("Card title") },
-        time = { Text("now") },
+        time = { Text("Now") },
         colors =
             CardDefaults.imageCardColors(
                 containerPainter =
@@ -247,7 +249,7 @@
             )
         },
         title = { Text("App card") },
-        time = { Text("now") },
+        time = { Text("Now") },
         colors = CardDefaults.outlinedCardColors(),
         border = CardDefaults.outlinedCardBorder(),
     ) {
@@ -261,7 +263,7 @@
     TitleCard(
         onClick = { /* Do something */ },
         title = { Text("Title card") },
-        time = { Text("now") },
+        time = { Text("Now") },
         colors = CardDefaults.outlinedCardColors(),
         border = CardDefaults.outlinedCardBorder(),
     ) {
diff --git a/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/EdgeButtonSample.kt b/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/EdgeButtonSample.kt
index b744aa9..197cce5 100644
--- a/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/EdgeButtonSample.kt
+++ b/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/EdgeButtonSample.kt
@@ -31,10 +31,10 @@
 import androidx.compose.ui.unit.dp
 import androidx.wear.compose.foundation.lazy.ScalingLazyColumn
 import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState
-import androidx.wear.compose.material3.ButtonDefaults
 import androidx.wear.compose.material3.ButtonDefaults.buttonColors
 import androidx.wear.compose.material3.Card
 import androidx.wear.compose.material3.EdgeButton
+import androidx.wear.compose.material3.EdgeButtonDefaults
 import androidx.wear.compose.material3.EdgeButtonSize
 import androidx.wear.compose.material3.Icon
 import androidx.wear.compose.material3.ScreenScaffold
@@ -54,7 +54,7 @@
             Icon(
                 Icons.Filled.Check,
                 contentDescription = "Check icon",
-                modifier = Modifier.size(ButtonDefaults.IconSize)
+                modifier = Modifier.size(EdgeButtonDefaults.MediumIconSize)
             )
         }
     }
diff --git a/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/icons/SampleIcons.kt b/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/icons/SampleIcons.kt
index c7def02..636377e 100644
--- a/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/icons/SampleIcons.kt
+++ b/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/icons/SampleIcons.kt
@@ -44,11 +44,11 @@
 }
 
 @Composable
-fun CheckIcon() {
+fun CheckIcon(modifier: Modifier = Modifier.size(ButtonDefaults.IconSize)) {
     Icon(
         painter = painterResource(R.drawable.ic_check_rounded),
         contentDescription = "Check",
-        modifier = Modifier.size(ButtonDefaults.IconSize)
+        modifier = modifier
     )
 }
 
diff --git a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/EdgeButtonScreenshotTest.kt b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/EdgeButtonScreenshotTest.kt
index 9409890..5b45fb1 100644
--- a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/EdgeButtonScreenshotTest.kt
+++ b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/EdgeButtonScreenshotTest.kt
@@ -20,6 +20,7 @@
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.size
 import androidx.compose.foundation.text.BasicText
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.CompositionLocalProvider
@@ -68,26 +69,49 @@
     }
 
     @Test
-    fun edge_button_small() =
-        verifyScreenshot() { BasicEdgeButton(buttonSize = EdgeButtonSize.Small) }
+    fun edge_button_small() = verifyScreenshot {
+        BasicEdgeButton(buttonSize = EdgeButtonSize.Small)
+    }
 
     @Test
-    fun edge_button_medium() =
-        verifyScreenshot() { BasicEdgeButton(buttonSize = EdgeButtonSize.Medium) }
+    fun edge_button_medium() = verifyScreenshot {
+        BasicEdgeButton(buttonSize = EdgeButtonSize.Medium)
+    }
 
     @Test
-    fun edge_button_large() =
-        verifyScreenshot() { BasicEdgeButton(buttonSize = EdgeButtonSize.Large) }
+    fun edge_button_large() = verifyScreenshot {
+        BasicEdgeButton(buttonSize = EdgeButtonSize.Large)
+    }
 
     @Test
-    fun edge_button_disabled() =
-        verifyScreenshot() { BasicEdgeButton(buttonSize = EdgeButtonSize.Medium, enabled = false) }
+    fun edge_button_xsmall_icon() = verifyScreenshot {
+        BasicEdgeButtonWithIcon(buttonSize = EdgeButtonSize.ExtraSmall)
+    }
 
     @Test
-    fun edge_button_small_space_very_limited() =
-        verifyScreenshot() {
-            BasicEdgeButton(buttonSize = EdgeButtonSize.Small, constrainedHeight = 10.dp)
-        }
+    fun edge_button_small_icon() = verifyScreenshot {
+        BasicEdgeButtonWithIcon(buttonSize = EdgeButtonSize.Small)
+    }
+
+    @Test
+    fun edge_button_medium_icon() = verifyScreenshot {
+        BasicEdgeButtonWithIcon(buttonSize = EdgeButtonSize.Medium)
+    }
+
+    @Test
+    fun edge_button_large_icon() = verifyScreenshot {
+        BasicEdgeButtonWithIcon(buttonSize = EdgeButtonSize.Large)
+    }
+
+    @Test
+    fun edge_button_disabled() = verifyScreenshot {
+        BasicEdgeButton(buttonSize = EdgeButtonSize.Medium, enabled = false)
+    }
+
+    @Test
+    fun edge_button_small_space_very_limited() = verifyScreenshot {
+        BasicEdgeButton(buttonSize = EdgeButtonSize.Small, constrainedHeight = 10.dp)
+    }
 
     @Test
     fun edge_button_small_space_limited() = verifyScreenshot {
@@ -135,6 +159,23 @@
         }
     }
 
+    @Composable
+    private fun BasicEdgeButtonWithIcon(
+        buttonSize: EdgeButtonSize,
+        enabled: Boolean = true,
+    ) {
+        Box(Modifier.fillMaxSize()) {
+            EdgeButton(
+                onClick = { /* Do something */ },
+                enabled = enabled,
+                buttonSize = buttonSize,
+                modifier = Modifier.align(Alignment.BottomEnd).testTag(TEST_TAG)
+            ) {
+                TestIcon(modifier = Modifier.size(EdgeButtonDefaults.iconSizeFor(buttonSize)))
+            }
+        }
+    }
+
     private fun verifyScreenshot(
         layoutDirection: LayoutDirection = LayoutDirection.Ltr,
         content: @Composable () -> Unit
diff --git a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/Material3Test.kt b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/Material3Test.kt
index adb74f9..ebfcca8 100644
--- a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/Material3Test.kt
+++ b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/Material3Test.kt
@@ -63,8 +63,8 @@
 import androidx.compose.ui.test.junit4.ComposeContentTestRule
 import androidx.compose.ui.test.onNodeWithTag
 import androidx.compose.ui.test.onNodeWithText
-import androidx.compose.ui.test.performClick
 import androidx.compose.ui.test.performSemanticsAction
+import androidx.compose.ui.test.performTouchInput
 import androidx.compose.ui.text.TextLayoutResult
 import androidx.compose.ui.text.TextStyle
 import androidx.compose.ui.unit.Dp
@@ -418,7 +418,10 @@
     }
 
     mainClock.autoAdvance = false
-    onNodeWithTag(TEST_TAG).performClick()
+    onNodeWithTag(TEST_TAG).performTouchInput {
+        down(center)
+        up()
+    }
 
     /**
      * We are manually advancing by a fixed amount of frames since
diff --git a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/ScrollIndicatorTest.kt b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/ScrollIndicatorTest.kt
index dd140ab..0aa297a 100644
--- a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/ScrollIndicatorTest.kt
+++ b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/ScrollIndicatorTest.kt
@@ -159,7 +159,19 @@
 
     @Test
     fun scalingLazyColumnStateAdapter_shortContent_scrolled() {
-        verifySlcScrollToCenter(expectedIndicatorSize = 0.75f, itemsCount = 4)
+        val itemsCount = 4
+
+        // By default in this test we use SLC with AutoCentering at 0th item. Last item will also be
+        // centered. Knowing that, the size of the screen and size of the item, we can say that the
+        // top and bottom paddings should be equal to itemSizeDp + itemSpacingDp.
+        val autoCenteringPadding = itemSizeDp + itemSpacingDp
+        // We can get an approximate indicator size by dividing viewPort size by the length of all
+        // items - including visible (top) auto centering padding.
+        val expectedIndicatorSize =
+            viewportSizeDp /
+                (itemSizeDp * itemsCount + itemSpacingDp * (itemsCount - 1) + autoCenteringPadding)
+
+        verifySlcScrollToCenter(expectedIndicatorSize = expectedIndicatorSize, itemsCount = 4)
     }
 
     @Test
@@ -325,9 +337,10 @@
             expectedIndicatorSize = {
                 Truth.assertThat(it).isWithin(0.05f).of(expectedIndicatorSize)
             },
-            // Scrolling by half of the list height, minus original central position of the list,
-            // which is 1.5th item.
-            scrollByItems = itemsCount / 2f - 1.5f,
+            initialCenterItemIndex = 0,
+            // Scrolling by half of the total list height, minus original central position of the
+            // list, which is 0.5th item.
+            scrollByItems = itemsCount / 2f - 0.5f,
             itemsCount = itemsCount
         )
     }
@@ -356,8 +369,9 @@
         verticalArrangement: Arrangement.Vertical =
             Arrangement.spacedBy(space = itemSpacingDp, alignment = Alignment.Bottom),
         reverseLayout: Boolean = false,
-        autoCentering: AutoCenteringParams? = AutoCenteringParams(),
         initialCenterItemIndex: Int = 1,
+        autoCentering: AutoCenteringParams? =
+            AutoCenteringParams(itemIndex = initialCenterItemIndex),
         contentPaddingDp: Dp = 0.dp,
         scrollByItems: Float = 0f,
         itemsCount: Int = 0,
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/AnimatedToggleRoundedCornerShape.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/AnimatedToggleRoundedCornerShape.kt
index a4a8ebc..f39a9e0 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/AnimatedToggleRoundedCornerShape.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/AnimatedToggleRoundedCornerShape.kt
@@ -18,11 +18,12 @@
 
 import androidx.compose.animation.core.Animatable
 import androidx.compose.animation.core.FiniteAnimationSpec
+import androidx.compose.foundation.interaction.InteractionSource
+import androidx.compose.foundation.interaction.PressInteraction
 import androidx.compose.foundation.shape.CornerSize
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.Stable
-import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.rememberCoroutineScope
 import androidx.compose.ui.geometry.RoundRect
@@ -73,51 +74,45 @@
  */
 @Composable
 internal fun rememberAnimatedToggleRoundedCornerShape(
+    interactionSource: InteractionSource,
     uncheckedCornerSize: CornerSize,
     checkedCornerSize: CornerSize,
     pressedCornerSize: CornerSize,
-    pressed: Boolean,
     checked: Boolean,
     onPressAnimationSpec: FiniteAnimationSpec<Float>,
     onReleaseAnimationSpec: FiniteAnimationSpec<Float>,
 ): Shape {
-    val toggleState =
-        when {
-            pressed -> ToggleState.Pressed
-            checked -> ToggleState.Checked
-            else -> ToggleState.Unchecked
-        }
-
-    val previous = remember { mutableStateOf(toggleState) }
     val scope = rememberCoroutineScope()
-    val progress = remember { Animatable(1f) }
-
-    val toggledCornerSize =
-        toggleState.cornerSize(uncheckedCornerSize, checkedCornerSize, pressedCornerSize)
-    val animationSpec = if (pressed) onPressAnimationSpec else onReleaseAnimationSpec
+    val progress = remember { Animatable(0f) }
 
     val animatedShape = remember {
         AnimatedToggleRoundedCornerShape(
-            startCornerSize = toggledCornerSize,
-            endCornerSize = toggledCornerSize,
+            startCornerSize = if (checked) checkedCornerSize else uncheckedCornerSize,
+            endCornerSize = pressedCornerSize,
             progress = { progress.value },
         )
     }
 
-    LaunchedEffect(toggleState) {
-        // Allow the press up animation to finish its minimum duration before starting the next
-        if (!pressed) {
-            waitUntil { !progress.isRunning || progress.value > MIN_REQUIRED_ANIMATION_PROGRESS }
-        }
+    LaunchedEffect(interactionSource) {
+        interactionSource.interactions.collect { interaction ->
+            when (interaction) {
+                is PressInteraction.Press -> {
+                    scope.launch { progress.animateTo(1f, animationSpec = onPressAnimationSpec) }
+                }
+                is PressInteraction.Cancel,
+                is PressInteraction.Release -> {
+                    waitUntil {
+                        !progress.isRunning || progress.value > MIN_REQUIRED_ANIMATION_PROGRESS
+                    }
 
-        if (toggleState != previous.value) {
-            animatedShape.startCornerSize = animatedShape.endCornerSize
-            animatedShape.endCornerSize = toggledCornerSize
-            previous.value = toggleState
+                    // The end of the animation was the pressed shape. Reverse the animation back
+                    // to zero and set the start depending on the button's pressed state.
+                    animatedShape.startCornerSize =
+                        if (animatedShape.startCornerSize == uncheckedCornerSize) checkedCornerSize
+                        else uncheckedCornerSize
 
-            scope.launch {
-                progress.snapTo(1f - progress.value)
-                progress.animateTo(1f, animationSpec = animationSpec)
+                    scope.launch { progress.animateTo(0f, animationSpec = onReleaseAnimationSpec) }
+                }
             }
         }
     }
@@ -125,21 +120,4 @@
     return animatedShape
 }
 
-private fun ToggleState.cornerSize(
-    uncheckedCornerSize: CornerSize,
-    checkedCornerSize: CornerSize,
-    pressedCornerSize: CornerSize,
-) =
-    when (this) {
-        ToggleState.Unchecked -> uncheckedCornerSize
-        ToggleState.Checked -> checkedCornerSize
-        ToggleState.Pressed -> pressedCornerSize
-    }
-
-internal enum class ToggleState {
-    Unchecked,
-    Checked,
-    Pressed,
-}
-
 private const val MIN_REQUIRED_ANIMATION_PROGRESS = 0.75f
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/EdgeButton.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/EdgeButton.kt
index 2644b08..67c9de7 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/EdgeButton.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/EdgeButton.kt
@@ -247,7 +247,7 @@
                     interactionSource = interactionSource,
                 )
                 .sizeAndOffset { containerShapeHelper.contentWindow }
-                .scaleAndAlignContent()
+                .scaleAndAlignContent(buttonSize)
                 // Limit the content size to the expected width for the button size.
                 .requiredSizeIn(
                     maxWidth = contentShapeHelper.contentWidthDp(),
@@ -276,6 +276,15 @@
 value class EdgeButtonSize internal constructor(internal val maximumHeight: Dp) {
     internal fun maximumHeightPlusPadding() = maximumHeight + VERTICAL_PADDING * 2
 
+    internal fun verticalPadding() =
+        when (this) {
+            ExtraSmall -> Pair(10.dp, 12.dp)
+            Small -> Pair(8.dp, 12.dp)
+            Medium -> Pair(14.dp, 20.dp)
+            Large -> Pair(18.dp, 22.dp)
+            else -> Pair(14.dp, 20.dp)
+        }
+
     companion object {
         /** The Size to be applied for an extra small [EdgeButton]. */
         val ExtraSmall = EdgeButtonSize(46.dp)
@@ -291,6 +300,35 @@
     }
 }
 
+/** Contains the default values used by [EdgeButton]. */
+object EdgeButtonDefaults {
+    /** The recommended icon size when used with [EdgeButtonSize.ExtraSmall]. */
+    val ExtraSmallIconSize = 24.dp
+
+    /** The recommended icon size when used with [EdgeButtonSize.Small]. */
+    val SmallIconSize = 32.dp
+
+    /** The recommended icon size when used with [EdgeButtonSize.Medium]. */
+    val MediumIconSize = 32.dp
+
+    /** The recommended icon size when used with [EdgeButtonSize.Large]. */
+    val LargeIconSize = 36.dp
+
+    /**
+     * Recommended icon size for a given edge button size.
+     *
+     * @param edgeButtonSize The size of the edge button
+     */
+    fun iconSizeFor(edgeButtonSize: EdgeButtonSize): Dp =
+        when (edgeButtonSize) {
+            EdgeButtonSize.ExtraSmall -> ExtraSmallIconSize
+            EdgeButtonSize.Small -> SmallIconSize
+            EdgeButtonSize.Medium -> MediumIconSize
+            EdgeButtonSize.Large -> LargeIconSize
+            else -> MediumIconSize
+        }
+}
+
 private fun Modifier.sizeAndOffset(rectFn: (Constraints) -> Rect) =
     layout { measurable, constraints ->
         val rect = rectFn(constraints)
@@ -424,44 +462,55 @@
 
 // Scales the content if it doesn't fit horizontally, horizontally center if there is room.
 // Vertically centers the content if there is room, otherwise aligns to the top.
-private fun Modifier.scaleAndAlignContent() = this.then(ScaleAndAlignContentElement())
+private fun Modifier.scaleAndAlignContent(buttonSize: EdgeButtonSize) =
+    this.then(ScaleAndAlignContentElement(buttonSize))
 
-private class ScaleAndAlignContentElement : ModifierNodeElement<ScaleAndAlignContentNode>() {
-    override fun create(): ScaleAndAlignContentNode = ScaleAndAlignContentNode()
+private class ScaleAndAlignContentElement(val buttonSize: EdgeButtonSize) :
+    ModifierNodeElement<ScaleAndAlignContentNode>() {
+    override fun create(): ScaleAndAlignContentNode = ScaleAndAlignContentNode(buttonSize)
 
-    override fun update(node: ScaleAndAlignContentNode) {}
+    override fun update(node: ScaleAndAlignContentNode) {
+        node.buttonSize = buttonSize
+    }
 
     override fun InspectorInfo.inspectableProperties() {
         name = "ScaleAndAlignContentElement"
+        properties["buttonSize"] = buttonSize
     }
 
     // All instances are equivalent
-    override fun equals(other: Any?) = other is ScaleAndAlignContentElement
+    override fun equals(other: Any?) =
+        other is ScaleAndAlignContentElement && buttonSize == other.buttonSize
 
-    override fun hashCode() = 42
+    override fun hashCode() = buttonSize.hashCode()
 }
 
-private class ScaleAndAlignContentNode : LayoutModifierNode, Modifier.Node() {
+private class ScaleAndAlignContentNode(var buttonSize: EdgeButtonSize) :
+    LayoutModifierNode, Modifier.Node() {
     override fun MeasureScope.measure(
         measurable: Measurable,
         constraints: Constraints
     ): MeasureResult {
         val placeable = measurable.measure(Constraints())
 
-        val topPadding = INTERNAL_TOP_PADDING.roundToPx()
-
         val wrapperWidth = placeable.width.coerceIn(constraints.minWidth, constraints.maxWidth)
         val wrapperHeight = placeable.height.coerceIn(constraints.minHeight, constraints.maxHeight)
 
         val scale = (wrapperWidth.toFloat() / placeable.width.coerceAtLeast(1)).coerceAtMost(1f)
 
+        val verticalPadding = buttonSize.verticalPadding()
+        val topPadding = verticalPadding.top().roundToPx()
+        val bottomPadding = verticalPadding.bottom().roundToPx()
+
         return layout(wrapperWidth, wrapperHeight) {
-            // If there is enough vertical space, we align like 'Center', otherwise like 'TopCenter'
+            // If there is enough vertical space, we align like 'Center', with a slight vertical
+            // offset. Otherwise like 'TopCenter'
             val position =
                 IntOffset(
                     x = (wrapperWidth - placeable.width) / 2, // Always center horizontally
                     y =
-                        ((wrapperHeight - placeable.height * scale) / 2)
+                        ((wrapperHeight - placeable.height * scale + topPadding - bottomPadding) /
+                                2)
                             .roundToInt()
                             .coerceAtLeast(topPadding)
                 )
@@ -475,6 +524,11 @@
     }
 }
 
+// Syntactic sugar for Pair<Dp, Dp> when used to extra values for top and bottom vertical padding.
+private fun Pair<Dp, Dp>.top() = first
+
+private fun Pair<Dp, Dp>.bottom() = second
+
 // Sizes at which the container will start and end fading away.
 private val CONTAINER_FADE_START_DP = 30.dp
 private val CONTAINER_FADE_END_DP = 4.dp
@@ -496,6 +550,3 @@
 
 // Padding around the Edge Button on it's top and bottom.
 private val VERTICAL_PADDING = 3.dp
-
-// Padding between the top edge of the button and the content.
-private val INTERNAL_TOP_PADDING = 6.dp
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/LevelIndicator.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/LevelIndicator.kt
index 75ab770..4f6163c 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/LevelIndicator.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/LevelIndicator.kt
@@ -59,8 +59,7 @@
     val paddingHorizontal = LevelIndicatorDefaults.edgePadding
     val radius = screenWidthDp / 2 - paddingHorizontal.value - strokeWidth.value / 2
     // Calculate indicator height based on a triangle of the top half of the sweep angle
-    // and subtract the end caps
-    val indicatorHeight = 2f * sin((0.5f * sweepAngle).toRadians()) * radius - strokeWidth.value
+    val indicatorHeight = 2f * sin((0.5f * sweepAngle).toRadians()) * radius
 
     IndicatorImpl(
         state =
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/RoundButton.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/RoundButton.kt
index 890f102..cafd4be 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/RoundButton.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/RoundButton.kt
@@ -26,7 +26,7 @@
 import androidx.compose.foundation.combinedClickable
 import androidx.compose.foundation.interaction.InteractionSource
 import androidx.compose.foundation.interaction.MutableInteractionSource
-import androidx.compose.foundation.interaction.collectIsPressedAsState
+import androidx.compose.foundation.interaction.PressInteraction
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.BoxScope
 import androidx.compose.foundation.shape.CornerBasedShape
@@ -113,18 +113,21 @@
     onPressAnimationSpec: FiniteAnimationSpec<Float>,
     onReleaseAnimationSpec: FiniteAnimationSpec<Float>,
 ): Shape {
-    val pressed = interactionSource.collectIsPressedAsState()
     val progress = remember { Animatable(0f) }
     val scope = rememberCoroutineScope()
 
-    LaunchedEffect(pressed.value) {
-        when (pressed.value) {
-            true -> scope.launch { progress.animateTo(1f, animationSpec = onPressAnimationSpec) }
-            false -> {
-                waitUntil {
-                    !progress.isRunning || progress.value > MIN_REQUIRED_ANIMATION_PROGRESS
+    LaunchedEffect(interactionSource) {
+        interactionSource.interactions.collect { interaction ->
+            when (interaction) {
+                is PressInteraction.Press ->
+                    scope.launch { progress.animateTo(1f, animationSpec = onPressAnimationSpec) }
+                is PressInteraction.Cancel,
+                is PressInteraction.Release -> {
+                    waitUntil {
+                        !progress.isRunning || progress.value > MIN_REQUIRED_ANIMATION_PROGRESS
+                    }
+                    scope.launch { progress.animateTo(0f, animationSpec = onReleaseAnimationSpec) }
                 }
-                scope.launch { progress.animateTo(0f, animationSpec = onReleaseAnimationSpec) }
             }
         }
     }
@@ -200,14 +203,12 @@
 
         val finalInteractionSource = interactionSource ?: remember { MutableInteractionSource() }
 
-        val pressed = finalInteractionSource.collectIsPressedAsState()
-
         val finalShape =
             rememberAnimatedToggleRoundedCornerShape(
+                interactionSource = finalInteractionSource,
                 uncheckedCornerSize = uncheckedShape.topEnd,
                 checkedCornerSize = checkedShape.topEnd,
                 pressedCornerSize = pressedShape.topEnd,
-                pressed = pressed.value,
                 checked = checked,
                 onPressAnimationSpec = onPressAnimationSpec,
                 onReleaseAnimationSpec = onReleaseAnimationSpec,
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/ScrollIndicator.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/ScrollIndicator.kt
index 2fed7f5..1047dbc 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/ScrollIndicator.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/ScrollIndicator.kt
@@ -597,7 +597,10 @@
         val isLastItem = lastVisibleItem.index == layoutInfo.totalItemsCount - 1
         // If our visible item is last in the list, we add afterContentPadding to its size
         val lastVisibleItemSize =
-            lastVisibleItem.size + (if (isLastItem) layoutInfo.afterContentPadding else 0)
+            lastVisibleItem.size +
+                (if (isLastItem)
+                    layoutInfo.afterContentPadding + layoutInfo.afterAutoCenteringPadding
+                else 0)
         // This is the offset of the last item w.r.t. the ScalingLazyColumn coordinate system where
         // 0 in the center of the visible viewport and +/-(state.viewportHeightPx / 2f) are the
         // start and end of the viewport.
@@ -633,7 +636,9 @@
         val isFirstItem = firstVisibleItem.index == 0
         // If our visible item is first in the list, we set beforeFirstItemPadding to the padding
         // before the first item. Then we add it to the size of the first item in our calculations.
-        val beforeFirstItemPadding = (if (isFirstItem) layoutInfo.beforeContentPadding else 0)
+        val beforeFirstItemPadding =
+            if (isFirstItem) layoutInfo.beforeContentPadding + layoutInfo.beforeAutoCenteringPadding
+            else 0
 
         val firstItemStartOffset =
             firstVisibleItem.startOffset(layoutInfo.anchorType) - beforeFirstItemPadding
diff --git a/wear/compose/compose-navigation/build.gradle b/wear/compose/compose-navigation/build.gradle
index 9f4b0a2..8273ef7 100644
--- a/wear/compose/compose-navigation/build.gradle
+++ b/wear/compose/compose-navigation/build.gradle
@@ -31,11 +31,12 @@
 }
 
 dependencies {
+    api("androidx.compose.animation:animation:1.7.4")
     api("androidx.compose.ui:ui:1.7.4")
     api("androidx.compose.runtime:runtime:1.7.4")
     api("androidx.navigation:navigation-runtime:2.6.0")
     api(project(":wear:compose:compose-material"))
-    api("androidx.activity:activity-compose:1.7.0")
+    api("androidx.activity:activity-compose:1.9.0")
     api("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
 
     implementation("androidx.navigation:navigation-common:2.6.0")
diff --git a/wear/compose/compose-navigation/src/androidTest/kotlin/androidx/wear/compose/navigation/SwipeDismissableNavHostSampleTest.kt b/wear/compose/compose-navigation/src/androidTest/kotlin/androidx/wear/compose/navigation/SwipeDismissableNavHostSampleTest.kt
index 78e5dd1..eeed354 100644
--- a/wear/compose/compose-navigation/src/androidTest/kotlin/androidx/wear/compose/navigation/SwipeDismissableNavHostSampleTest.kt
+++ b/wear/compose/compose-navigation/src/androidTest/kotlin/androidx/wear/compose/navigation/SwipeDismissableNavHostSampleTest.kt
@@ -15,12 +15,14 @@
  */
 package androidx.wear.compose.navigation
 
+import android.os.Build
 import androidx.compose.ui.test.junit4.createComposeRule
 import androidx.compose.ui.test.onNodeWithText
 import androidx.compose.ui.test.onRoot
 import androidx.compose.ui.test.performClick
 import androidx.compose.ui.test.performTouchInput
 import androidx.compose.ui.test.swipeRight
+import androidx.test.filters.SdkSuppress
 import androidx.wear.compose.navigation.samples.NavHostWithNamedArgument
 import androidx.wear.compose.navigation.samples.SimpleNavHost
 import org.junit.Rule
@@ -31,7 +33,7 @@
 
     @Test
     fun toggles_between_destinations_in_simplenavhost() {
-        rule.setContentWithTheme { SimpleNavHost() }
+        rule.setContent { SimpleNavHost() }
 
         rule.onNodeWithText("On").performClick()
         rule.onNodeWithText("Off").performClick()
@@ -41,7 +43,7 @@
 
     @Test
     fun navigates_to_named_arguments() {
-        rule.setContentWithTheme { NavHostWithNamedArgument() }
+        rule.setContent { NavHostWithNamedArgument() }
 
         rule.onNodeWithText("Item 1").performClick()
 
@@ -49,8 +51,10 @@
     }
 
     @Test
+    @SdkSuppress(maxSdkVersion = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
     fun swipes_back_from_named_arguments() {
-        rule.setContentWithTheme { NavHostWithNamedArgument() }
+
+        rule.setContent { NavHostWithNamedArgument() }
 
         rule.onNodeWithText("Item 1").performClick()
         rule.onRoot().performTouchInput { swipeRight() }
diff --git a/wear/compose/compose-navigation/src/androidTest/kotlin/androidx/wear/compose/navigation/SwipeDismissableNavHostTest.kt b/wear/compose/compose-navigation/src/androidTest/kotlin/androidx/wear/compose/navigation/SwipeDismissableNavHostTest.kt
index 08f6c25..7bed2df 100644
--- a/wear/compose/compose-navigation/src/androidTest/kotlin/androidx/wear/compose/navigation/SwipeDismissableNavHostTest.kt
+++ b/wear/compose/compose-navigation/src/androidTest/kotlin/androidx/wear/compose/navigation/SwipeDismissableNavHostTest.kt
@@ -15,6 +15,9 @@
  */
 package androidx.wear.compose.navigation
 
+import android.os.Build
+import android.window.BackEvent
+import androidx.activity.BackEventCompat
 import androidx.activity.OnBackPressedDispatcher
 import androidx.activity.OnBackPressedDispatcherOwner
 import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
@@ -52,9 +55,9 @@
 import androidx.navigation.NavBackStackEntry
 import androidx.navigation.NavHostController
 import androidx.navigation.testing.TestNavHostController
+import androidx.test.filters.SdkSuppress
 import androidx.wear.compose.material.Button
 import androidx.wear.compose.material.CompactChip
-import androidx.wear.compose.material.MaterialTheme
 import androidx.wear.compose.material.Text
 import androidx.wear.compose.material.ToggleButton
 import com.google.common.truth.Truth.assertThat
@@ -64,16 +67,18 @@
 class SwipeDismissableNavHostTest {
     @get:Rule val rule = createComposeRule()
 
+    private lateinit var backPressedDispatcher: OnBackPressedDispatcher
+
     @Test
     fun supports_testtag() {
-        rule.setContentWithTheme { SwipeDismissWithNavigation() }
+        rule.setContentWithBackPressedDispatcher { SwipeDismissWithNavigation() }
 
         rule.onNodeWithTag(TEST_TAG).assertExists()
     }
 
     @Test
     fun navigates_to_next_level() {
-        rule.setContentWithTheme { SwipeDismissWithNavigation() }
+        rule.setContentWithBackPressedDispatcher { SwipeDismissWithNavigation() }
 
         // Click to move to next destination.
         rule.onNodeWithText(START).performClick()
@@ -83,8 +88,10 @@
     }
 
     @Test
+    @SdkSuppress(maxSdkVersion = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
     fun navigates_back_to_previous_level_after_swipe() {
-        rule.setContentWithTheme { SwipeDismissWithNavigation() }
+
+        rule.setContentWithBackPressedDispatcher { SwipeDismissWithNavigation() }
 
         // Click to move to next destination then swipe to dismiss.
         rule.onNodeWithText(START).performClick()
@@ -95,8 +102,11 @@
     }
 
     @Test
+    @SdkSuppress(maxSdkVersion = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
     fun does_not_navigate_back_to_previous_level_when_swipe_disabled() {
-        rule.setContentWithTheme { SwipeDismissWithNavigation(userSwipeEnabled = false) }
+        rule.setContentWithBackPressedDispatcher {
+            SwipeDismissWithNavigation(userSwipeEnabled = false)
+        }
 
         // Click to move to next destination then swipe to dismiss.
         rule.onNodeWithText(START).performClick()
@@ -109,24 +119,17 @@
 
     @Test
     fun navigates_back_to_previous_level_with_back_button() {
-        val onBackPressedDispatcher = OnBackPressedDispatcher()
-        val dispatcherOwner =
-            object : OnBackPressedDispatcherOwner, LifecycleOwner by TestLifecycleOwner() {
-                override val onBackPressedDispatcher = onBackPressedDispatcher
-            }
         lateinit var navController: NavHostController
 
-        rule.setContentWithTheme {
-            CompositionLocalProvider(LocalOnBackPressedDispatcherOwner provides dispatcherOwner) {
-                navController = rememberSwipeDismissableNavController()
-                SwipeDismissWithNavigation(navController)
-            }
+        rule.setContentWithBackPressedDispatcher {
+            navController = rememberSwipeDismissableNavController()
+            SwipeDismissWithNavigation(navController)
         }
         // Move to next destination.
         rule.onNodeWithText(START).performClick()
 
         // Now trigger the back button
-        rule.runOnIdle { onBackPressedDispatcher.onBackPressed() }
+        rule.runOnIdle { backPressedDispatcher.onBackPressed() }
         rule.waitForIdle()
 
         // Should now display "start".
@@ -136,7 +139,7 @@
 
     @Test
     fun hides_previous_level_when_not_swiping() {
-        rule.setContentWithTheme { SwipeDismissWithNavigation() }
+        rule.setContentWithBackPressedDispatcher { SwipeDismissWithNavigation() }
 
         // Click to move to next destination then swipe to dismiss.
         rule.onNodeWithText(START).performClick()
@@ -147,12 +150,15 @@
 
     @ExperimentalTestApi
     @Test
+    @SdkSuppress(maxSdkVersion = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
     fun displays_previous_screen_during_swipe_gesture() {
-        rule.setContentWithTheme { WithTouchSlop(0f) { SwipeDismissWithNavigation() } }
+        rule.setContentWithBackPressedDispatcher {
+            WithTouchSlop(0f) { SwipeDismissWithNavigation() }
+        }
 
         // Click to move to next destination.
         rule.onNodeWithText(START).performClick()
-        // Click and drag to being a swipe gesture, but do not release the finger.
+        // Click and drag to begin a swipe gesture, but do not release the finger.
         rule
             .onNodeWithTag(TEST_TAG)
             .performTouchInput({
@@ -165,9 +171,46 @@
     }
 
     @Test
+    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.VANILLA_ICE_CREAM)
+    fun does_not_navigate_back_to_previous_level_after_swipe_api_35() {
+
+        rule.setContentWithBackPressedDispatcher { SwipeDismissWithNavigation() }
+
+        // Click to move to next destination then swipe to dismiss.
+        rule.onNodeWithText(START).performClick()
+        rule.onNodeWithTag(TEST_TAG).performTouchInput { swipeRight() }
+
+        // Should now display "start".
+        rule.onNodeWithText(START).assertDoesNotExist()
+    }
+
+    @ExperimentalTestApi
+    @Test
+    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.VANILLA_ICE_CREAM)
+    fun displays_previous_screen_during_predictive_back_api_35() {
+
+        rule.setContentWithBackPressedDispatcher { SwipeDismissWithNavigation() }
+
+        // Click to move to next destination.
+        rule.onNodeWithText(START).performClick()
+        // Click and drag to begin a back event gesture, but do not release the finger.
+        rule.runOnIdle {
+            backPressedDispatcher.dispatchOnBackStarted(
+                BackEventCompat(0.1F, 0.1F, 0.1F, BackEvent.EDGE_LEFT)
+            )
+            backPressedDispatcher.dispatchOnBackProgressed(
+                BackEventCompat(0.1F, 0.1F, 0.5F, BackEvent.EDGE_LEFT)
+            )
+        }
+
+        // As the finger is still 'down', the background should be visible.
+        rule.onNodeWithText(START).assertExists()
+    }
+
+    @Test
     fun destinations_keep_saved_state() {
         val screenId = mutableStateOf(START)
-        rule.setContentWithTheme {
+        rule.setContentWithBackPressedDispatcher {
             val holder = rememberSaveableStateHolder()
             holder.SaveableStateProvider(screenId) {
                 val navController = rememberSwipeDismissableNavController()
@@ -208,16 +251,14 @@
 
         rule.onNodeWithText("Off").performClick()
         rule.onNodeWithText("Go").performClick()
-        rule.waitForIdle()
-        rule.onNodeWithTag(TEST_TAG).performTouchInput({ swipeRight() })
-        rule.waitForIdle()
+        goBack()
         rule.onNodeWithText("On").assertExists()
     }
 
     @Test
     fun remembers_saved_state_on_two_screens() {
         val screenId = mutableStateOf(START)
-        rule.setContentWithTheme {
+        rule.setContentWithBackPressedDispatcher {
             val holder = rememberSaveableStateHolder()
             val navController = rememberSwipeDismissableNavController()
             SwipeDismissableNavHost(
@@ -283,10 +324,10 @@
         rule.onNodeWithText("Jump").performClick()
         rule.waitForIdle()
         rule.onNodeWithText("Off").assertExists()
-        rule.onNodeWithTag(TEST_TAG).performTouchInput({ swipeRight() })
+        goBack()
         // Next screen should still display the incremented counter.
         rule.onNodeWithText("1").assertExists()
-        rule.onNodeWithTag(TEST_TAG).performTouchInput({ swipeRight() })
+        goBack()
         // Start screen should still display 'On'
         rule.waitForIdle()
         rule.onNodeWithText("On").assertExists()
@@ -300,7 +341,7 @@
     fun updates_lifecycle_for_initial_destination() {
         lateinit var navController: NavHostController
         rule.mainClock.autoAdvance = false
-        rule.setContentWithTheme {
+        rule.setContentWithBackPressedDispatcher {
             navController = rememberSwipeDismissableNavController()
             SwipeDismissWithNavigation(navController)
         }
@@ -315,7 +356,7 @@
     @Test
     fun updates_lifecycle_after_navigation() {
         lateinit var navController: NavHostController
-        rule.setContentWithTheme {
+        rule.setContentWithBackPressedDispatcher {
             navController = rememberSwipeDismissableNavController()
             SwipeDismissWithNavigation(navController)
         }
@@ -332,7 +373,7 @@
     @Test
     fun updates_lifecycle_after_navigation_and_swipe_back() {
         lateinit var navController: NavHostController
-        rule.setContentWithTheme {
+        rule.setContentWithBackPressedDispatcher {
             navController = rememberSwipeDismissableNavController()
             SwipeDismissWithNavigation(navController)
         }
@@ -350,7 +391,7 @@
     @Test
     fun updates_lifecycle_after_popping_back_stack() {
         lateinit var navController: NavHostController
-        rule.setContentWithTheme {
+        rule.setContentWithBackPressedDispatcher {
             navController = rememberSwipeDismissableNavController()
             SwipeDismissWithNavigation(navController)
         }
@@ -370,7 +411,7 @@
     fun provides_access_to_current_backstack_entry_state() {
         lateinit var navController: NavHostController
         lateinit var backStackEntry: State<NavBackStackEntry?>
-        rule.setContentWithTheme {
+        rule.setContentWithBackPressedDispatcher {
             navController = rememberSwipeDismissableNavController()
             backStackEntry = navController.currentBackStackEntryAsState()
             SwipeDismissWithNavigation(navController)
@@ -385,7 +426,7 @@
     fun testNavHostController_starts_at_default_destination() {
         lateinit var navController: TestNavHostController
 
-        rule.setContentWithTheme {
+        rule.setContentWithBackPressedDispatcher {
             navController = TestNavHostController(LocalContext.current)
             navController.navigatorProvider.addNavigator(WearNavigator())
 
@@ -399,7 +440,7 @@
     fun testNavHostController_sets_current_destination() {
         lateinit var navController: TestNavHostController
 
-        rule.setContentWithTheme {
+        rule.setContentWithBackPressedDispatcher {
             navController = TestNavHostController(LocalContext.current)
             navController.navigatorProvider.addNavigator(WearNavigator())
 
@@ -436,10 +477,32 @@
             }
         }
     }
-}
 
-fun ComposeContentTestRule.setContentWithTheme(composable: @Composable () -> Unit) {
-    setContent { MaterialTheme { composable() } }
+    /**
+     * Depending on API level, either swipes right on the view with TEST_TAG, or presses back button
+     */
+    private fun goBack() {
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
+            rule.runOnIdle { backPressedDispatcher.onBackPressed() }
+        } else {
+            rule.onNodeWithTag(TEST_TAG).performTouchInput { swipeRight() }
+        }
+    }
+
+    private fun ComposeContentTestRule.setContentWithBackPressedDispatcher(
+        composable: @Composable () -> Unit
+    ) {
+        backPressedDispatcher = OnBackPressedDispatcher()
+        val dispatcherOwner =
+            object : OnBackPressedDispatcherOwner, LifecycleOwner by TestLifecycleOwner() {
+                override val onBackPressedDispatcher = backPressedDispatcher
+            }
+        setContent {
+            CompositionLocalProvider(LocalOnBackPressedDispatcherOwner provides dispatcherOwner) {
+                composable()
+            }
+        }
+    }
 }
 
 private const val NEXT = "next"
diff --git a/wear/compose/compose-navigation/src/main/java/androidx/wear/compose/navigation/BasicSwipeToDismissBoxNavHost.kt b/wear/compose/compose-navigation/src/main/java/androidx/wear/compose/navigation/BasicSwipeToDismissBoxNavHost.kt
new file mode 100644
index 0000000..f2c3ff6
--- /dev/null
+++ b/wear/compose/compose-navigation/src/main/java/androidx/wear/compose/navigation/BasicSwipeToDismissBoxNavHost.kt
@@ -0,0 +1,274 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.wear.compose.navigation
+
+import android.util.Log
+import androidx.activity.compose.BackHandler
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.AnimationVector1D
+import androidx.compose.animation.core.CubicBezierEasing
+import androidx.compose.animation.core.LinearEasing
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.SaveableStateHolder
+import androidx.compose.runtime.saveable.rememberSaveableStateHolder
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.RectangleShape
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.util.lerp
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleEventObserver
+import androidx.lifecycle.compose.LocalLifecycleOwner
+import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner
+import androidx.navigation.NavBackStackEntry
+import androidx.navigation.NavDestination
+import androidx.navigation.NavGraph
+import androidx.navigation.NavHostController
+import androidx.navigation.Navigator
+import androidx.navigation.compose.LocalOwnersProvider
+import androidx.navigation.get
+import androidx.wear.compose.foundation.BasicSwipeToDismissBox
+import androidx.wear.compose.foundation.LocalReduceMotion
+import androidx.wear.compose.foundation.SwipeToDismissKeys
+
+@Composable
+internal fun BasicSwipeToDismissBoxNavHost(
+    navController: NavHostController,
+    graph: NavGraph,
+    modifier: Modifier = Modifier,
+    userSwipeEnabled: Boolean = true,
+    state: SwipeDismissableNavHostState = rememberSwipeDismissableNavHostState(),
+) {
+    val lifecycleOwner = LocalLifecycleOwner.current
+    val viewModelStoreOwner =
+        checkNotNull(LocalViewModelStoreOwner.current) {
+            "SwipeDismissableNavHost requires a ViewModelStoreOwner to be provided " +
+                "via LocalViewModelStoreOwner"
+        }
+
+    navController.setViewModelStore(viewModelStoreOwner.viewModelStore)
+
+    // Then set the graph
+    navController.graph = graph
+
+    // Find the WearNavigator, returning early if it isn't found
+    // (such as is the case when using TestNavHostController).
+    val wearNavigator =
+        navController.navigatorProvider.get<Navigator<out NavDestination>>(WearNavigator.NAME)
+            as? WearNavigator ?: return
+
+    val backStack by wearNavigator.backStack.collectAsState()
+
+    val navigateBack: () -> Unit = { navController.popBackStack() }
+
+    val transitionsInProgress by wearNavigator.transitionsInProgress.collectAsState()
+
+    DisposableEffect(lifecycleOwner) {
+        // Setup the navController with proper owners
+        navController.setLifecycleOwner(lifecycleOwner)
+        onDispose {}
+    }
+
+    val stateHolder = rememberSaveableStateHolder()
+
+    val previous = if (backStack.size <= 1) null else backStack[backStack.lastIndex - 1]
+    // Get the current navigation backstack entry. If the backstack is empty, it could be because
+    // no WearNavigator.Destinations were added to the navigation backstack (be sure to build
+    // the NavGraph using androidx.wear.compose.navigation.composable) or because the last entry
+    // was popped prior to navigating (instead, use navigate with popUpTo).
+    // If the activity is using FLAG_ACTIVITY_NEW_TASK then it also needs to set
+    // FLAG_ACTIVITY_CLEAR_TASK, otherwise the activity will be created twice,
+    // the first of these with an empty backstack.
+    val current = backStack.lastOrNull()
+
+    if (current == null) {
+        val warningText =
+            "Current backstack entry is empty. Please ensure: \n" +
+                "1. The current WearNavigator navigation backstack is not empty (e.g. by using " +
+                "androidx.wear.compose.navigation.composable to build your nav graph). \n" +
+                "2. The last entry is not popped prior to navigation " +
+                "(instead, use navigate with popUpTo). \n" +
+                "3. If the activity uses FLAG_ACTIVITY_NEW_TASK you should also set " +
+                "FLAG_ACTIVITY_CLEAR_TASK to maintain the backstack consistency."
+
+        Log.w(TAG, warningText)
+        // There's nothing to draw here, so we can return early to make sure "current" is always
+        // available below this line
+        return
+    }
+
+    val isRoundDevice = isRoundDevice()
+    val reduceMotionEnabled = LocalReduceMotion.current.enabled()
+
+    var initialContent by remember { mutableStateOf(true) }
+    val swipeState = state.swipeToDismissBoxState
+
+    LaunchedEffect(swipeState.isAnimationRunning) {
+        // This effect marks the transitions completed when swipe animations finish,
+        // so that the navigation backstack entries can go to Lifecycle.State.RESUMED.
+        if (!swipeState.isAnimationRunning) {
+            transitionsInProgress.forEach { entry -> wearNavigator.onTransitionComplete(entry) }
+        }
+    }
+
+    val animationProgress =
+        remember(current) {
+            if (!wearNavigator.isPop.value) {
+                Animatable(0f)
+            } else {
+                Animatable(1f)
+            }
+        }
+
+    LaunchedEffect(animationProgress.isRunning) {
+        if (!animationProgress.isRunning) {
+            transitionsInProgress.forEach { entry -> wearNavigator.onTransitionComplete(entry) }
+        }
+    }
+
+    BackHandler(enabled = backStack.size > 1, onBack = navigateBack)
+
+    BasicSwipeToDismissBox(
+        onDismissed = navigateBack,
+        state = swipeState,
+        modifier = Modifier,
+        userSwipeEnabled = userSwipeEnabled && previous != null,
+        backgroundKey = previous?.id ?: SwipeToDismissKeys.Background,
+        contentKey = current.id,
+    ) { isBackground ->
+        BoxedStackEntryContent(
+            entry = if (isBackground) previous else current,
+            saveableStateHolder = stateHolder,
+            modifier =
+                if (isBackground) {
+                    modifier
+                } else {
+                    modifier.graphicsLayer {
+                        val scaleProgression =
+                            NAV_HOST_ENTER_TRANSITION_EASING_STANDARD.transform(
+                                (animationProgress.value / 0.75f)
+                            )
+                        val alphaProgression =
+                            NAV_HOST_ENTER_TRANSITION_EASING_STANDARD.transform(
+                                (animationProgress.value / 0.25f)
+                            )
+                        val scale = lerp(0.75f, 1f, scaleProgression).coerceAtMost(1f)
+                        val alpha = lerp(0.1f, 1f, alphaProgression).coerceIn(0f, 1f)
+                        scaleX = scale
+                        scaleY = scale
+                        this.alpha = alpha
+                        clip = true
+                        shape = if (isRoundDevice) CircleShape else RectangleShape
+                    }
+                },
+            layerColor =
+                if (isBackground || wearNavigator.isPop.value) {
+                    Color.Unspecified
+                } else FLASH_COLOR,
+            animatable = animationProgress
+        )
+    }
+
+    LaunchedEffect(current) {
+        if (!wearNavigator.isPop.value) {
+            if (reduceMotionEnabled) {
+                animationProgress.snapTo(1f)
+            } else {
+                animationProgress.animateTo(
+                    targetValue = 1f,
+                    animationSpec =
+                        tween(
+                            durationMillis =
+                                NAV_HOST_ENTER_TRANSITION_DURATION_MEDIUM +
+                                    NAV_HOST_ENTER_TRANSITION_DURATION_SHORT,
+                            easing = LinearEasing
+                        )
+                )
+            }
+        }
+    }
+
+    DisposableEffect(previous, current) {
+        if (initialContent) {
+            // There are no animations for showing the initial content, so mark transitions
+            // complete, allowing the navigation backstack entry to go to
+            // Lifecycle.State.RESUMED.
+            transitionsInProgress.forEach { entry -> wearNavigator.onTransitionComplete(entry) }
+            initialContent = false
+        }
+        onDispose {
+            transitionsInProgress.forEach { entry -> wearNavigator.onTransitionComplete(entry) }
+        }
+    }
+}
+
+@Composable
+private fun BoxedStackEntryContent(
+    entry: NavBackStackEntry?,
+    saveableStateHolder: SaveableStateHolder,
+    modifier: Modifier = Modifier,
+    layerColor: Color,
+    animatable: Animatable<Float, AnimationVector1D>
+) {
+    if (entry != null) {
+        val isRoundDevice = isRoundDevice()
+        var lifecycleState by remember { mutableStateOf(entry.lifecycle.currentState) }
+        DisposableEffect(entry.lifecycle) {
+            val observer = LifecycleEventObserver { _, event -> lifecycleState = event.targetState }
+            entry.lifecycle.addObserver(observer)
+            onDispose { entry.lifecycle.removeObserver(observer) }
+        }
+        if (lifecycleState.isAtLeast(Lifecycle.State.CREATED)) {
+            Box(modifier, propagateMinConstraints = true) {
+                val destination = entry.destination as WearNavigator.Destination
+                entry.LocalOwnersProvider(saveableStateHolder) { destination.content(entry) }
+                // Adding a flash effect when a new screen opens
+                if (layerColor != Color.Unspecified) {
+                    Canvas(Modifier.fillMaxSize()) {
+                        val absoluteProgression =
+                            ((animatable.value - 0.25f) / 0.75f).coerceIn(0f, 1f)
+                        val easedProgression =
+                            NAV_HOST_ENTER_TRANSITION_EASING_STANDARD.transform(absoluteProgression)
+                        val alpha = lerp(0.07f, 0f, easedProgression).coerceIn(0f, 1f)
+                        if (isRoundDevice) {
+                            drawCircle(color = layerColor.copy(alpha))
+                        } else {
+                            drawRect(color = layerColor.copy(alpha))
+                        }
+                    }
+                }
+            }
+        }
+    }
+}
+
+private const val NAV_HOST_ENTER_TRANSITION_DURATION_SHORT = 100
+private const val NAV_HOST_ENTER_TRANSITION_DURATION_MEDIUM = 300
+private val NAV_HOST_ENTER_TRANSITION_EASING_STANDARD = CubicBezierEasing(0.4f, 0f, 0.2f, 1f)
+private val FLASH_COLOR = Color.White
diff --git a/wear/compose/compose-navigation/src/main/java/androidx/wear/compose/navigation/PredictiveBackNavHost.kt b/wear/compose/compose-navigation/src/main/java/androidx/wear/compose/navigation/PredictiveBackNavHost.kt
new file mode 100644
index 0000000..e97ad67
--- /dev/null
+++ b/wear/compose/compose-navigation/src/main/java/androidx/wear/compose/navigation/PredictiveBackNavHost.kt
@@ -0,0 +1,286 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.wear.compose.navigation
+
+import android.os.Build
+import android.util.Log
+import androidx.activity.compose.PredictiveBackHandler
+import androidx.annotation.RequiresApi
+import androidx.collection.mutableObjectFloatMapOf
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.ContentTransform
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.LinearEasing
+import androidx.compose.animation.core.SeekableTransitionState
+import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.animate
+import androidx.compose.animation.core.rememberTransition
+import androidx.compose.animation.core.spring
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.scaleIn
+import androidx.compose.animation.scaleOut
+import androidx.compose.animation.slideInHorizontally
+import androidx.compose.animation.slideOutHorizontally
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveableStateHolder
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.RectangleShape
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.compose.LocalLifecycleOwner
+import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner
+import androidx.navigation.NavDestination
+import androidx.navigation.NavGraph
+import androidx.navigation.NavHostController
+import androidx.navigation.Navigator
+import androidx.navigation.compose.LocalOwnersProvider
+import androidx.navigation.get
+import androidx.wear.compose.foundation.LocalSwipeToDismissBackgroundScrimColor
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.launch
+
+@Composable
+@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
+internal fun PredictiveBackNavHost(
+    navController: NavHostController,
+    graph: NavGraph,
+    modifier: Modifier = Modifier,
+    userSwipeEnabled: Boolean = true,
+) {
+    val lifecycleOwner = LocalLifecycleOwner.current
+    val viewModelStoreOwner =
+        checkNotNull(LocalViewModelStoreOwner.current) {
+            "SwipeDismissableNavHost requires a ViewModelStoreOwner to be provided " +
+                "via LocalViewModelStoreOwner"
+        }
+
+    navController.setViewModelStore(viewModelStoreOwner.viewModelStore)
+
+    // Then set the graph
+    navController.graph = graph
+
+    // Find the WearNavigator, returning early if it isn't found
+    // (such as is the case when using TestNavHostController).
+    val wearNavigator =
+        navController.navigatorProvider.get<Navigator<out NavDestination>>(WearNavigator.NAME)
+            as? WearNavigator ?: return
+
+    val backStack by wearNavigator.backStack.collectAsState()
+
+    val navigateBack: () -> Unit = { navController.popBackStack() }
+
+    DisposableEffect(lifecycleOwner) {
+        // Setup the navController with proper owners
+        navController.setLifecycleOwner(lifecycleOwner)
+        onDispose {}
+    }
+
+    val stateHolder = rememberSaveableStateHolder()
+
+    val previous = backStack.getOrNull(backStack.lastIndex - 1)
+    // Get the current navigation backstack entry. If the backstack is empty, it could be because
+    // no WearNavigator.Destinations were added to the navigation backstack (be sure to build
+    // the NavGraph using androidx.wear.compose.navigation.composable) or because the last entry
+    // was popped prior to navigating (instead, use navigate with popUpTo).
+    // If the activity is using FLAG_ACTIVITY_NEW_TASK then it also needs to set
+    // FLAG_ACTIVITY_CLEAR_TASK, otherwise the activity will be created twice,
+    // the first of these with an empty backstack.
+    val current = backStack.lastOrNull()
+
+    if (current == null) {
+        val warningText =
+            "Current backstack entry is empty. Please ensure: \n" +
+                "1. The current WearNavigator navigation backstack is not empty (e.g. by using " +
+                "androidx.wear.compose.navigation.composable to build your nav graph). \n" +
+                "2. The last entry is not popped prior to navigation " +
+                "(instead, use navigate with popUpTo). \n" +
+                "3. If the activity uses FLAG_ACTIVITY_NEW_TASK you should also set " +
+                "FLAG_ACTIVITY_CLEAR_TASK to maintain the backstack consistency."
+
+        Log.w(TAG, warningText)
+        // There's nothing to draw here, so we can return early to make sure "current" is always
+        // available below this line
+        return
+    }
+
+    val scrimColor = LocalSwipeToDismissBackgroundScrimColor.current
+    val isRoundDevice = isRoundDevice()
+
+    // Use PredictiveBackHandler instead of BackHandler on API >= 35
+    var progress by remember { mutableFloatStateOf(0f) }
+    var inPredictiveBack by remember { mutableStateOf(false) }
+    val transitionState = remember { SeekableTransitionState(current) }
+    PredictiveBackHandler(userSwipeEnabled && backStack.size > 1) { backEvent ->
+        inPredictiveBack = true
+        progress = 0f
+        try {
+            backEvent.collect { progress = it.progress }
+            Animatable(progress).animateTo(1f, TRANSITION_ANIMATION_SPEC) { progress = value }
+            inPredictiveBack = false
+            navigateBack()
+        } catch (e: CancellationException) {
+            inPredictiveBack = false
+        }
+    }
+
+    val zIndices = remember { mutableObjectFloatMapOf<String>() }
+    val transition = rememberTransition(transitionState, label = "entry")
+
+    if (inPredictiveBack && previous != null) {
+        LaunchedEffect(progress) { transitionState.seekTo(progress, previous) }
+    } else {
+        LaunchedEffect(current) {
+            if (transitionState.currentState != current) {
+                transitionState.animateTo(current)
+            } else {
+                animate(transitionState.fraction, 0f, animationSpec = TRANSITION_ANIMATION_SPEC) {
+                    value,
+                    _ ->
+                    [email protected] {
+                        if (value > 0) {
+                            // Seek the original transition back to the currentState
+                            transitionState.seekTo(value)
+                        }
+                        if (value == 0f) {
+                            // Once we animate to the start, we need to snap to the right state.
+                            transitionState.snapTo(current)
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    transition.AnimatedContent(
+        modifier,
+        transitionSpec = {
+            val initialZIndex = zIndices.getOrPut(initialState.id) { 0f }
+            val targetZIndex =
+                when {
+                    targetState.id == initialState.id -> initialZIndex
+                    wearNavigator.isPop.value || inPredictiveBack ->
+                        initialZIndex - 1f // Going to the previous page, so zIndex - 1
+                    else -> initialZIndex + 1f // Going to the next page, so zIndex + 1
+                }.also { zIndices[targetState.id] = it }
+
+            ContentTransform(
+                targetContentEnter =
+                    if (wearNavigator.isPop.value || inPredictiveBack) POP_ENTER_TRANSITION
+                    else ENTER_TRANSITION,
+                initialContentExit =
+                    if (wearNavigator.isPop.value || inPredictiveBack) POP_EXIT_TRANSITION
+                    else EXIT_TRANSITION,
+                targetContentZIndex = targetZIndex,
+                sizeTransform = null
+            )
+        },
+        contentAlignment = Alignment.Center,
+        contentKey = { it.id }
+    ) {
+        // In some specific cases, such as popping your back stack or changing your
+        // start destination, AnimatedContent can contain an entry that is no longer
+        // part of visible entries since it was cleared from the back stack and is not
+        // animating.
+        val currentEntry =
+            if (wearNavigator.isPop.value || inPredictiveBack) {
+                // We have to do this because the previous entry might not show up in backStack
+                it
+            } else {
+                backStack.lastOrNull { entry -> it == entry }
+            }
+
+        if (currentEntry != null) {
+            Box(
+                modifier =
+                    Modifier.background(
+                            scrimColor,
+                            if (isRoundDevice) CircleShape else RectangleShape
+                        )
+                        .fillMaxSize()
+            ) {
+                // while in the scope of the composable, we provide the navBackStackEntry as the
+                // ViewModelStoreOwner and LifecycleOwner
+                if (currentEntry.lifecycle.currentState != Lifecycle.State.DESTROYED) {
+                    currentEntry.LocalOwnersProvider(stateHolder) {
+                        (currentEntry.destination as WearNavigator.Destination).content(
+                            currentEntry
+                        )
+                    }
+                }
+                if (currentEntry != current) {
+                    Box(
+                        modifier =
+                            Modifier.clickable(
+                                    enabled = false,
+                                    indication = null,
+                                    interactionSource = remember { MutableInteractionSource() }
+                                ) {
+                                    // Ignore taps on previous backstack entries
+                                }
+                                .fillMaxSize()
+                    )
+                }
+            }
+        }
+    }
+    LaunchedEffect(transition.currentState, transition.targetState) {
+        if (transition.currentState == transition.targetState) {
+            backStack.forEach { entry -> wearNavigator.onTransitionComplete(entry) }
+            zIndices.forEach { key, _ ->
+                if (key != transition.targetState.id) zIndices.remove(key)
+            }
+        }
+    }
+}
+
+private val ENTER_TRANSITION =
+    slideInHorizontally(initialOffsetX = { it / 2 }, animationSpec = spring(0.8f, 300f)) +
+        scaleIn(initialScale = 0.8f, animationSpec = spring(1f, 500f)) +
+        fadeIn(animationSpec = spring(1f, 1500f))
+private val EXIT_TRANSITION =
+    scaleOut(targetScale = 0.85f, animationSpec = spring(1f, 150f)) +
+        slideOutHorizontally(targetOffsetX = { -it / 2 }, animationSpec = spring(0.8f, 200f)) +
+        fadeOut(targetAlpha = 0.6f, animationSpec = spring(1f, 1400f))
+private val POP_ENTER_TRANSITION =
+    scaleIn(initialScale = 0.8f, animationSpec = tween(easing = LinearEasing)) +
+        slideInHorizontally(
+            initialOffsetX = { -it / 2 },
+            animationSpec = tween(easing = LinearEasing)
+        ) +
+        fadeIn(initialAlpha = 0.5f, animationSpec = tween(easing = LinearEasing))
+private val POP_EXIT_TRANSITION =
+    slideOutHorizontally(targetOffsetX = { it }, animationSpec = tween(easing = LinearEasing)) +
+        scaleOut(targetScale = 0.8f, animationSpec = tween(easing = LinearEasing))
+
+private val TRANSITION_ANIMATION_SPEC =
+    spring<Float>(Spring.DampingRatioNoBouncy, Spring.StiffnessMedium)
diff --git a/wear/compose/compose-navigation/src/main/java/androidx/wear/compose/navigation/SwipeDismissableNavHost.kt b/wear/compose/compose-navigation/src/main/java/androidx/wear/compose/navigation/SwipeDismissableNavHost.kt
index 03c34ce..8d9d2db 100644
--- a/wear/compose/compose-navigation/src/main/java/androidx/wear/compose/navigation/SwipeDismissableNavHost.kt
+++ b/wear/compose/compose-navigation/src/main/java/androidx/wear/compose/navigation/SwipeDismissableNavHost.kt
@@ -16,52 +16,19 @@
 
 package androidx.wear.compose.navigation
 
-import android.util.Log
-import androidx.activity.compose.BackHandler
-import androidx.compose.animation.core.Animatable
-import androidx.compose.animation.core.AnimationVector1D
-import androidx.compose.animation.core.CubicBezierEasing
-import androidx.compose.animation.core.LinearEasing
-import androidx.compose.animation.core.tween
-import androidx.compose.foundation.Canvas
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.shape.CircleShape
+import android.os.Build
 import androidx.compose.runtime.Composable
-import androidx.compose.runtime.DisposableEffect
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.collectAsState
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
-import androidx.compose.runtime.saveable.SaveableStateHolder
-import androidx.compose.runtime.saveable.rememberSaveableStateHolder
-import androidx.compose.runtime.setValue
 import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.graphics.RectangleShape
-import androidx.compose.ui.graphics.graphicsLayer
 import androidx.compose.ui.platform.LocalConfiguration
-import androidx.compose.ui.util.lerp
-import androidx.lifecycle.Lifecycle
-import androidx.lifecycle.LifecycleEventObserver
-import androidx.lifecycle.compose.LocalLifecycleOwner
-import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner
-import androidx.navigation.NavBackStackEntry
-import androidx.navigation.NavDestination
 import androidx.navigation.NavGraph
 import androidx.navigation.NavGraphBuilder
 import androidx.navigation.NavHostController
-import androidx.navigation.Navigator
-import androidx.navigation.compose.LocalOwnersProvider
 import androidx.navigation.createGraph
-import androidx.navigation.get
 import androidx.wear.compose.foundation.BasicSwipeToDismissBox
-import androidx.wear.compose.foundation.LocalReduceMotion
 import androidx.wear.compose.foundation.LocalSwipeToDismissBackgroundScrimColor
 import androidx.wear.compose.foundation.LocalSwipeToDismissContentScrimColor
 import androidx.wear.compose.foundation.SwipeToDismissBoxState
-import androidx.wear.compose.foundation.SwipeToDismissKeys
 import androidx.wear.compose.foundation.edgeSwipeToDismiss
 import androidx.wear.compose.foundation.rememberSwipeToDismissBoxState
 
@@ -75,11 +42,16 @@
  * The builder passed into this method is [remember]ed. This means that for this NavHost, the
  * contents of the builder cannot be changed.
  *
- * Content is displayed within a [BasicSwipeToDismissBox], showing the current navigation level.
  * During a swipe-to-dismiss gesture, the previous navigation level (if any) is shown in the
  * background. BackgroundScrimColor and ContentScrimColor of it are taken from
  * [LocalSwipeToDismissBackgroundScrimColor] and [LocalSwipeToDismissContentScrimColor].
  *
+ * Below API level 35, content of the current navigation level is displayed within a
+ * [BasicSwipeToDismissBox] to detect swipe back gestures.
+ *
+ * API level 35 onwards, [SwipeDismissableNavHost] listens to platform predictive back events for
+ * navigation, and [BasicSwipeToDismissBox] is not used for swipe gesture detection.
+ *
  * Example of a [SwipeDismissableNavHost] alternating between 2 screens:
  *
  * @sample androidx.wear.compose.navigation.samples.SimpleNavHost
@@ -91,12 +63,14 @@
  * @param startDestination The route for the start destination
  * @param modifier The modifier to be applied to the layout
  * @param userSwipeEnabled [Boolean] Whether swipe-to-dismiss gesture is enabled.
- * @param state State containing information about ongoing swipe and animation.
+ * @param state State containing information about ongoing swipe and animation. This parameter is
+ *   unused API level 35 onwards, because the platform supports predictive back and
+ *   [SwipeDismissableNavHost] uses platform gestures to detect the back gestures.
  * @param route The route for the graph
  * @param builder The builder used to construct the graph
  */
 @Composable
-public fun SwipeDismissableNavHost(
+fun SwipeDismissableNavHost(
     navController: NavHostController,
     startDestination: String,
     modifier: Modifier = Modifier,
@@ -125,11 +99,16 @@
  * The builder passed into this method is [remember]ed. This means that for this NavHost, the
  * contents of the builder cannot be changed.
  *
- * Content is displayed within a [BasicSwipeToDismissBox], showing the current navigation level.
  * During a swipe-to-dismiss gesture, the previous navigation level (if any) is shown in the
  * background. BackgroundScrimColor and ContentScrimColor of it are taken from
  * [LocalSwipeToDismissBackgroundScrimColor] and [LocalSwipeToDismissContentScrimColor].
  *
+ * Below API level 35, content of the current navigation level is displayed within a
+ * [BasicSwipeToDismissBox] to detect swipe back gestures.
+ *
+ * API level 35 onwards, [SwipeDismissableNavHost] listens to platform predictive back events for
+ * navigation, and [BasicSwipeToDismissBox] is not used for swipe gesture detection.
+ *
  * Example of a [SwipeDismissableNavHost] alternating between 2 screens:
  *
  * @sample androidx.wear.compose.navigation.samples.SimpleNavHost
@@ -141,173 +120,34 @@
  * @param graph Graph for this host
  * @param modifier [Modifier] to be applied to the layout
  * @param userSwipeEnabled [Boolean] Whether swipe-to-dismiss gesture is enabled.
- * @param state State containing information about ongoing swipe and animation.
+ * @param state State containing information about ongoing swipe and animation. This parameter is
+ *   unused API level 35 onwards, because the platform supports predictive back and
+ *   [SwipeDismissableNavHost] uses platform gestures to detect the back gestures.
  * @throws IllegalArgumentException if no WearNavigation.Destination is on the navigation backstack.
  */
 @Composable
-public fun SwipeDismissableNavHost(
+fun SwipeDismissableNavHost(
     navController: NavHostController,
     graph: NavGraph,
     modifier: Modifier = Modifier,
     userSwipeEnabled: Boolean = true,
     state: SwipeDismissableNavHostState = rememberSwipeDismissableNavHostState(),
 ) {
-    val lifecycleOwner = LocalLifecycleOwner.current
-    val viewModelStoreOwner =
-        checkNotNull(LocalViewModelStoreOwner.current) {
-            "SwipeDismissableNavHost requires a ViewModelStoreOwner to be provided " +
-                "via LocalViewModelStoreOwner"
-        }
-
-    navController.setViewModelStore(viewModelStoreOwner.viewModelStore)
-
-    // Then set the graph
-    navController.graph = graph
-
-    // Find the WearNavigator, returning early if it isn't found
-    // (such as is the case when using TestNavHostController).
-    val wearNavigator =
-        navController.navigatorProvider.get<Navigator<out NavDestination>>(WearNavigator.NAME)
-            as? WearNavigator ?: return
-
-    val backStack by wearNavigator.backStack.collectAsState()
-
-    val navigateBack: () -> Unit = { navController.popBackStack() }
-    BackHandler(enabled = backStack.size > 1, onBack = navigateBack)
-
-    val transitionsInProgress by wearNavigator.transitionsInProgress.collectAsState()
-    var initialContent by remember { mutableStateOf(true) }
-
-    DisposableEffect(lifecycleOwner) {
-        // Setup the navController with proper owners
-        navController.setLifecycleOwner(lifecycleOwner)
-        onDispose {}
-    }
-
-    val stateHolder = rememberSaveableStateHolder()
-
-    val previous = if (backStack.size <= 1) null else backStack[backStack.lastIndex - 1]
-    // Get the current navigation backstack entry. If the backstack is empty, it could be because
-    // no WearNavigator.Destinations were added to the navigation backstack (be sure to build
-    // the NavGraph using androidx.wear.compose.navigation.composable) or because the last entry
-    // was popped prior to navigating (instead, use navigate with popUpTo).
-    // If the activity is using FLAG_ACTIVITY_NEW_TASK then it also needs to set
-    // FLAG_ACTIVITY_CLEAR_TASK, otherwise the activity will be created twice,
-    // the first of these with an empty backstack.
-    val current = backStack.lastOrNull()
-
-    if (current == null) {
-        val warningText =
-            "Current backstack entry is empty. Please ensure: \n" +
-                "1. The current WearNavigator navigation backstack is not empty (e.g. by using " +
-                "androidx.wear.compose.navigation.composable to build your nav graph). \n" +
-                "2. The last entry is not popped prior to navigation " +
-                "(instead, use navigate with popUpTo). \n" +
-                "3. If the activity uses FLAG_ACTIVITY_NEW_TASK you should also set " +
-                "FLAG_ACTIVITY_CLEAR_TASK to maintain the backstack consistency."
-
-        Log.w(TAG, warningText)
-    }
-
-    val swipeState = state.swipeToDismissBoxState
-    LaunchedEffect(swipeState.isAnimationRunning) {
-        // This effect marks the transitions completed when swipe animations finish,
-        // so that the navigation backstack entries can go to Lifecycle.State.RESUMED.
-        if (!swipeState.isAnimationRunning) {
-            transitionsInProgress.forEach { entry -> wearNavigator.onTransitionComplete(entry) }
-        }
-    }
-
-    val isRoundDevice = isRoundDevice()
-    val reduceMotionEnabled = LocalReduceMotion.current.enabled()
-
-    val animationProgress =
-        remember(current) {
-            if (!wearNavigator.isPop.value) {
-                Animatable(0f)
-            } else {
-                Animatable(1f)
-            }
-        }
-
-    LaunchedEffect(animationProgress.isRunning) {
-        if (!animationProgress.isRunning) {
-            transitionsInProgress.forEach { entry -> wearNavigator.onTransitionComplete(entry) }
-        }
-    }
-
-    LaunchedEffect(current) {
-        if (!wearNavigator.isPop.value) {
-            if (reduceMotionEnabled) {
-                animationProgress.snapTo(1f)
-            } else {
-                animationProgress.animateTo(
-                    targetValue = 1f,
-                    animationSpec =
-                        tween(
-                            durationMillis =
-                                NAV_HOST_ENTER_TRANSITION_DURATION_MEDIUM +
-                                    NAV_HOST_ENTER_TRANSITION_DURATION_SHORT,
-                            easing = LinearEasing
-                        )
-                )
-            }
-        }
-    }
-
-    BasicSwipeToDismissBox(
-        onDismissed = navigateBack,
-        state = swipeState,
-        modifier = Modifier,
-        userSwipeEnabled = userSwipeEnabled && previous != null,
-        backgroundKey = previous?.id ?: SwipeToDismissKeys.Background,
-        contentKey = current?.id ?: SwipeToDismissKeys.Content
-    ) { isBackground ->
-        BoxedStackEntryContent(
-            entry = if (isBackground) previous else current,
-            saveableStateHolder = stateHolder,
-            modifier =
-                modifier.then(
-                    if (isBackground) {
-                        Modifier // Not applying graphicsLayer on the background.
-                    } else {
-                        Modifier.graphicsLayer {
-                            val scaleProgression =
-                                NAV_HOST_ENTER_TRANSITION_EASING_STANDARD.transform(
-                                    (animationProgress.value / 0.75f)
-                                )
-                            val opacityProgression =
-                                NAV_HOST_ENTER_TRANSITION_EASING_STANDARD.transform(
-                                    (animationProgress.value / 0.25f)
-                                )
-                            val scale = lerp(0.75f, 1f, scaleProgression).coerceAtMost(1f)
-                            val opacity = lerp(0.1f, 1f, opacityProgression).coerceIn(0f, 1f)
-                            scaleX = scale
-                            scaleY = scale
-                            alpha = opacity
-                            clip = true
-                            shape = if (isRoundDevice) CircleShape else RectangleShape
-                        }
-                    }
-                ),
-            layerColor =
-                if (isBackground || wearNavigator.isPop.value) {
-                    Color.Unspecified
-                } else FLASH_COLOR,
-            animatable = animationProgress
+    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
+        PredictiveBackNavHost(
+            navController = navController,
+            graph = graph,
+            modifier = modifier,
+            userSwipeEnabled = userSwipeEnabled,
         )
-    }
-
-    DisposableEffect(previous, current) {
-        if (initialContent) {
-            // There are no animations for showing the initial content, so mark transitions
-            // complete, allowing the navigation backstack entry to go to Lifecycle.State.RESUMED.
-            transitionsInProgress.forEach { entry -> wearNavigator.onTransitionComplete(entry) }
-            initialContent = false
-        }
-        onDispose {
-            transitionsInProgress.forEach { entry -> wearNavigator.onTransitionComplete(entry) }
-        }
+    } else {
+        BasicSwipeToDismissBoxNavHost(
+            navController = navController,
+            graph = graph,
+            modifier = modifier,
+            userSwipeEnabled = userSwipeEnabled,
+            state = state,
+        )
     }
 }
 
@@ -346,7 +186,7 @@
     level = DeprecationLevel.HIDDEN
 )
 @Composable
-public fun SwipeDismissableNavHost(
+fun SwipeDismissableNavHost(
     navController: NavHostController,
     startDestination: String,
     modifier: Modifier = Modifier,
@@ -398,7 +238,7 @@
     level = DeprecationLevel.HIDDEN
 )
 @Composable
-public fun SwipeDismissableNavHost(
+fun SwipeDismissableNavHost(
     navController: NavHostController,
     graph: NavGraph,
     modifier: Modifier = Modifier,
@@ -419,9 +259,7 @@
  *   swipe-to-dismiss gesture in [SwipeDismissableNavHost] and can also be used to support
  *   edge-swiping, using [edgeSwipeToDismiss].
  */
-public class SwipeDismissableNavHostState(
-    internal val swipeToDismissBoxState: SwipeToDismissBoxState
-) {
+class SwipeDismissableNavHostState(internal val swipeToDismissBoxState: SwipeToDismissBoxState) {
     @Suppress("DEPRECATION")
     @Deprecated(
         "This overload is provided for backward compatibility. " +
@@ -438,10 +276,12 @@
  *
  * @param swipeToDismissBoxState State for [BasicSwipeToDismissBox], which is used to support the
  *   swipe-to-dismiss gesture in [SwipeDismissableNavHost] and can also be used to support
- *   edge-swiping, using [edgeSwipeToDismiss].
+ *   edge-swiping, using [edgeSwipeToDismiss]. This parameter is unused after API 35, because the
+ *   platform supports edge-swiping via predictive back gesture, and [SwipeDismissableNavHost] drops
+ *   the use of [BasicSwipeToDismissBox] in favour of predictive back based navigation.
  */
 @Composable
-public fun rememberSwipeDismissableNavHostState(
+fun rememberSwipeDismissableNavHostState(
     swipeToDismissBoxState: SwipeToDismissBoxState = rememberSwipeToDismissBoxState()
 ): SwipeDismissableNavHostState {
     return remember(swipeToDismissBoxState) { SwipeDismissableNavHostState(swipeToDismissBoxState) }
@@ -454,7 +294,7 @@
     level = DeprecationLevel.HIDDEN
 )
 @Composable
-public fun rememberSwipeDismissableNavHostState(
+fun rememberSwipeDismissableNavHostState(
     swipeToDismissBoxState: androidx.wear.compose.material.SwipeToDismissBoxState =
         androidx.wear.compose.material.rememberSwipeToDismissBoxState()
 ): SwipeDismissableNavHostState {
@@ -462,53 +302,9 @@
 }
 
 @Composable
-private fun BoxedStackEntryContent(
-    entry: NavBackStackEntry?,
-    saveableStateHolder: SaveableStateHolder,
-    modifier: Modifier = Modifier,
-    layerColor: Color,
-    animatable: Animatable<Float, AnimationVector1D>
-) {
-    if (entry != null) {
-        val isRoundDevice = isRoundDevice()
-        var lifecycleState by remember { mutableStateOf(entry.lifecycle.currentState) }
-        DisposableEffect(entry.lifecycle) {
-            val observer = LifecycleEventObserver { _, event -> lifecycleState = event.targetState }
-            entry.lifecycle.addObserver(observer)
-            onDispose { entry.lifecycle.removeObserver(observer) }
-        }
-        if (lifecycleState.isAtLeast(Lifecycle.State.CREATED)) {
-            Box(modifier, propagateMinConstraints = true) {
-                val destination = entry.destination as WearNavigator.Destination
-                entry.LocalOwnersProvider(saveableStateHolder) { destination.content(entry) }
-                // Adding a flash effect when a new screen opens
-                if (layerColor != Color.Unspecified) {
-                    Canvas(Modifier.fillMaxSize()) {
-                        val absoluteProgression =
-                            ((animatable.value - 0.25f) / 0.75f).coerceIn(0f, 1f)
-                        val easedProgression =
-                            NAV_HOST_ENTER_TRANSITION_EASING_STANDARD.transform(absoluteProgression)
-                        val alpha = lerp(0.07f, 0f, easedProgression).coerceIn(0f, 1f)
-                        if (isRoundDevice) {
-                            drawCircle(color = layerColor.copy(alpha))
-                        } else {
-                            drawRect(color = layerColor.copy(alpha))
-                        }
-                    }
-                }
-            }
-        }
-    }
-}
-
-@Composable
-private fun isRoundDevice(): Boolean {
+internal fun isRoundDevice(): Boolean {
     val configuration = LocalConfiguration.current
     return remember(configuration) { configuration.isScreenRound }
 }
 
-private const val TAG = "SwipeDismissableNavHost"
-private const val NAV_HOST_ENTER_TRANSITION_DURATION_SHORT = 100
-private const val NAV_HOST_ENTER_TRANSITION_DURATION_MEDIUM = 300
-private val NAV_HOST_ENTER_TRANSITION_EASING_STANDARD = CubicBezierEasing(0.4f, 0f, 0.2f, 1f)
-private val FLASH_COLOR = Color.White
+internal const val TAG = "SwipeDismissableNavHost"
diff --git a/wear/compose/integration-tests/navigation/src/main/java/androidx/wear/compose/integration/navigation/MainActivity.kt b/wear/compose/integration-tests/navigation/src/main/java/androidx/wear/compose/integration/navigation/MainActivity.kt
index 21ba986..fe2e2ee 100644
--- a/wear/compose/integration-tests/navigation/src/main/java/androidx/wear/compose/integration/navigation/MainActivity.kt
+++ b/wear/compose/integration-tests/navigation/src/main/java/androidx/wear/compose/integration/navigation/MainActivity.kt
@@ -80,10 +80,16 @@
                             modifier = Modifier.fillMaxSize(),
                         ) {
                             Text(text = "Screen 2", color = MaterialTheme.colors.onSurface)
+                            Spacer(modifier = Modifier.fillMaxWidth().height(4.dp))
                             CompactChip(
                                 onClick = { navController.navigate(SCREEN3) },
                                 label = { Text("Click for next screen") },
                             )
+                            Spacer(modifier = Modifier.fillMaxWidth().height(4.dp))
+                            CompactChip(
+                                onClick = { navController.popBackStack() },
+                                label = { Text("Go Back") },
+                            )
                         }
                     }
                     composable(SCREEN3) {
@@ -93,10 +99,16 @@
                             modifier = Modifier.fillMaxSize(),
                         ) {
                             Text(text = "Screen 3", color = MaterialTheme.colors.onSurface)
+                            Spacer(modifier = Modifier.fillMaxWidth().height(4.dp))
                             Text(
                                 text = "Swipe right to go back",
                                 color = MaterialTheme.colors.onSurface,
                             )
+                            Spacer(modifier = Modifier.fillMaxWidth().height(4.dp))
+                            CompactChip(
+                                onClick = { navController.popBackStack() },
+                                label = { Text("Go Back") },
+                            )
                         }
                     }
                     composable(EDGE_SWIPE_SCREEN) {