Merge "Bump annotation to 1.9.0-beta01" into androidx-main
diff --git a/activity/activity/build.gradle b/activity/activity/build.gradle
index 5257fc3..99a5da5 100644
--- a/activity/activity/build.gradle
+++ b/activity/activity/build.gradle
@@ -28,7 +28,7 @@
     api("androidx.lifecycle:lifecycle-viewmodel:2.6.1")
     api("androidx.savedstate:savedstate:1.2.1")
     api("androidx.lifecycle:lifecycle-viewmodel-savedstate:2.6.1")
-    implementation("androidx.profileinstaller:profileinstaller:1.4.0-rc01")
+    implementation("androidx.profileinstaller:profileinstaller:1.4.0")
     implementation("androidx.tracing:tracing:1.0.0")
     implementation(libs.kotlinCoroutinesCore)
     api(libs.kotlinStdlib)
diff --git a/appcompat/appcompat/build.gradle b/appcompat/appcompat/build.gradle
index f4fc9020..855505c 100644
--- a/appcompat/appcompat/build.gradle
+++ b/appcompat/appcompat/build.gradle
@@ -32,7 +32,7 @@
     api("androidx.drawerlayout:drawerlayout:1.0.0")
     implementation("androidx.lifecycle:lifecycle-runtime:2.6.1")
     implementation("androidx.lifecycle:lifecycle-viewmodel:2.6.1")
-    implementation("androidx.profileinstaller:profileinstaller:1.3.1")
+    implementation("androidx.profileinstaller:profileinstaller:1.4.0")
     implementation("androidx.resourceinspection:resourceinspection-annotation:1.0.1")
     api("androidx.savedstate:savedstate:1.2.1")
 
diff --git a/appsearch/appsearch/api/current.txt b/appsearch/appsearch/api/current.txt
index 23f9054..afc07be 100644
--- a/appsearch/appsearch/api/current.txt
+++ b/appsearch/appsearch/api/current.txt
@@ -851,6 +851,17 @@
     method public default java.util.List<androidx.appsearch.ast.Node!> getChildren();
   }
 
+  @SuppressCompatibility @androidx.appsearch.app.ExperimentalAppSearchApi public final class TextNode implements androidx.appsearch.ast.Node {
+    ctor public TextNode(androidx.appsearch.ast.TextNode);
+    ctor public TextNode(String);
+    method public String getValue();
+    method public boolean isPrefix();
+    method public boolean isVerbatim();
+    method public void setPrefix(boolean);
+    method public void setValue(String);
+    method public void setVerbatim(boolean);
+  }
+
 }
 
 package androidx.appsearch.exceptions {
diff --git a/appsearch/appsearch/api/restricted_current.txt b/appsearch/appsearch/api/restricted_current.txt
index 23f9054..afc07be 100644
--- a/appsearch/appsearch/api/restricted_current.txt
+++ b/appsearch/appsearch/api/restricted_current.txt
@@ -851,6 +851,17 @@
     method public default java.util.List<androidx.appsearch.ast.Node!> getChildren();
   }
 
+  @SuppressCompatibility @androidx.appsearch.app.ExperimentalAppSearchApi public final class TextNode implements androidx.appsearch.ast.Node {
+    ctor public TextNode(androidx.appsearch.ast.TextNode);
+    ctor public TextNode(String);
+    method public String getValue();
+    method public boolean isPrefix();
+    method public boolean isVerbatim();
+    method public void setPrefix(boolean);
+    method public void setValue(String);
+    method public void setVerbatim(boolean);
+  }
+
 }
 
 package androidx.appsearch.exceptions {
diff --git a/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/app/SearchSpecCtsTest.java b/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/app/SearchSpecCtsTest.java
index 5b5ac39..8dd3688 100644
--- a/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/app/SearchSpecCtsTest.java
+++ b/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/app/SearchSpecCtsTest.java
@@ -752,6 +752,8 @@
                 .setOrder(SearchSpec.ORDER_ASCENDING)
                 .setRankingStrategy("this.documentScore()")
                 .addInformationalRankingExpressions("this.relevanceScore()")
+                .addInformationalRankingExpressions(
+                        ImmutableSet.of("this.documentScore() * this.relevanceScore()", "1 + 1"))
                 .build();
         assertThat(searchSpec.getOrder()).isEqualTo(SearchSpec.ORDER_ASCENDING);
         assertThat(searchSpec.getRankingStrategy())
@@ -759,7 +761,8 @@
         assertThat(searchSpec.getAdvancedRankingExpression())
                 .isEqualTo("this.documentScore()");
         assertThat(searchSpec.getInformationalRankingExpressions()).containsExactly(
-                "this.relevanceScore()");
+                "this.relevanceScore()",
+                "this.documentScore() * this.relevanceScore()", "1 + 1").inOrder();
     }
 
     @Test
@@ -772,6 +775,8 @@
         SearchSpec original = searchSpecBuilder.build();
         SearchSpec rebuild = searchSpecBuilder
                 .addInformationalRankingExpressions("this.documentScore()")
+                .addInformationalRankingExpressions(
+                        ImmutableSet.of("this.documentScore() * this.relevanceScore()", "1 + 1"))
                 .build();
 
         // Rebuild won't effect the original object
@@ -779,7 +784,8 @@
                 .containsExactly("this.relevanceScore()");
 
         assertThat(rebuild.getInformationalRankingExpressions())
-                .containsExactly("this.relevanceScore()", "this.documentScore()").inOrder();
+                .containsExactly("this.relevanceScore()", "this.documentScore()",
+                        "this.documentScore() * this.relevanceScore()", "1 + 1").inOrder();
     }
 
     @Test
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
new file mode 100644
index 0000000..fce2056
--- /dev/null
+++ b/appsearch/appsearch/src/androidTest/java/androidx/appsearch/cts/ast/TextNodeCtsTest.java
@@ -0,0 +1,86 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.appsearch.cts.ast;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.junit.Assert.assertThrows;
+
+import androidx.appsearch.ast.TextNode;
+
+import org.junit.Test;
+
+public class TextNodeCtsTest {
+    @Test
+    public void testConstructor_prefixVerbatimFalseByDefault() {
+        TextNode defaultTextNode = new TextNode("foo");
+
+        assertThat(defaultTextNode.isPrefix()).isFalse();
+        assertThat(defaultTextNode.isVerbatim()).isFalse();
+    }
+
+    @Test
+    public void testCopyConstructor_fieldsCorrectlyCopied() {
+        TextNode fooNode = new TextNode("foo");
+        fooNode.setPrefix(false);
+        fooNode.setVerbatim(true);
+
+        TextNode copyConstructedFooNode =  new TextNode(fooNode);
+
+        assertThat(fooNode.getValue()).isEqualTo(copyConstructedFooNode.getValue());
+        assertThat(fooNode.isPrefix()).isEqualTo(copyConstructedFooNode.isPrefix());
+        assertThat(fooNode.isVerbatim()).isEqualTo(copyConstructedFooNode.isVerbatim());
+    }
+
+    @Test
+    public void testCopyConstructor_originalUnchanged() {
+        TextNode fooNode = new TextNode("foo");
+        fooNode.setPrefix(true);
+        fooNode.setVerbatim(true);
+        TextNode barNode = new TextNode(fooNode);
+        barNode.setValue("bar");
+        barNode.setPrefix(false);
+
+        // Check original is unchanged.
+        assertThat(fooNode.getValue()).isEqualTo("foo");
+        assertThat(fooNode.isPrefix()).isTrue();
+        assertThat(fooNode.isVerbatim()).isTrue();
+        // Check that the fields were modified.
+        assertThat(barNode.getValue()).isEqualTo("bar");
+        assertThat(barNode.isPrefix()).isFalse();
+        // Check that fields that weren't set are unmodified.
+        assertThat(barNode.isVerbatim()).isTrue();
+    }
+
+    @Test
+    public void testGetChildren_alwaysReturnEmptyList() {
+        TextNode fooNode = new TextNode("foo");
+        assertThat(fooNode.getChildren().isEmpty()).isTrue();
+    }
+
+    @Test
+    public void testConstructor_throwsIfStringNull() {
+        String nullString = null;
+        assertThrows(NullPointerException.class, () -> new TextNode(nullString));
+    }
+
+    @Test
+    public void testCopyConstructor_throwsIfStringNodeNull() {
+        TextNode nullTextNode = null;
+        assertThrows(NullPointerException.class, () -> new TextNode(nullTextNode));
+    }
+}
diff --git a/appsearch/appsearch/src/main/java/androidx/appsearch/ast/TextNode.java b/appsearch/appsearch/src/main/java/androidx/appsearch/ast/TextNode.java
new file mode 100644
index 0000000..80f9ab4
--- /dev/null
+++ b/appsearch/appsearch/src/main/java/androidx/appsearch/ast/TextNode.java
@@ -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.appsearch.ast;
+
+import androidx.annotation.NonNull;
+import androidx.appsearch.app.ExperimentalAppSearchApi;
+import androidx.appsearch.flags.FlaggedApi;
+import androidx.appsearch.flags.Flags;
+import androidx.core.util.Preconditions;
+
+/**
+ * {@link Node} that stores text.
+ *
+ * <p>Text may represent a string or number.
+ * For example in the query `hello AND "world peace" -cat price:49.99`
+ * <ul>
+ *     <li> hello and cat are strings.
+ *     <li> "world peace" is a verbatim string, i.e. a quoted string that can be represented by
+ *     setting mVerbatim to true. Because it is a verbatim string, it will be treated as a
+ *     single term "world peace" instead of terms "world" and "peace".
+ *     <li> 49.99 is a number. {@link TextNode}s may represent integers or doubles and treat numbers
+ *     as terms.
+ *     <li> price is NOT a string but a property path as part of a {@link PropertyRestrictNode}.
+ * </ul>
+ *
+ * <p>The node will be segmented and normalized based on the flags set in the Node.
+ * For example, if the node containing the string "foo" has both mPrefix and mVerbatim set to true,
+ * then the resulting tree will be treated as the query `"foo"*`
+ * i.e. the prefix of the quoted string "foo".
+ *
+ * <p>{@link TextNode}s is guaranteed to not have child nodes.
+ *
+ * <p>This API may change in response to feedback and additional changes.
+ */
+@ExperimentalAppSearchApi
+@FlaggedApi(Flags.FLAG_ENABLE_ABSTRACT_SYNTAX_TREES)
+public final class TextNode implements Node{
+    private String mValue;
+    private boolean mPrefix = false;
+    private boolean mVerbatim = false;
+
+    /**
+     * Public constructor for {@link TextNode} representing text passed into the constructor as a
+     * string.
+     *
+     * <p>By default {@link #mPrefix}  and {@link #mVerbatim} are both false. In other words the
+     * {@link TextNode} represents a term that is not the prefix of a potentially longer term that
+     * could be matched against and not a quoted string to be treated as a single term.
+     *
+     * @param value The text value that {@link TextNode} holds.
+     */
+    public TextNode(@NonNull String value) {
+        mValue = Preconditions.checkNotNull(value);
+    }
+
+    /**
+     * Copy constructor that takes in {@link TextNode}.
+     *
+     * @param original The {@link TextNode} to copy and return another {@link TextNode}.
+     */
+    public TextNode(@NonNull TextNode original) {
+        Preconditions.checkNotNull(original);
+        mValue = original.mValue;
+        mPrefix = original.mPrefix;
+        mVerbatim = original.mVerbatim;
+    }
+
+    /**
+     * Retrieve the string value that the TextNode holds.
+     *
+     * @return A string representing the text that the TextNode holds.
+     */
+    @NonNull
+    public String getValue() {
+        return mValue;
+    }
+
+    /**
+     * Whether or not a TextNode represents a query term that will match indexed tokens when the
+     * query term is a prefix of the token.
+     *
+     * <p>For example, if the value of the TextNode is "foo" and mPrefix is set to true, then the
+     * TextNode represents the query `foo*`, and will match against tokens like "foo", "foot", and
+     * "football".
+     *
+     * <p>If mPrefix and mVerbatim are both true, then the TextNode represents the prefix of the
+     * quoted string. For example if the value of the TextNode is "foo bar" and both mPrefix and
+     * mVerbatim are set to true, then the TextNode represents the query `"foo bar"*`.
+     *
+     * @return True, if the TextNode represents a query term that will match indexed tokens when the
+     * query term is a prefix of the token.
+     *
+     * <p> False, if the TextNode represents a query term that will only match exact tokens in the
+     * index.
+     */
+    public boolean isPrefix() {
+        return mPrefix;
+    }
+
+    /**
+     * Whether or not a TextNode represents a quoted string.
+     *
+     * <p>For example, if the value of the TextNode is "foo bar" and mVerbatim is set to true, then
+     * the TextNode represents the query `"foo bar"`. "foo bar" will be treated as a single token
+     * and match documents that have a property marked as verbatim and exactly contain
+     * "foo bar".
+     *
+     * <p>If mVerbatim and mPrefix are both true, then the TextNode represents the prefix of the
+     * quoted string. For example if the value of the TextNode is "foo bar" and both mPrefix and
+     * mVerbatim are set to true, then the TextNode represents the query `"foo bar"*`.
+     *
+     * @return True, if the TextNode represents a quoted string. For example, if the value of
+     * TextNode is "foo bar", then the query represented is `"foo bar"`. This means "foo bar" will
+     * be treated as one term, matching documents that have a property marked as verbatim and
+     * contains exactly "foo bar".
+     *
+     * <p> False, if the TextNode does not represent a quoted string. For example, if the value of
+     * TextNode is "foo bar", then the query represented is `foo bar`. This means that "foo" and
+     * "bar" will be treated as separate terms instead of one term and implicitly ANDed, matching
+     * documents that contain both "foo" and "bar".
+     */
+    public boolean isVerbatim() {
+        return mVerbatim;
+    }
+
+    /**
+     * Set the text value that the {@link TextNode} holds.
+     *
+     * @param value The string that the {@link TextNode} will hold.
+     */
+    public void setValue(@NonNull String value) {
+        mValue = Preconditions.checkNotNull(value);
+    }
+
+    /**
+     * Set whether or not the {@link TextNode} represents a prefix. If true, the {@link TextNode}
+     * represents a prefix match for {@code value}.
+     *
+     * @param isPrefix Whether or not the {@link TextNode} represents a prefix. If true, it
+     *                 represents a query term that will match against indexed tokens when the query
+     *                 term is a prefix of token.
+     */
+    public void setPrefix(boolean isPrefix) {
+        mPrefix = isPrefix;
+    }
+
+    /**
+     * Set whether or not the {@link TextNode} represents a quoted string, i.e. verbatim. If true,
+     * the {@link TextNode} represents a quoted string.
+     *
+     * @param isVerbatim Whether or not the {@link TextNode} represents a quoted string. If true, it
+     *     represents a quoted string.
+     */
+    public void setVerbatim(boolean isVerbatim) {
+        mVerbatim = isVerbatim;
+    }
+}
diff --git a/appsearch/exportToFramework.py b/appsearch/exportToFramework.py
index ea807db..f898b04 100755
--- a/appsearch/exportToFramework.py
+++ b/appsearch/exportToFramework.py
@@ -181,7 +181,7 @@
                     'java.util.Objects')
             .replace(
                 'import androidx.core.os.ParcelCompat',
-                'import android.core.os.Parcel')
+                'import android.os.Parcel')
             # Preconditions.checkNotNull is replaced with Objects.requireNonNull. We add both
             # imports and let google-java-format sort out which one is unused.
             .replace(
@@ -199,8 +199,9 @@
         contents = re.sub(r'\/\/ @exportToFramework:copyToPath\([^)]+\)', '', contents)
         contents = re.sub(r'@RequiresFeature\([^)]*\)', '', contents, flags=re.DOTALL)
 
-        contents = re.sub(r'ParcelCompat\.readParcelable\(.*?([a-zA-Z]+), ', r'\1.readParcelable(',
-                          contents, flags=re.DOTALL)
+        contents = re.sub(
+                r'ParcelCompat\.readParcelable\(.*?([a-zA-Z.()]+),.*?([a-zA-Z.()]+),.*?([a-zA-Z.()]+)\)',
+                r'\1.readParcelable(\2)', contents, flags=re.DOTALL)
 
         # Jetpack methods have the Async suffix, but framework doesn't. Strip the Async suffix
         # to allow the same documentation to compile for both.
diff --git a/benchmark/baseline-profile-gradle-plugin/lint-baseline.xml b/benchmark/baseline-profile-gradle-plugin/lint-baseline.xml
index d8fe4cd..388661e 100644
--- a/benchmark/baseline-profile-gradle-plugin/lint-baseline.xml
+++ b/benchmark/baseline-profile-gradle-plugin/lint-baseline.xml
@@ -2,15 +2,6 @@
 <issues format="6" by="lint 8.7.0-alpha02" type="baseline" client="gradle" dependencies="false" name="AGP (8.7.0-alpha02)" variant="all" version="8.7.0-alpha02">
 
     <issue
-        id="GradleProjectIsolation"
-        message="Use providers.gradleProperty instead of getProperties"
-        errorLine1="                    project.properties.filterKeys { k ->"
-        errorLine2="                            ~~~~~~~~~~">
-        <location
-            file="src/main/kotlin/androidx/baselineprofile/gradle/producer/tasks/CollectBaselineProfileTask.kt"/>
-    </issue>
-
-    <issue
         id="InternalAgpApiUsage"
         message="Avoid using internal Android Gradle Plugin APIs"
         errorLine1="import com.android.build.gradle.internal.api.DefaultAndroidSourceDirectorySet"
diff --git a/benchmark/baseline-profile-gradle-plugin/src/main/kotlin/androidx/baselineprofile/gradle/apptarget/BaselineProfileAppTargetPlugin.kt b/benchmark/baseline-profile-gradle-plugin/src/main/kotlin/androidx/baselineprofile/gradle/apptarget/BaselineProfileAppTargetPlugin.kt
index c71c8b3..7b4aecf 100644
--- a/benchmark/baseline-profile-gradle-plugin/src/main/kotlin/androidx/baselineprofile/gradle/apptarget/BaselineProfileAppTargetPlugin.kt
+++ b/benchmark/baseline-profile-gradle-plugin/src/main/kotlin/androidx/baselineprofile/gradle/apptarget/BaselineProfileAppTargetPlugin.kt
@@ -231,7 +231,11 @@
             newBuildTypePrefix = BUILD_TYPE_BASELINE_PROFILE_PREFIX,
             filterBlock = {
                 // Create baseline profile build types only for non debuggable builds.
-                !it.isDebuggable
+                // Note that it's possible to override benchmarkRelease and nonMinifiedRelease,
+                // so we also want to make sure we don't extended these again.
+                !it.isDebuggable &&
+                    !it.name.startsWith(BUILD_TYPE_BASELINE_PROFILE_PREFIX) &&
+                    !it.name.startsWith(BUILD_TYPE_BENCHMARK_PREFIX)
             },
             newConfigureBlock = { base, ext ->
 
@@ -278,7 +282,11 @@
             newBuildTypePrefix = BUILD_TYPE_BASELINE_PROFILE_PREFIX,
             filterBlock = {
                 // Create baseline profile build types only for non debuggable builds.
-                !it.isDebuggable
+                // Note that it's possible to override benchmarkRelease and nonMinifiedRelease,
+                // so we also want to make sure we don't extended these again.
+                !it.isDebuggable &&
+                    !it.name.startsWith(BUILD_TYPE_BASELINE_PROFILE_PREFIX) &&
+                    !it.name.startsWith(BUILD_TYPE_BENCHMARK_PREFIX)
             },
             newConfigureBlock = { base, ext ->
 
@@ -330,8 +338,13 @@
             extendedBuildTypeToOriginalBuildTypeMapping = benchmarkExtendedToOriginalTypeMap,
             filterBlock = {
                 // Create benchmark type for non debuggable types, and without considering
-                // baseline profiles build types.
-                !it.isDebuggable && it.name !in baselineProfileExtendedToOriginalTypeMap
+                // baseline profiles build types. Note that it's possible to override
+                // benchmarkRelease and nonMinifiedRelease, so we also want to make sure we don't
+                // extended these again.
+                !it.isDebuggable &&
+                    it.name !in baselineProfileExtendedToOriginalTypeMap &&
+                    !it.name.startsWith(BUILD_TYPE_BASELINE_PROFILE_PREFIX) &&
+                    !it.name.startsWith(BUILD_TYPE_BENCHMARK_PREFIX)
             },
             newConfigureBlock = { base, ext ->
 
diff --git a/benchmark/baseline-profile-gradle-plugin/src/main/kotlin/androidx/baselineprofile/gradle/producer/tasks/CollectBaselineProfileTask.kt b/benchmark/baseline-profile-gradle-plugin/src/main/kotlin/androidx/baselineprofile/gradle/producer/tasks/CollectBaselineProfileTask.kt
index dd6adb1..27615f97 100644
--- a/benchmark/baseline-profile-gradle-plugin/src/main/kotlin/androidx/baselineprofile/gradle/producer/tasks/CollectBaselineProfileTask.kt
+++ b/benchmark/baseline-profile-gradle-plugin/src/main/kotlin/androidx/baselineprofile/gradle/producer/tasks/CollectBaselineProfileTask.kt
@@ -84,9 +84,9 @@
 
                 // Sets the project testInstrumentationRunnerArguments
                 it.testInstrumentationRunnerArguments.set(
-                    project.properties.filterKeys { k ->
-                        k.startsWith(PROP_KEY_PREFIX_INSTRUMENTATION_RUNNER_ARG)
-                    }
+                    project.providers.gradlePropertiesPrefixedBy(
+                        PROP_KEY_PREFIX_INSTRUMENTATION_RUNNER_ARG
+                    )
                 )
 
                 // Disables the task if requested
diff --git a/benchmark/baseline-profile-gradle-plugin/src/main/kotlin/androidx/baselineprofile/gradle/utils/AgpPlugin.kt b/benchmark/baseline-profile-gradle-plugin/src/main/kotlin/androidx/baselineprofile/gradle/utils/AgpPlugin.kt
index 29f7c75..8638ad0 100644
--- a/benchmark/baseline-profile-gradle-plugin/src/main/kotlin/androidx/baselineprofile/gradle/utils/AgpPlugin.kt
+++ b/benchmark/baseline-profile-gradle-plugin/src/main/kotlin/androidx/baselineprofile/gradle/utils/AgpPlugin.kt
@@ -58,6 +58,10 @@
         } ?: return@lazy null
     }
 
+    val suppressWarnings: Boolean by lazy {
+        project.providers.gradleProperty("androidx.baselineprofile.suppresswarnings").isPresent
+    }
+
     // Logger
     protected val logger = BaselineProfilePluginLogger(project.logger)
 
@@ -100,6 +104,14 @@
 
     private fun configureWithAndroidPlugin() {
 
+        fun setWarnings() {
+            if (suppressWarnings) {
+                logger.suppressAllWarnings()
+            } else {
+                getWarnings()?.let { warnings -> logger.setWarnings(warnings) }
+            }
+        }
+
         onBeforeFinalizeDsl()
 
         testAndroidComponentExtension()?.let { testComponent ->
@@ -108,7 +120,7 @@
 
                 // This can be done only here, since warnings may depend on user configuration
                 // that is ready only after `finalizeDsl`.
-                getWarnings()?.let { warnings -> logger.setWarnings(warnings) }
+                setWarnings()
                 checkAgpVersion()
             }
             testComponent.beforeVariants { onTestBeforeVariants(it) }
@@ -124,7 +136,7 @@
 
                 // This can be done only here, since warnings may depend on user configuration
                 // that is ready only after `finalizeDsl`.
-                getWarnings()?.let { warnings -> logger.setWarnings(warnings) }
+                setWarnings()
                 checkAgpVersion()
             }
             applicationComponent.beforeVariants { onApplicationBeforeVariants(it) }
@@ -140,7 +152,7 @@
 
                 // This can be done only here, since warnings may depend on user configuration
                 // that is ready only after `finalizeDsl`.
-                getWarnings()?.let { warnings -> logger.setWarnings(warnings) }
+                setWarnings()
                 checkAgpVersion()
             }
             libraryComponent.beforeVariants { onLibraryBeforeVariants(it) }
diff --git a/benchmark/baseline-profile-gradle-plugin/src/main/kotlin/androidx/baselineprofile/gradle/utils/BaselineProfilePluginLogger.kt b/benchmark/baseline-profile-gradle-plugin/src/main/kotlin/androidx/baselineprofile/gradle/utils/BaselineProfilePluginLogger.kt
index f41bbb1..daebf6e 100644
--- a/benchmark/baseline-profile-gradle-plugin/src/main/kotlin/androidx/baselineprofile/gradle/utils/BaselineProfilePluginLogger.kt
+++ b/benchmark/baseline-profile-gradle-plugin/src/main/kotlin/androidx/baselineprofile/gradle/utils/BaselineProfilePluginLogger.kt
@@ -31,21 +31,28 @@
             maxAgpVersion = false
         }
 
+    private var suppressAllWarnings: Boolean = false
+
     fun setWarnings(warnings: Warnings) {
         this.warnings = warnings
     }
 
+    fun suppressAllWarnings() {
+        suppressAllWarnings = true
+    }
+
     fun debug(message: String) = logger.debug(message)
 
     fun info(message: String) = logger.info(message)
 
     fun warn(property: Warnings.() -> (Boolean), propertyName: String?, message: String) {
+        if (suppressAllWarnings) return
         if (property(warnings)) {
             logger.warn(message)
             if (propertyName != null) {
                 logger.warn(
                     """
-                
+
                 This warning can be disabled setting the following property:
                 baselineProfile {
                     warnings {
diff --git a/benchmark/baseline-profile-gradle-plugin/src/test/kotlin/androidx/baselineprofile/gradle/apptarget/BaselineProfileAppTargetPluginTest.kt b/benchmark/baseline-profile-gradle-plugin/src/test/kotlin/androidx/baselineprofile/gradle/apptarget/BaselineProfileAppTargetPluginTest.kt
index f039ec8..eaf3624 100644
--- a/benchmark/baseline-profile-gradle-plugin/src/test/kotlin/androidx/baselineprofile/gradle/apptarget/BaselineProfileAppTargetPluginTest.kt
+++ b/benchmark/baseline-profile-gradle-plugin/src/test/kotlin/androidx/baselineprofile/gradle/apptarget/BaselineProfileAppTargetPluginTest.kt
@@ -22,6 +22,7 @@
 import androidx.baselineprofile.gradle.utils.TestAgpVersion.TEST_AGP_VERSION_8_1_0
 import androidx.baselineprofile.gradle.utils.build
 import androidx.baselineprofile.gradle.utils.buildAndAssertThatOutput
+import androidx.baselineprofile.gradle.utils.containsOnly
 import com.google.common.truth.Truth.assertThat
 import java.io.File
 import org.junit.Rule
@@ -44,7 +45,10 @@
     """
         .trimIndent()
 
-private fun createBuildGradle(agpVersion: TestAgpVersion) =
+private fun createBuildGradle(
+    agpVersion: TestAgpVersion,
+    overrideExtendedBuildTypesForRelease: Boolean = false
+) =
     """
     import static com.android.build.gradle.internal.ProguardFileType.EXPLICIT;
 
@@ -56,6 +60,21 @@
     android {
         namespace 'com.example.namespace'
         buildTypes {
+
+            ${
+        if (overrideExtendedBuildTypesForRelease) """
+
+            benchmarkRelease {
+                initWith(release)
+                profileable true
+            }
+            nonMinifiedRelease {
+                initWith(release)
+            }
+   
+            """.trimIndent() else ""
+    }
+
             anotherRelease {
                 initWith(release)
                 minifyEnabled true
@@ -87,8 +106,15 @@
         }
     }
 
+    def printVariantsTaskProvider = tasks.register("printVariants", PrintTask) { t ->
+        t.text.set("")
+    }
+
     androidComponents {
         onVariants(selector()) { variant ->
+            printVariantsTaskProvider.configure { t ->
+                t.text.set(t.text.get() + "\n" + "print-variant:" + variant.name)
+            }
             tasks.register(variant.name + "BuildProperties", PrintTask) { t ->
                 def buildType = android.buildTypes[variant.buildType]
                 def text = "minifyEnabled=" + buildType.minifyEnabled.toString() + "\n"
@@ -110,76 +136,6 @@
     """
         .trimIndent()
 
-@RunWith(Parameterized::class)
-class BaselineProfileAppTargetPluginTest(agpVersion: TestAgpVersion) {
-
-    @get:Rule
-    val projectSetup = BaselineProfileProjectSetupRule(forceAgpVersion = agpVersion.versionString)
-
-    private val buildGradle = createBuildGradle(agpVersion)
-
-    companion object {
-        @Parameterized.Parameters(name = "agpVersion={0}")
-        @JvmStatic
-        fun parameters() = TestAgpVersion.values()
-    }
-
-    @Test
-    fun testSrcSetAreAddedToVariantsForApplications() {
-        projectSetup.appTarget.setBuildGradle(buildGradle)
-
-        data class TaskAndExpected(val taskName: String, val expectedDirs: List<String>)
-
-        arrayOf(
-                TaskAndExpected(
-                    taskName = "nonMinifiedAnotherReleaseJavaSources",
-                    expectedDirs =
-                        listOf(
-                            "src/main/java",
-                            "src/anotherRelease/java",
-                            "src/nonMinifiedAnotherRelease/java",
-                        )
-                ),
-                TaskAndExpected(
-                    taskName = "nonMinifiedReleaseJavaSources",
-                    expectedDirs =
-                        listOf(
-                            "src/main/java",
-                            "src/release/java",
-                            "src/nonMinifiedRelease/java",
-                        )
-                ),
-                TaskAndExpected(
-                    taskName = "nonMinifiedAnotherReleaseKotlinSources",
-                    expectedDirs =
-                        listOf(
-                            "src/main/kotlin",
-                            "src/anotherRelease/kotlin",
-                            "src/nonMinifiedAnotherRelease/kotlin",
-                        )
-                ),
-                TaskAndExpected(
-                    taskName = "nonMinifiedReleaseKotlinSources",
-                    expectedDirs =
-                        listOf(
-                            "src/main/kotlin",
-                            "src/release/kotlin",
-                            "src/nonMinifiedRelease/kotlin",
-                        )
-                )
-            )
-            .forEach { t ->
-
-                // Runs the task and assert
-                projectSetup.appTarget.gradleRunner.buildAndAssertThatOutput(t.taskName) {
-                    t.expectedDirs
-                        .map { File(projectSetup.appTarget.rootDir, it) }
-                        .forEach { e -> contains(e.absolutePath) }
-                }
-            }
-    }
-}
-
 @RunWith(JUnit4::class)
 class BaselineProfileAppTargetPluginTestWithAgp80 {
 
@@ -230,10 +186,25 @@
             assertThat(logLine).isNotNull()
         }
     }
+
+    @Test
+    fun verifyUnitTestDisabled() {
+        projectSetup.appTarget.setBuildGradle(buildGradle)
+        projectSetup.appTarget.gradleRunner.buildAndAssertThatOutput("test", "--dry-run") {
+            contains(":testDebugUnitTest ")
+            contains(":testReleaseUnitTest ")
+            contains(":testAnotherReleaseUnitTest ")
+            doesNotContain(":testNonMinifiedReleaseUnitTest ")
+            doesNotContain(":testNonMinifiedAnotherReleaseUnitTest ")
+            doesNotContain(":testBenchmarkAnotherReleaseUnitTest ")
+        }
+    }
 }
 
 @RunWith(Parameterized::class)
-class BaselineProfileAppTargetPluginTestWithAgp81AndAbove(agpVersion: TestAgpVersion) {
+class BaselineProfileAppTargetPluginTestWithAgp81AndAbove(
+    private val agpVersion: TestAgpVersion,
+) {
 
     companion object {
         @Parameterized.Parameters(name = "agpVersion={0}")
@@ -247,6 +218,61 @@
     private val buildGradle = createBuildGradle(agpVersion)
 
     @Test
+    fun additionalBuildTypesShouldNotBeCreatedForExistingNonMinifiedAndBenchmarkBuildTypes() =
+        arrayOf(
+                true,
+                false,
+            )
+            .forEach { overrideExtendedBuildTypesForRelease ->
+                projectSetup.appTarget.setBuildGradle(
+                    buildGradleContent =
+                        createBuildGradle(
+                            agpVersion = agpVersion,
+                            overrideExtendedBuildTypesForRelease =
+                                overrideExtendedBuildTypesForRelease,
+                        )
+                )
+                projectSetup.appTarget.gradleRunner.build("printVariants") {
+                    val variants =
+                        it.lines()
+                            .filter { l -> l.startsWith("print-variant:") }
+                            .map { l -> l.substringAfter("print-variant:").trim() }
+                            .toSet()
+                            .toList()
+
+                    assertThat(
+                            variants.containsOnly(
+                                "debug",
+                                "release",
+                                "benchmarkRelease",
+                                "nonMinifiedRelease",
+                                "anotherRelease",
+                                "nonMinifiedAnotherRelease",
+                                "benchmarkAnotherRelease",
+                                "myCustomRelease",
+                                "nonMinifiedMyCustomRelease",
+                                "benchmarkMyCustomRelease",
+                            )
+                        )
+                        .isTrue()
+                }
+            }
+
+    @Test
+    fun verifyUnitTestDisabled() {
+        projectSetup.appTarget.setBuildGradle(buildGradle)
+        projectSetup.appTarget.gradleRunner.buildAndAssertThatOutput("test", "--dry-run") {
+            contains(":testDebugUnitTest ")
+            contains(":testReleaseUnitTest ")
+            contains(":testAnotherReleaseUnitTest ")
+            doesNotContain(":testNonMinifiedReleaseUnitTest ")
+            doesNotContain(":testBenchmarkReleaseUnitTest ")
+            doesNotContain(":testNonMinifiedAnotherReleaseUnitTest ")
+            doesNotContain(":testBenchmarkAnotherReleaseUnitTest ")
+        }
+    }
+
+    @Test
     fun verifyNewBuildTypes() {
         projectSetup.appTarget.setBuildGradle(buildGradle)
 
@@ -399,7 +425,7 @@
 }
 
 @RunWith(Parameterized::class)
-class BaselineProfileAppTargetPluginTestWithAgp80AndAbove(agpVersion: TestAgpVersion) {
+class BaselineProfileAppTargetPluginTestWithAgp80AndAbove(private val agpVersion: TestAgpVersion) {
 
     companion object {
         @Parameterized.Parameters(name = "agpVersion={0}")
@@ -413,16 +439,96 @@
     private val buildGradle = createBuildGradle(agpVersion)
 
     @Test
-    fun verifyUnitTestDisabled() {
+    fun testSrcSetAreAddedToVariantsForApplications() {
         projectSetup.appTarget.setBuildGradle(buildGradle)
-        projectSetup.appTarget.gradleRunner.buildAndAssertThatOutput("test", "--dry-run") {
-            contains(":testDebugUnitTest ")
-            contains(":testReleaseUnitTest ")
-            contains(":testAnotherReleaseUnitTest ")
-            doesNotContain(":testNonMinifiedReleaseUnitTest ")
-            doesNotContain(":testBenchmarkReleaseUnitTest ")
-            doesNotContain(":testNonMinifiedAnotherReleaseUnitTest ")
-            doesNotContain(":testBenchmarkAnotherReleaseUnitTest ")
-        }
+
+        data class TaskAndExpected(val taskName: String, val expectedDirs: List<String>)
+
+        arrayOf(
+                TaskAndExpected(
+                    taskName = "nonMinifiedAnotherReleaseJavaSources",
+                    expectedDirs =
+                        listOf(
+                            "src/main/java",
+                            "src/anotherRelease/java",
+                            "src/nonMinifiedAnotherRelease/java",
+                        )
+                ),
+                TaskAndExpected(
+                    taskName = "nonMinifiedReleaseJavaSources",
+                    expectedDirs =
+                        listOf(
+                            "src/main/java",
+                            "src/release/java",
+                            "src/nonMinifiedRelease/java",
+                        )
+                ),
+                TaskAndExpected(
+                    taskName = "nonMinifiedAnotherReleaseKotlinSources",
+                    expectedDirs =
+                        listOf(
+                            "src/main/kotlin",
+                            "src/anotherRelease/kotlin",
+                            "src/nonMinifiedAnotherRelease/kotlin",
+                        )
+                ),
+                TaskAndExpected(
+                    taskName = "nonMinifiedReleaseKotlinSources",
+                    expectedDirs =
+                        listOf(
+                            "src/main/kotlin",
+                            "src/release/kotlin",
+                            "src/nonMinifiedRelease/kotlin",
+                        )
+                )
+            )
+            .forEach { t ->
+
+                // Runs the task and assert
+                projectSetup.appTarget.gradleRunner.buildAndAssertThatOutput(t.taskName) {
+                    t.expectedDirs
+                        .map { File(projectSetup.appTarget.rootDir, it) }
+                        .forEach { e -> contains(e.absolutePath) }
+                }
+            }
     }
+
+    @Test
+    fun additionalBuildTypesShouldNotBeCreatedForExistingNonMinifiedAndBenchmarkBuildTypes() =
+        arrayOf(
+                true,
+                false,
+            )
+            .forEach { overrideExtendedBuildTypesForRelease ->
+                projectSetup.appTarget.setBuildGradle(
+                    buildGradleContent =
+                        createBuildGradle(
+                            agpVersion = agpVersion,
+                            overrideExtendedBuildTypesForRelease =
+                                overrideExtendedBuildTypesForRelease,
+                        )
+                )
+
+                projectSetup.appTarget.gradleRunner.build("printVariants") {
+                    val variants =
+                        it.lines()
+                            .filter { l -> l.startsWith("print-variant:") }
+                            .map { l -> l.substringAfter("print-variant:").trim() }
+                            .toSet()
+                            .toList()
+
+                    assertThat(
+                            variants.containsOnly(
+                                "debug",
+                                "release",
+                                "nonMinifiedRelease",
+                                "anotherRelease",
+                                "nonMinifiedAnotherRelease",
+                                "myCustomRelease",
+                                "nonMinifiedMyCustomRelease",
+                            )
+                        )
+                        .isTrue()
+                }
+            }
 }
diff --git a/benchmark/baseline-profile-gradle-plugin/src/test/kotlin/androidx/baselineprofile/gradle/consumer/BaselineProfileConsumerPluginTest.kt b/benchmark/baseline-profile-gradle-plugin/src/test/kotlin/androidx/baselineprofile/gradle/consumer/BaselineProfileConsumerPluginTest.kt
index e4f5538..736c8a4 100644
--- a/benchmark/baseline-profile-gradle-plugin/src/test/kotlin/androidx/baselineprofile/gradle/consumer/BaselineProfileConsumerPluginTest.kt
+++ b/benchmark/baseline-profile-gradle-plugin/src/test/kotlin/androidx/baselineprofile/gradle/consumer/BaselineProfileConsumerPluginTest.kt
@@ -1501,6 +1501,46 @@
     }
 
     @Test
+    fun testSuppressWarningWithProperty() {
+        val requiredLines =
+            listOf(
+                "This version of the Baseline Profile Gradle Plugin was tested with versions below",
+                // We skip the lines in between because they may contain changing version numbers.
+                "baselineProfile {",
+                "    warnings {",
+                "        maxAgpVersion = false",
+                "    }",
+                "}"
+            )
+
+        projectSetup.consumer.setup(androidPlugin = ANDROID_APPLICATION_PLUGIN)
+        projectSetup.producer.setupWithoutFlavors(
+            releaseProfileLines = listOf(Fixtures.CLASS_1_METHOD_1, Fixtures.CLASS_1),
+        )
+
+        val gradleCmds =
+            arrayOf(
+                "generateBaselineProfile",
+                "-Pandroidx.benchmark.test.maxagpversion=1.0.0",
+            )
+
+        // Run with no suppress warnings property
+        projectSetup.consumer.gradleRunner.build(*gradleCmds) {
+            val notFound = it.lines().requireInOrder(*requiredLines.toTypedArray())
+            assertThat(notFound).isEmpty()
+        }
+
+        // Run with suppress warnings property
+        projectSetup.consumer.gradleRunner.build(
+            *gradleCmds,
+            "-Pandroidx.baselineprofile.suppresswarnings"
+        ) {
+            val notFound = it.lines().requireInOrder(*requiredLines.toTypedArray())
+            assertThat(notFound).isEqualTo(requiredLines)
+        }
+    }
+
+    @Test
     fun testMergeArtAndStartupProfilesShouldDependOnProfileGeneration() {
         projectSetup.producer.setupWithFreeAndPaidFlavors(
             freeReleaseProfileLines = listOf(Fixtures.CLASS_1_METHOD_1, Fixtures.CLASS_1),
diff --git a/benchmark/baseline-profile-gradle-plugin/src/test/kotlin/androidx/baselineprofile/gradle/utils/TestUtils.kt b/benchmark/baseline-profile-gradle-plugin/src/test/kotlin/androidx/baselineprofile/gradle/utils/TestUtils.kt
index 5392918..ee1a2a1 100644
--- a/benchmark/baseline-profile-gradle-plugin/src/test/kotlin/androidx/baselineprofile/gradle/utils/TestUtils.kt
+++ b/benchmark/baseline-profile-gradle-plugin/src/test/kotlin/androidx/baselineprofile/gradle/utils/TestUtils.kt
@@ -95,6 +95,9 @@
     return remaining
 }
 
+internal fun List<String>.containsOnly(vararg strings: String): Boolean =
+    toSet().union(setOf(*strings)).size == this.size
+
 fun camelCase(vararg strings: String): String {
     if (strings.isEmpty()) return ""
     return StringBuilder()
diff --git a/benchmark/benchmark-macro/build.gradle b/benchmark/benchmark-macro/build.gradle
index 3ffb560..b3181f7 100644
--- a/benchmark/benchmark-macro/build.gradle
+++ b/benchmark/benchmark-macro/build.gradle
@@ -69,7 +69,7 @@
     api("androidx.annotation:annotation:1.8.1")
 
     implementation("androidx.core:core:1.9.0")
-    implementation("androidx.profileinstaller:profileinstaller:1.3.1")
+    implementation("androidx.profileinstaller:profileinstaller:1.4.0")
     implementation("androidx.tracing:tracing-ktx:1.1.0")
     implementation("androidx.tracing:tracing-perfetto:1.0.0")
     implementation("androidx.tracing:tracing-perfetto-binary:1.0.0")
diff --git a/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/ProfileInstallBroadcast.kt b/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/ProfileInstallBroadcast.kt
index f98293b..3183dea 100644
--- a/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/ProfileInstallBroadcast.kt
+++ b/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/ProfileInstallBroadcast.kt
@@ -70,6 +70,9 @@
                             if (sdkInt in 31..33) {
                                 " Please use profileinstaller `1.2.1`" +
                                     " or newer for API 31-33 support"
+                            } else if (sdkInt >= 34) {
+                                " Please use profileinstaller `1.4.0`" +
+                                    " or newer for API 34+ support"
                             } else {
                                 ""
                             }
diff --git a/benchmark/gradle-plugin/src/main/resources/scripts/disableJit.sh b/benchmark/gradle-plugin/src/main/resources/scripts/disableJit.sh
index 858a968..bcf9c44 100755
--- a/benchmark/gradle-plugin/src/main/resources/scripts/disableJit.sh
+++ b/benchmark/gradle-plugin/src/main/resources/scripts/disableJit.sh
@@ -27,8 +27,7 @@
     else
         echo "Could not find adb. Options are:"
         echo "  1. Ensure adb is on your \$PATH"
-        echo "  2. Use './gradlew lockClocks'"
-        echo "  3. Manually adb push this script to your device, and run it there"
+        echo "  2. Manually adb push this script to your device, and run it there"
         exit -1
     fi
 fi
@@ -38,6 +37,7 @@
 # require root
 if [[ `id` != "uid=0"* ]]; then
     echo "Not running as root, cannot disable jit, aborting"
+    echo "Run 'adb root' and retry"
     exit -1
 fi
 
@@ -45,7 +45,19 @@
 stop
 start
 
+## Poll for boot animation to start...
+echo "  Waiting for boot animation to start..."
+while [[ "`getprop init.svc.bootanim`" == "stopped" ]]; do
+  sleep 0.1; # frequent polling for boot anim to start, in case it's fast
+done
+
+## And then complete
+echo "  Waiting for boot animation to stop..."
+while [[ "`getprop init.svc.bootanim`" == "running" ]]; do
+  sleep 0.5;
+done
+
 DEVICE=`getprop ro.product.device`
-echo "JIT compilation has been disabled on $DEVICE!"
+echo "\nJIT compilation has been disabled on $DEVICE!"
 echo "Performance will be terrible for almost everything! (except e.g. AOT benchmarks)"
 echo "To reenable it (strongly recommended after benchmarking!!!), reboot or run resetDevice.sh"
diff --git a/benchmark/gradle-plugin/src/main/resources/scripts/lockClocks.sh b/benchmark/gradle-plugin/src/main/resources/scripts/lockClocks.sh
index 593afb8..fe2169d 100755
--- a/benchmark/gradle-plugin/src/main/resources/scripts/lockClocks.sh
+++ b/benchmark/gradle-plugin/src/main/resources/scripts/lockClocks.sh
@@ -57,6 +57,7 @@
 # require root
 if [[ `id` != "uid=0"* ]]; then
     echo "Not running as root, cannot lock clocks, aborting"
+    echo "Run 'adb root' and retry"
     exit -1
 fi
 
diff --git a/benchmark/gradle-plugin/src/main/resources/scripts/resetDevice.sh b/benchmark/gradle-plugin/src/main/resources/scripts/resetDevice.sh
index 060f075..8d11421 100755
--- a/benchmark/gradle-plugin/src/main/resources/scripts/resetDevice.sh
+++ b/benchmark/gradle-plugin/src/main/resources/scripts/resetDevice.sh
@@ -27,8 +27,7 @@
     else
         echo "Could not find adb. Options are:"
         echo "  1. Ensure adb is on your \$PATH"
-        echo "  2. Use './gradlew lockClocks'"
-        echo "  3. Manually adb push this script to your device, and run it there"
+        echo "  2. Manually adb push this script to your device, and run it there"
         exit -1
     fi
 fi
diff --git a/busytown/androidx_host_tests_docker_2004.sh b/busytown/androidx_host_tests_docker_2004.sh
new file mode 100755
index 0000000..6856127
--- /dev/null
+++ b/busytown/androidx_host_tests_docker_2004.sh
@@ -0,0 +1,8 @@
+#!/bin/bash
+set -e
+
+echo "Starting $0 at $(date)"
+
+cd "$(dirname $0)"
+
+echo "Completing $0 at $(date)"
diff --git a/camera/camera-camera2-pipe-integration/src/androidTest/java/androidx/camera/camera2/pipe/integration/EncoderProfilesProviderAdapterDeviceTest.kt b/camera/camera-camera2-pipe-integration/src/androidTest/java/androidx/camera/camera2/pipe/integration/EncoderProfilesProviderAdapterDeviceTest.kt
index 4424f17..8440729 100644
--- a/camera/camera-camera2-pipe-integration/src/androidTest/java/androidx/camera/camera2/pipe/integration/EncoderProfilesProviderAdapterDeviceTest.kt
+++ b/camera/camera-camera2-pipe-integration/src/androidTest/java/androidx/camera/camera2/pipe/integration/EncoderProfilesProviderAdapterDeviceTest.kt
@@ -150,6 +150,7 @@
         val videoProxy = profilesProxy!!.videoProfiles[0]
         val audioProxy = profilesProxy.audioProfiles[0]
 
+        // Don't check video/audio profile, see cts/CamcorderProfileTest.java
         assertThat(profilesProxy.defaultDurationSeconds).isEqualTo(profiles.defaultDurationSeconds)
         assertThat(profilesProxy.recommendedFileFormat).isEqualTo(profiles.recommendedFileFormat)
         assertThat(videoProxy.codec).isEqualTo(video.codec)
@@ -158,7 +159,6 @@
         assertThat(videoProxy.frameRate).isEqualTo(video.frameRate)
         assertThat(videoProxy.width).isEqualTo(video.width)
         assertThat(videoProxy.height).isEqualTo(video.height)
-        assertThat(videoProxy.profile).isEqualTo(video.profile)
         assertThat(videoProxy.bitDepth).isEqualTo(BIT_DEPTH_8)
         assertThat(videoProxy.chromaSubsampling).isEqualTo(YUV_420)
         assertThat(videoProxy.hdrFormat).isEqualTo(HDR_NONE)
@@ -167,7 +167,6 @@
         assertThat(audioProxy.bitrate).isEqualTo(audio.bitrate)
         assertThat(audioProxy.sampleRate).isEqualTo(audio.sampleRate)
         assertThat(audioProxy.channels).isEqualTo(audio.channels)
-        assertThat(audioProxy.profile).isEqualTo(audio.profile)
     }
 
     @SdkSuppress(minSdkVersion = 33)
@@ -182,6 +181,7 @@
         val videoProxy = profilesProxy!!.videoProfiles[0]
         val audioProxy = profilesProxy.audioProfiles[0]
 
+        // Don't check video/audio profile, see cts/CamcorderProfileTest.java
         assertThat(profilesProxy.defaultDurationSeconds).isEqualTo(profiles.defaultDurationSeconds)
         assertThat(profilesProxy.recommendedFileFormat).isEqualTo(profiles.recommendedFileFormat)
         assertThat(videoProxy.codec).isEqualTo(video.codec)
@@ -190,7 +190,6 @@
         assertThat(videoProxy.frameRate).isEqualTo(video.frameRate)
         assertThat(videoProxy.width).isEqualTo(video.width)
         assertThat(videoProxy.height).isEqualTo(video.height)
-        assertThat(videoProxy.profile).isEqualTo(video.profile)
         assertThat(videoProxy.bitDepth).isEqualTo(video.bitDepth)
         assertThat(videoProxy.chromaSubsampling).isEqualTo(video.chromaSubsampling)
         assertThat(videoProxy.hdrFormat).isEqualTo(video.hdrFormat)
@@ -199,7 +198,6 @@
         assertThat(audioProxy.bitrate).isEqualTo(audio.bitrate)
         assertThat(audioProxy.sampleRate).isEqualTo(audio.sampleRate)
         assertThat(audioProxy.channels).isEqualTo(audio.channels)
-        assertThat(audioProxy.profile).isEqualTo(audio.profile)
     }
 
     @LabTestRule.LabTestOnly
diff --git a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/CameraGraph.kt b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/CameraGraph.kt
index 8c2c1ff..f5da2b9 100644
--- a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/CameraGraph.kt
+++ b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/CameraGraph.kt
@@ -724,6 +724,63 @@
          */
         public suspend fun unlock3APostCapture(cancelAf: Boolean = true): Deferred<Result3A>
     }
+
+    /**
+     * [Parameters] is a Map-like interface that stores the key-value parameter pairs from
+     * [CaptureRequest] and [Metadata] for each [CameraGraph]. Parameter are read/set directly using
+     * get/set methods in this interface.
+     *
+     * During an active [CameraGraph.Session], changes in [Parameters] may not be applied right
+     * away. Instead, the change will be applied after [CameraGraph.Session] closes. When there is
+     * no active [CameraGraph.Session], the change will be applied without having to wait for the
+     * session to close. When applying parameter changes, it will overwrite parameter values that
+     * were configured when building the request, and overwrite [Config.defaultParameters]. It will
+     * not overwrite [Config.requiredParameters].
+     *
+     * Note that [Parameters] only store values that is a result of methods from this interface. The
+     * parameter values that were set from implicit template values, or from building a request
+     * directly will not be reflected here.
+     */
+    public interface Parameters {
+        /** Get the value correspond to the given [CaptureRequest.Key]. */
+        public operator fun <T> get(key: CaptureRequest.Key<T>): T?
+
+        /** Get the value correspond to the given [Metadata.Key]. */
+        public operator fun <T> get(key: Metadata.Key<T>): T?
+
+        /** Store the [CaptureRequest] key value pair in the class. */
+        public operator fun <T> set(key: CaptureRequest.Key<T>, value: T)
+
+        /** Store the [Metadata] key value pair in the class. */
+        public operator fun <T> set(key: Metadata.Key<T>, value: T)
+
+        /**
+         * Store the key value pairs in the class. The key is either [CaptureRequest.Key] or
+         * [Metadata.Key].
+         */
+        public fun setAll(values: Map<*, Any?>)
+
+        /** Clear all [CaptureRequest] and [Metadata] parameters stored in the class. */
+        public fun clear()
+
+        /**
+         * Remove the [CaptureRequest] key value pair associated with the given key. Returns true if
+         * a key was present and removed.
+         */
+        public fun <T> remove(key: CaptureRequest.Key<T>): Boolean
+
+        /**
+         * Remove the [Metadata] key value pair associated with the given key. Returns true if a key
+         * was present and removed.
+         */
+        public fun <T> remove(key: Metadata.Key<T>): Boolean
+
+        /**
+         * Remove all parameters that match the given keys. The key is either [CaptureRequest.Key]
+         * or [Metadata.Key].
+         */
+        public fun removeAll(keys: Set<*>): Boolean
+    }
 }
 
 /**
diff --git a/camera/camera-camera2/src/androidTest/java/androidx/camera/camera2/internal/Camera2EncoderProfilesProviderTest.kt b/camera/camera-camera2/src/androidTest/java/androidx/camera/camera2/internal/Camera2EncoderProfilesProviderTest.kt
index 9ac126f..47c76ed 100644
--- a/camera/camera-camera2/src/androidTest/java/androidx/camera/camera2/internal/Camera2EncoderProfilesProviderTest.kt
+++ b/camera/camera-camera2/src/androidTest/java/androidx/camera/camera2/internal/Camera2EncoderProfilesProviderTest.kt
@@ -135,6 +135,7 @@
         val videoProxy = profilesProxy!!.videoProfiles[0]
         val audioProxy = profilesProxy.audioProfiles[0]
 
+        // Don't check video/audio profile, see cts/CamcorderProfileTest.java
         assertThat(profilesProxy.defaultDurationSeconds).isEqualTo(profiles.defaultDurationSeconds)
         assertThat(profilesProxy.recommendedFileFormat).isEqualTo(profiles.recommendedFileFormat)
         assertThat(videoProxy.codec).isEqualTo(video.codec)
@@ -143,7 +144,6 @@
         assertThat(videoProxy.frameRate).isEqualTo(video.frameRate)
         assertThat(videoProxy.width).isEqualTo(video.width)
         assertThat(videoProxy.height).isEqualTo(video.height)
-        assertThat(videoProxy.profile).isEqualTo(video.profile)
         assertThat(videoProxy.bitDepth).isEqualTo(BIT_DEPTH_8)
         assertThat(videoProxy.chromaSubsampling).isEqualTo(YUV_420)
         assertThat(videoProxy.hdrFormat).isEqualTo(HDR_NONE)
@@ -152,7 +152,6 @@
         assertThat(audioProxy.bitrate).isEqualTo(audio.bitrate)
         assertThat(audioProxy.sampleRate).isEqualTo(audio.sampleRate)
         assertThat(audioProxy.channels).isEqualTo(audio.channels)
-        assertThat(audioProxy.profile).isEqualTo(audio.profile)
     }
 
     @SdkSuppress(minSdkVersion = 33)
@@ -167,6 +166,7 @@
         val videoProxy = profilesProxy!!.videoProfiles[0]
         val audioProxy = profilesProxy.audioProfiles[0]
 
+        // Don't check video/audio profile, see cts/CamcorderProfileTest.java
         assertThat(profilesProxy.defaultDurationSeconds).isEqualTo(profiles.defaultDurationSeconds)
         assertThat(profilesProxy.recommendedFileFormat).isEqualTo(profiles.recommendedFileFormat)
         assertThat(videoProxy.codec).isEqualTo(video.codec)
@@ -175,7 +175,6 @@
         assertThat(videoProxy.frameRate).isEqualTo(video.frameRate)
         assertThat(videoProxy.width).isEqualTo(video.width)
         assertThat(videoProxy.height).isEqualTo(video.height)
-        assertThat(videoProxy.profile).isEqualTo(video.profile)
         assertThat(videoProxy.bitDepth).isEqualTo(video.bitDepth)
         assertThat(videoProxy.chromaSubsampling).isEqualTo(video.chromaSubsampling)
         assertThat(videoProxy.hdrFormat).isEqualTo(video.hdrFormat)
@@ -184,7 +183,6 @@
         assertThat(audioProxy.bitrate).isEqualTo(audio.bitrate)
         assertThat(audioProxy.sampleRate).isEqualTo(audio.sampleRate)
         assertThat(audioProxy.channels).isEqualTo(audio.channels)
-        assertThat(audioProxy.profile).isEqualTo(audio.profile)
     }
 
     @LabTestRule.LabTestOnly
diff --git a/camera/camera-core/src/androidTest/java/androidx/camera/core/imagecapture/ProcessingNodeDeviceTest.kt b/camera/camera-core/src/androidTest/java/androidx/camera/core/imagecapture/ProcessingNodeDeviceTest.kt
index 9a5bc12..260965f 100644
--- a/camera/camera-core/src/androidTest/java/androidx/camera/core/imagecapture/ProcessingNodeDeviceTest.kt
+++ b/camera/camera-core/src/androidTest/java/androidx/camera/core/imagecapture/ProcessingNodeDeviceTest.kt
@@ -143,7 +143,7 @@
 
     private suspend fun processYuvAndVerifyOutputSize(outputFileOptions: OutputFileOptions?) {
         // Arrange: create node with JPEG input and grayscale effect.
-        val node = ProcessingNode(mainThreadExecutor())
+        val node = ProcessingNode(mainThreadExecutor(), null)
         val nodeIn = ProcessingNode.In.of(ImageFormat.YUV_420_888, ImageFormat.JPEG)
         val imageIn =
             createYuvFakeImageProxy(
@@ -162,7 +162,11 @@
     private suspend fun processJpegAndVerifyEffectApplied(outputFileOptions: OutputFileOptions?) {
         // Arrange: create node with JPEG input and grayscale effect.
         val node =
-            ProcessingNode(mainThreadExecutor(), InternalImageProcessor(GrayscaleImageEffect()))
+            ProcessingNode(
+                mainThreadExecutor(),
+                null,
+                InternalImageProcessor(GrayscaleImageEffect())
+            )
         val nodeIn = ProcessingNode.In.of(ImageFormat.JPEG, ImageFormat.JPEG)
         val imageIn =
             createJpegFakeImageProxy(
@@ -215,7 +219,7 @@
         outputFileOptions: OutputFileOptions?
     ) {
         // Arrange: create a request with no cropping
-        val node = ProcessingNode(mainThreadExecutor())
+        val node = ProcessingNode(mainThreadExecutor(), null)
         val nodeIn = ProcessingNode.In.of(ImageFormat.JPEG, ImageFormat.JPEG)
         node.transform(nodeIn)
         val takePictureCallback = FakeTakePictureCallback()
@@ -255,7 +259,7 @@
     ) {
         // Arrange: create a request with no cropping
         val format = ImageFormat.JPEG_R
-        val node = ProcessingNode(mainThreadExecutor())
+        val node = ProcessingNode(mainThreadExecutor(), null)
         val nodeIn = ProcessingNode.In.of(format, format)
         node.transform(nodeIn)
         val takePictureCallback = FakeTakePictureCallback()
@@ -301,7 +305,7 @@
 
     private suspend fun inMemoryInputPacket_callbackInvoked(outputFileOptions: OutputFileOptions?) {
         // Arrange.
-        val node = ProcessingNode(mainThreadExecutor())
+        val node = ProcessingNode(mainThreadExecutor(), null)
         val nodeIn = ProcessingNode.In.of(ImageFormat.JPEG, ImageFormat.JPEG)
         node.transform(nodeIn)
         val takePictureCallback = FakeTakePictureCallback()
@@ -341,7 +345,7 @@
     ) {
         // Arrange.
         val format = ImageFormat.JPEG_R
-        val node = ProcessingNode(mainThreadExecutor())
+        val node = ProcessingNode(mainThreadExecutor(), null)
         val nodeIn = ProcessingNode.In.of(format, format)
         node.transform(nodeIn)
         val takePictureCallback = FakeTakePictureCallback()
@@ -380,7 +384,7 @@
 
     private suspend fun saveJpegOnDisk_verifyOutput(outputFileOptions: OutputFileOptions?) {
         // Arrange: create a on-disk processing request.
-        val node = ProcessingNode(mainThreadExecutor())
+        val node = ProcessingNode(mainThreadExecutor(), null)
         val nodeIn = ProcessingNode.In.of(ImageFormat.JPEG, ImageFormat.JPEG)
         node.transform(nodeIn)
         val takePictureCallback = FakeTakePictureCallback()
@@ -420,7 +424,7 @@
     private suspend fun saveJpegrOnDisk_verifyOutput(outputFileOptions: OutputFileOptions?) {
         // Arrange: create a on-disk processing request.
         val format = ImageFormat.JPEG_R
-        val node = ProcessingNode(mainThreadExecutor())
+        val node = ProcessingNode(mainThreadExecutor(), null)
         val nodeIn = ProcessingNode.In.of(format, format)
         node.transform(nodeIn)
         val takePictureCallback = FakeTakePictureCallback()
@@ -478,7 +482,7 @@
         // Arrange.
         // Force inject the quirk for the A24 incorrect JPEG metadata problem
         val node =
-            ProcessingNode(mainThreadExecutor(), Quirks(listOf(IncorrectJpegMetadataQuirk())))
+            ProcessingNode(mainThreadExecutor(), Quirks(listOf(IncorrectJpegMetadataQuirk())), null)
         val nodeIn = ProcessingNode.In.of(ImageFormat.JPEG, ImageFormat.JPEG)
         node.transform(nodeIn)
         val takePictureCallback = FakeTakePictureCallback()
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/ImageCapture.java b/camera/camera-core/src/main/java/androidx/camera/core/ImageCapture.java
index 0873c4b..73d7363 100644
--- a/camera/camera-core/src/main/java/androidx/camera/core/ImageCapture.java
+++ b/camera/camera-core/src/main/java/androidx/camera/core/ImageCapture.java
@@ -16,7 +16,9 @@
 
 package androidx.camera.core;
 
+import static android.graphics.ImageFormat.JPEG;
 import static android.graphics.ImageFormat.JPEG_R;
+import static android.graphics.ImageFormat.RAW_SENSOR;
 
 import static androidx.camera.core.CameraEffect.IMAGE_CAPTURE;
 import static androidx.camera.core.impl.ImageCaptureConfig.OPTION_BUFFER_FORMAT;
@@ -64,6 +66,7 @@
 import android.graphics.Bitmap;
 import android.graphics.ImageFormat;
 import android.graphics.Rect;
+import android.hardware.camera2.CameraCharacteristics;
 import android.location.Location;
 import android.media.Image;
 import android.media.ImageReader;
@@ -310,6 +313,12 @@
     public static final int OUTPUT_FORMAT_JPEG_ULTRA_HDR = 1;
 
     /**
+     * Captures raw images in the {@link ImageFormat#RAW_SENSOR} image format.
+     */
+    @RestrictTo(Scope.LIBRARY_GROUP)
+    public static final int OUTPUT_FORMAT_RAW = 2;
+
+    /**
      * Provides a static configuration with implementation-agnostic options.
      */
     @RestrictTo(Scope.LIBRARY_GROUP)
@@ -463,12 +472,14 @@
                 null);
         if (bufferFormat != null) {
             Preconditions.checkArgument(!(isSessionProcessorEnabledInCurrentCamera()
-                            && bufferFormat != ImageFormat.JPEG),
+                            && bufferFormat != JPEG),
                     "Cannot set non-JPEG buffer format with Extensions enabled.");
             builder.getMutableConfig().insertOption(OPTION_INPUT_FORMAT,
                     useSoftwareJpeg ? ImageFormat.YUV_420_888 : bufferFormat);
         } else {
-            if (isOutputFormatUltraHdr(builder.getMutableConfig())) {
+            if (isOutputFormatRaw(builder.getMutableConfig())) {
+                builder.getMutableConfig().insertOption(OPTION_INPUT_FORMAT, RAW_SENSOR);
+            } else if (isOutputFormatUltraHdr(builder.getMutableConfig())) {
                 builder.getMutableConfig().insertOption(OPTION_INPUT_FORMAT, JPEG_R);
                 builder.getMutableConfig().insertOption(OPTION_INPUT_DYNAMIC_RANGE,
                         DynamicRange.UNSPECIFIED);
@@ -480,12 +491,12 @@
                         builder.getMutableConfig().retrieveOption(OPTION_SUPPORTED_RESOLUTIONS,
                                 null);
                 if (supportedSizes == null) {
-                    builder.getMutableConfig().insertOption(OPTION_INPUT_FORMAT, ImageFormat.JPEG);
+                    builder.getMutableConfig().insertOption(OPTION_INPUT_FORMAT, JPEG);
                 } else {
                     // Use Jpeg first if supported.
-                    if (isImageFormatSupported(supportedSizes, ImageFormat.JPEG)) {
+                    if (isImageFormatSupported(supportedSizes, JPEG)) {
                         builder.getMutableConfig().insertOption(OPTION_INPUT_FORMAT,
-                                ImageFormat.JPEG);
+                                JPEG);
                     } else if (isImageFormatSupported(supportedSizes, ImageFormat.YUV_420_888)) {
                         builder.getMutableConfig().insertOption(OPTION_INPUT_FORMAT,
                                 ImageFormat.YUV_420_888);
@@ -515,6 +526,11 @@
                 OUTPUT_FORMAT_JPEG_ULTRA_HDR);
     }
 
+    private static boolean isOutputFormatRaw(@NonNull MutableConfig config) {
+        return Objects.equals(config.retrieveOption(OPTION_OUTPUT_FORMAT, null),
+                OUTPUT_FORMAT_RAW);
+    }
+
     /**
      * Configures flash mode to CameraControlInternal once it is ready.
      */
@@ -979,6 +995,10 @@
                 formats.add(OUTPUT_FORMAT_JPEG_ULTRA_HDR);
             }
 
+            if (isRawSupported()) {
+                formats.add(OUTPUT_FORMAT_RAW);
+            }
+
             return formats;
         }
 
@@ -990,6 +1010,15 @@
 
             return false;
         }
+
+        private boolean isRawSupported() {
+            if (mCameraInfo instanceof CameraInfoInternal) {
+                CameraInfoInternal cameraInfoInternal = (CameraInfoInternal) mCameraInfo;
+                return cameraInfoInternal.getSupportedOutputFormats().contains(RAW_SENSOR);
+            }
+
+            return false;
+        }
     }
 
     @NonNull
@@ -1133,7 +1162,7 @@
                 supported = false;
             }
             Integer bufferFormat = mutableConfig.retrieveOption(OPTION_BUFFER_FORMAT, null);
-            if (bufferFormat != null && bufferFormat != ImageFormat.JPEG) {
+            if (bufferFormat != null && bufferFormat != JPEG) {
                 Logger.w(TAG, "Software JPEG cannot be used with non-JPEG output buffer format.");
                 supported = false;
             }
@@ -1274,8 +1303,8 @@
                 // Prefer YUV because it takes less time to decode to bitmap.
                 List<Size> sizes = map.get(ImageFormat.YUV_420_888);
                 if (sizes == null || sizes.isEmpty()) {
-                    sizes = map.get(ImageFormat.JPEG);
-                    postviewFormat = ImageFormat.JPEG;
+                    sizes = map.get(JPEG);
+                    postviewFormat = JPEG;
                 }
 
                 if (sizes != null && !sizes.isEmpty()) {
@@ -1307,7 +1336,21 @@
             }
         }
 
-        mImagePipeline = new ImagePipeline(config, resolution, getEffect(), isVirtualCamera,
+        CameraCharacteristics cameraCharacteristics = null;
+        if (getCamera() != null) {
+            try {
+                Object obj = getCamera().getCameraInfoInternal().getCameraCharacteristics();
+                if (obj instanceof CameraCharacteristics) {
+                    cameraCharacteristics = (CameraCharacteristics) obj;
+                }
+            } catch (Exception e) {
+                Log.e(TAG, "getCameraCharacteristics failed", e);
+            }
+        }
+
+        mImagePipeline = new ImagePipeline(config, resolution,
+                cameraCharacteristics,
+                getEffect(), isVirtualCamera,
                 postViewSize, postviewFormat);
 
         if (mTakePictureManager == null) {
@@ -1598,7 +1641,7 @@
      */
     @OptIn(markerClass = androidx.camera.core.ExperimentalImageCaptureOutputFormat.class)
     @Target({ElementType.TYPE_USE})
-    @IntDef({OUTPUT_FORMAT_JPEG, OUTPUT_FORMAT_JPEG_ULTRA_HDR})
+    @IntDef({OUTPUT_FORMAT_JPEG, OUTPUT_FORMAT_JPEG_ULTRA_HDR, OUTPUT_FORMAT_RAW})
     @Retention(RetentionPolicy.SOURCE)
     @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
     public @interface OutputFormat {
@@ -2320,12 +2363,14 @@
             if (bufferFormat != null) {
                 getMutableConfig().insertOption(OPTION_INPUT_FORMAT, bufferFormat);
             } else {
-                if (isOutputFormatUltraHdr(getMutableConfig())) {
+                if (isOutputFormatRaw(getMutableConfig())) {
+                    getMutableConfig().insertOption(OPTION_INPUT_FORMAT, RAW_SENSOR);
+                } else if (isOutputFormatUltraHdr(getMutableConfig())) {
                     getMutableConfig().insertOption(OPTION_INPUT_FORMAT, JPEG_R);
                     getMutableConfig().insertOption(OPTION_INPUT_DYNAMIC_RANGE,
                             DynamicRange.UNSPECIFIED);
                 } else {
-                    getMutableConfig().insertOption(OPTION_INPUT_FORMAT, ImageFormat.JPEG);
+                    getMutableConfig().insertOption(OPTION_INPUT_FORMAT, JPEG);
                 }
             }
 
@@ -2830,7 +2875,7 @@
          * <p>If not set, the output format will default to {@link #OUTPUT_FORMAT_JPEG}.
          *
          * @param outputFormat The output image format. Value is {@link #OUTPUT_FORMAT_JPEG} or
-         *                     {@link #OUTPUT_FORMAT_JPEG_ULTRA_HDR}.
+         *                     {@link #OUTPUT_FORMAT_JPEG_ULTRA_HDR} or {@link #OUTPUT_FORMAT_RAW}.
          * @return The current Builder.
          *
          * @see OutputFormat
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/imagecapture/DngImage2Disk.java b/camera/camera-core/src/main/java/androidx/camera/core/imagecapture/DngImage2Disk.java
new file mode 100644
index 0000000..c19ed71
--- /dev/null
+++ b/camera/camera-core/src/main/java/androidx/camera/core/imagecapture/DngImage2Disk.java
@@ -0,0 +1,131 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.camera.core.imagecapture;
+
+import static androidx.camera.core.ImageCapture.ERROR_FILE_IO;
+import static androidx.camera.core.imagecapture.FileUtil.createTempFile;
+import static androidx.camera.core.imagecapture.FileUtil.moveFileToTarget;
+
+import android.hardware.camera2.CameraCharacteristics;
+import android.hardware.camera2.CaptureResult;
+import android.hardware.camera2.DngCreator;
+import android.media.ExifInterface;
+import android.net.Uri;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.OptIn;
+import androidx.annotation.VisibleForTesting;
+import androidx.camera.core.ExperimentalGetImage;
+import androidx.camera.core.ImageCapture;
+import androidx.camera.core.ImageCaptureException;
+import androidx.camera.core.ImageProxy;
+import androidx.camera.core.processing.Operation;
+
+import com.google.auto.value.AutoValue;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+
+public class DngImage2Disk implements Operation<DngImage2Disk.In, ImageCapture.OutputFileResults> {
+
+    @NonNull
+    private DngCreator mDngCreator;
+
+    public DngImage2Disk(@NonNull CameraCharacteristics cameraCharacteristics,
+            @NonNull CaptureResult captureResult) {
+        this(new DngCreator(cameraCharacteristics, captureResult));
+    }
+
+    @VisibleForTesting
+    DngImage2Disk(@NonNull DngCreator dngCreator) {
+        mDngCreator = dngCreator;
+    }
+
+    @NonNull
+    @Override
+    public ImageCapture.OutputFileResults apply(@NonNull In in) throws ImageCaptureException {
+        ImageCapture.OutputFileOptions options = in.getOutputFileOptions();
+        File tempFile = createTempFile(options);
+        writeImageToFile(tempFile, in.getImageProxy(), in.getRotationDegrees());
+        Uri uri = moveFileToTarget(tempFile, options);
+        return new ImageCapture.OutputFileResults(uri);
+    }
+
+    /**
+     * Writes byte array to the given {@link File}.
+     */
+    @OptIn(markerClass = ExperimentalGetImage.class)
+    private void writeImageToFile(
+            @NonNull File tempFile,
+            @NonNull ImageProxy imageProxy,
+            int rotationDegrees) throws ImageCaptureException {
+        try (FileOutputStream output = new FileOutputStream(tempFile)) {
+            mDngCreator.setOrientation(computeExifOrientation(rotationDegrees));
+            mDngCreator.writeImage(output, imageProxy.getImage());
+        } catch (IllegalArgumentException e) {
+            throw new ImageCaptureException(ERROR_FILE_IO,
+                    "Image with an unsupported format was used", e);
+        } catch (IllegalStateException e) {
+            throw new ImageCaptureException(ERROR_FILE_IO,
+                    "Not enough metadata information has been "
+                            + "set to write a well-formatted DNG file", e);
+        } catch (IOException e) {
+            throw new ImageCaptureException(ERROR_FILE_IO, "Failed to write to temp file", e);
+        } finally {
+            imageProxy.close();
+        }
+    }
+
+    static int computeExifOrientation(int rotationDegrees) {
+        switch (rotationDegrees) {
+            case 0:
+                return ExifInterface.ORIENTATION_NORMAL;
+            case 90:
+                return ExifInterface.ORIENTATION_ROTATE_90;
+            case 180:
+                return ExifInterface.ORIENTATION_ROTATE_180;
+            case 270:
+                return ExifInterface.ORIENTATION_ROTATE_270;
+        }
+        return ExifInterface.ORIENTATION_UNDEFINED;
+    }
+
+    /**
+     * Input packet.
+     */
+    @AutoValue
+    abstract static class In {
+
+        @NonNull
+        abstract ImageProxy getImageProxy();
+
+        abstract int getRotationDegrees();
+
+        @NonNull
+        abstract ImageCapture.OutputFileOptions getOutputFileOptions();
+
+        @NonNull
+        static DngImage2Disk.In of(
+                @NonNull ImageProxy imageProxy,
+                int rotationDegrees,
+                @NonNull ImageCapture.OutputFileOptions outputFileOptions) {
+            return new AutoValue_DngImage2Disk_In(imageProxy,
+                    rotationDegrees, outputFileOptions);
+        }
+    }
+}
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/imagecapture/FileUtil.java b/camera/camera-core/src/main/java/androidx/camera/core/imagecapture/FileUtil.java
new file mode 100644
index 0000000..befa01a
--- /dev/null
+++ b/camera/camera-core/src/main/java/androidx/camera/core/imagecapture/FileUtil.java
@@ -0,0 +1,265 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.camera.core.imagecapture;
+
+import static androidx.camera.core.ImageCapture.ERROR_FILE_IO;
+
+import static java.util.Objects.requireNonNull;
+
+import android.content.ContentResolver;
+import android.content.ContentValues;
+import android.net.Uri;
+import android.os.Build;
+import android.provider.MediaStore;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.camera.core.ImageCapture;
+import androidx.camera.core.ImageCaptureException;
+import androidx.camera.core.impl.utils.Exif;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.UUID;
+
+/**
+ * Utility class for file read and write operations.
+ */
+public final class FileUtil {
+
+    private static final String TEMP_FILE_PREFIX = "CameraX";
+    private static final String TEMP_FILE_SUFFIX = ".tmp";
+    private static final int COPY_BUFFER_SIZE = 1024;
+    private static final int PENDING = 1;
+    private static final int NOT_PENDING = 0;
+
+    private FileUtil() {}
+
+    /**
+     * Creates a temporary Dng file.
+     */
+    @NonNull
+    static File createTempFile(@NonNull ImageCapture.OutputFileOptions options)
+            throws ImageCaptureException {
+        try {
+            File appProvidedFile = options.getFile();
+            if (appProvidedFile != null) {
+                // For saving-to-file case, write to the target folder and rename for better
+                // performance. The file extensions must be the same as app provided to avoid the
+                // directory access problem.
+                return new File(appProvidedFile.getParent(),
+                        TEMP_FILE_PREFIX + UUID.randomUUID().toString()
+                                + getFileExtensionWithDot(appProvidedFile));
+            } else {
+                return File.createTempFile(TEMP_FILE_PREFIX, TEMP_FILE_SUFFIX);
+            }
+        } catch (IOException e) {
+            throw new ImageCaptureException(ERROR_FILE_IO, "Failed to create temp file.", e);
+        }
+    }
+
+    /**
+     * Updates exif data.
+     *
+     * @param tempFile
+     * @param originalExif
+     * @param options
+     * @param rotationDegrees
+     * @throws ImageCaptureException
+     */
+    static void updateFileExif(
+            @NonNull File tempFile,
+            @NonNull Exif originalExif,
+            @NonNull ImageCapture.OutputFileOptions options,
+            int rotationDegrees)
+            throws ImageCaptureException {
+        try {
+            // Create new exif based on the original exif.
+            Exif exif = Exif.createFromFile(tempFile);
+            originalExif.copyToCroppedImage(exif);
+
+            if (exif.getRotation() == 0 && rotationDegrees != 0) {
+                // When the HAL does not handle rotation, exif rotation is 0. In which case we
+                // apply the packet rotation.
+                // See: EXIF_ROTATION_AVAILABILITY
+                exif.rotate(rotationDegrees);
+            }
+
+            // Overwrite exif based on metadata.
+            ImageCapture.Metadata metadata = options.getMetadata();
+            if (metadata.isReversedHorizontal()) {
+                exif.flipHorizontally();
+            }
+            if (metadata.isReversedVertical()) {
+                exif.flipVertically();
+            }
+            if (metadata.getLocation() != null) {
+                exif.attachLocation(metadata.getLocation());
+            }
+            exif.save();
+        } catch (IOException e) {
+            throw new ImageCaptureException(ERROR_FILE_IO, "Failed to update Exif data", e);
+        }
+    }
+
+    /**
+     * Copies the file to target, deletes the original file and returns the target's {@link Uri}.
+     *
+     * @return null if the target is {@link OutputStream}.
+     */
+    @Nullable
+    static Uri moveFileToTarget(
+            @NonNull File tempFile, @NonNull ImageCapture.OutputFileOptions options)
+            throws ImageCaptureException {
+        Uri uri = null;
+        try {
+            if (isSaveToMediaStore(options)) {
+                uri = copyFileToMediaStore(tempFile, options);
+            } else if (isSaveToOutputStream(options)) {
+                copyFileToOutputStream(tempFile, requireNonNull(options.getOutputStream()));
+            } else if (isSaveToFile(options)) {
+                uri = copyFileToFile(tempFile, requireNonNull(options.getFile()));
+            }
+        } catch (IOException e) {
+            throw new ImageCaptureException(
+                    ERROR_FILE_IO, "Failed to write to OutputStream.", null);
+        } finally {
+            tempFile.delete();
+        }
+        return uri;
+    }
+
+    private static String getFileExtensionWithDot(File file) {
+        String fileName = file.getName();
+        int dotIndex = fileName.lastIndexOf('.');
+        if (dotIndex >= 0) {
+            return fileName.substring(dotIndex);
+        } else {
+            return "";
+        }
+    }
+
+    private static Uri copyFileToMediaStore(
+            @NonNull File file,
+            @NonNull ImageCapture.OutputFileOptions options)
+            throws ImageCaptureException {
+        ContentResolver contentResolver = requireNonNull(options.getContentResolver());
+        ContentValues values = options.getContentValues() != null
+                ? new ContentValues(options.getContentValues())
+                : new ContentValues();
+        setContentValuePendingFlag(values, PENDING);
+        Uri uri = null;
+        try {
+            uri = contentResolver.insert(options.getSaveCollection(), values);
+            if (uri == null) {
+                throw new ImageCaptureException(
+                        ERROR_FILE_IO, "Failed to insert a MediaStore URI.", null);
+            }
+            copyTempFileToUri(file, uri, contentResolver);
+        } catch (IOException | SecurityException e) {
+            throw new ImageCaptureException(
+                    ERROR_FILE_IO, "Failed to write to MediaStore URI: " + uri, e);
+        } finally {
+            if (uri != null) {
+                updateUriPendingStatus(uri, contentResolver, NOT_PENDING);
+            }
+        }
+        return uri;
+    }
+
+    private static Uri copyFileToFile(@NonNull File source, @NonNull File target)
+            throws ImageCaptureException {
+        // Normally File#renameTo will overwrite the targetFile even if it already exists.
+        // Just in case of unexpected behavior on certain platforms or devices, delete the
+        // target file before renaming.
+        if (target.exists()) {
+            target.delete();
+        }
+        if (!source.renameTo(target)) {
+            throw new ImageCaptureException(
+                    ERROR_FILE_IO,
+                    "Failed to overwrite the file: " + target.getAbsolutePath(),
+                    null);
+        }
+        return Uri.fromFile(target);
+    }
+
+    /**
+     * Copies temp file to {@link Uri}.
+     */
+    private static void copyTempFileToUri(
+            @NonNull File tempFile,
+            @NonNull Uri uri,
+            @NonNull ContentResolver contentResolver) throws IOException {
+        try (OutputStream outputStream = contentResolver.openOutputStream(uri)) {
+            if (outputStream == null) {
+                throw new FileNotFoundException(uri + " cannot be resolved.");
+            }
+            copyFileToOutputStream(tempFile, outputStream);
+        }
+    }
+
+    @SuppressWarnings("IOStreamConstructor")
+    private static void copyFileToOutputStream(@NonNull File file,
+            @NonNull OutputStream outputStream)
+            throws IOException {
+        try (InputStream in = new FileInputStream(file)) {
+            byte[] buf = new byte[COPY_BUFFER_SIZE];
+            int len;
+            while ((len = in.read(buf)) > 0) {
+                outputStream.write(buf, 0, len);
+            }
+        }
+    }
+
+    /**
+     * Removes IS_PENDING flag during the writing to {@link Uri}.
+     */
+    private static void updateUriPendingStatus(@NonNull Uri outputUri,
+            @NonNull ContentResolver contentResolver, int isPending) {
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+            ContentValues values = new ContentValues();
+            setContentValuePendingFlag(values, isPending);
+            contentResolver.update(outputUri, values, null, null);
+        }
+    }
+
+    /** Set IS_PENDING flag to {@link ContentValues}. */
+    private static void setContentValuePendingFlag(@NonNull ContentValues values, int isPending) {
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+            values.put(MediaStore.Images.Media.IS_PENDING, isPending);
+        }
+    }
+
+    private static boolean isSaveToMediaStore(ImageCapture.OutputFileOptions outputFileOptions) {
+        return outputFileOptions.getSaveCollection() != null
+                && outputFileOptions.getContentResolver() != null
+                && outputFileOptions.getContentValues() != null;
+    }
+
+    private static boolean isSaveToFile(ImageCapture.OutputFileOptions outputFileOptions) {
+        return outputFileOptions.getFile() != null;
+    }
+
+    private static boolean isSaveToOutputStream(ImageCapture.OutputFileOptions outputFileOptions) {
+        return outputFileOptions.getOutputStream() != null;
+    }
+}
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/imagecapture/ImagePipeline.java b/camera/camera-core/src/main/java/androidx/camera/core/imagecapture/ImagePipeline.java
index 6f12e60..812be3d 100644
--- a/camera/camera-core/src/main/java/androidx/camera/core/imagecapture/ImagePipeline.java
+++ b/camera/camera-core/src/main/java/androidx/camera/core/imagecapture/ImagePipeline.java
@@ -22,10 +22,12 @@
 import static androidx.camera.core.impl.utils.Threads.checkMainThread;
 import static androidx.camera.core.impl.utils.TransformUtils.hasCropping;
 import static androidx.camera.core.internal.utils.ImageUtil.isJpegFormats;
+import static androidx.camera.core.internal.utils.ImageUtil.isRawFormats;
 
 import static java.util.Objects.requireNonNull;
 
 import android.graphics.ImageFormat;
+import android.hardware.camera2.CameraCharacteristics;
 import android.media.ImageReader;
 import android.util.Size;
 
@@ -87,8 +89,9 @@
     @VisibleForTesting
     public ImagePipeline(
             @NonNull ImageCaptureConfig useCaseConfig,
-            @NonNull Size cameraSurfaceSize) {
-        this(useCaseConfig, cameraSurfaceSize, /*cameraEffect=*/ null,
+            @NonNull Size cameraSurfaceSize,
+            @NonNull CameraCharacteristics cameraCharacteristics) {
+        this(useCaseConfig, cameraSurfaceSize, cameraCharacteristics, /*cameraEffect=*/ null,
                 /*isVirtualCamera=*/ false, /* postviewSize */ null, ImageFormat.YUV_420_888);
     }
 
@@ -96,9 +99,10 @@
     public ImagePipeline(
             @NonNull ImageCaptureConfig useCaseConfig,
             @NonNull Size cameraSurfaceSize,
+            @NonNull CameraCharacteristics cameraCharacteristics,
             @Nullable CameraEffect cameraEffect,
             boolean isVirtualCamera) {
-        this(useCaseConfig, cameraSurfaceSize, cameraEffect, isVirtualCamera,
+        this(useCaseConfig, cameraSurfaceSize, cameraCharacteristics, cameraEffect, isVirtualCamera,
                 null, ImageFormat.YUV_420_888);
     }
 
@@ -106,6 +110,7 @@
     public ImagePipeline(
             @NonNull ImageCaptureConfig useCaseConfig,
             @NonNull Size cameraSurfaceSize,
+            @Nullable CameraCharacteristics cameraCharacteristics,
             @Nullable CameraEffect cameraEffect,
             boolean isVirtualCamera,
             @Nullable Size postviewSize,
@@ -118,6 +123,7 @@
         mCaptureNode = new CaptureNode();
         mProcessingNode = new ProcessingNode(
                 requireNonNull(mUseCaseConfig.getIoExecutor(CameraXExecutors.ioExecutor())),
+                cameraCharacteristics,
                 cameraEffect != null ? new InternalImageProcessor(cameraEffect) : null);
 
         // Connect nodes
@@ -246,6 +252,9 @@
         if (inputFormat != null && inputFormat == ImageFormat.JPEG_R) {
             return ImageFormat.JPEG_R;
         }
+        if (inputFormat != null && inputFormat == ImageFormat.RAW_SENSOR) {
+            return ImageFormat.RAW_SENSOR;
+        }
 
         // By default, use JPEG format.
         return ImageFormat.JPEG;
@@ -303,9 +312,10 @@
             builder.addSurface(mPipelineIn.getSurface());
             builder.setPostviewEnabled(shouldEnablePostview());
 
-            // Only sets the JPEG rotation and quality for JPEG formats. Some devices do not
+            // Sets the JPEG rotation and quality for JPEG and RAW formats. Some devices do not
             // handle these configs for non-JPEG images. See b/204375890.
-            if (isJpegFormats(mPipelineIn.getInputFormat())) {
+            if (isJpegFormats(mPipelineIn.getInputFormat())
+                    || isRawFormats(mPipelineIn.getInputFormat())) {
                 if (EXIF_ROTATION_AVAILABILITY.isRotationOptionSupported()) {
                     builder.addImplementationOption(CaptureConfig.OPTION_ROTATION,
                             takePictureRequest.getRotationDegrees());
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/imagecapture/JpegBytes2Disk.java b/camera/camera-core/src/main/java/androidx/camera/core/imagecapture/JpegBytes2Disk.java
index 4dccd13..26085ba 100644
--- a/camera/camera-core/src/main/java/androidx/camera/core/imagecapture/JpegBytes2Disk.java
+++ b/camera/camera-core/src/main/java/androidx/camera/core/imagecapture/JpegBytes2Disk.java
@@ -16,20 +16,17 @@
 package androidx.camera.core.imagecapture;
 
 import static androidx.camera.core.ImageCapture.ERROR_FILE_IO;
+import static androidx.camera.core.imagecapture.FileUtil.createTempFile;
+import static androidx.camera.core.imagecapture.FileUtil.moveFileToTarget;
+import static androidx.camera.core.imagecapture.FileUtil.updateFileExif;
 
 import static java.util.Objects.requireNonNull;
 
-import android.content.ContentResolver;
-import android.content.ContentValues;
 import android.net.Uri;
-import android.os.Build;
-import android.provider.MediaStore;
 
 import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.camera.core.ImageCapture;
 import androidx.camera.core.ImageCaptureException;
-import androidx.camera.core.impl.utils.Exif;
 import androidx.camera.core.internal.compat.workaround.InvalidJpegDataParser;
 import androidx.camera.core.processing.Operation;
 import androidx.camera.core.processing.Packet;
@@ -37,25 +34,14 @@
 import com.google.auto.value.AutoValue;
 
 import java.io.File;
-import java.io.FileInputStream;
-import java.io.FileNotFoundException;
 import java.io.FileOutputStream;
 import java.io.IOException;
-import java.io.InputStream;
-import java.io.OutputStream;
-import java.util.UUID;
 
 /**
  * Saves JPEG bytes to disk.
  */
 class JpegBytes2Disk implements Operation<JpegBytes2Disk.In, ImageCapture.OutputFileResults> {
 
-    private static final String TEMP_FILE_PREFIX = "CameraX";
-    private static final String TEMP_FILE_SUFFIX = ".tmp";
-    private static final int COPY_BUFFER_SIZE = 1024;
-    private static final int PENDING = 1;
-    private static final int NOT_PENDING = 0;
-
     @NonNull
     @Override
     public ImageCapture.OutputFileResults apply(@NonNull In in) throws ImageCaptureException {
@@ -70,42 +56,9 @@
     }
 
     /**
-     * Creates a temporary JPEG file.
-     */
-    @NonNull
-    private static File createTempFile(@NonNull ImageCapture.OutputFileOptions options)
-            throws ImageCaptureException {
-        try {
-            File appProvidedFile = options.getFile();
-            if (appProvidedFile != null) {
-                // For saving-to-file case, write to the target folder and rename for better
-                // performance. The file extensions must be the same as app provided to avoid the
-                // directory access problem.
-                return new File(appProvidedFile.getParent(),
-                        TEMP_FILE_PREFIX + UUID.randomUUID().toString()
-                                + getFileExtensionWithDot(appProvidedFile));
-            } else {
-                return File.createTempFile(TEMP_FILE_PREFIX, TEMP_FILE_SUFFIX);
-            }
-        } catch (IOException e) {
-            throw new ImageCaptureException(ERROR_FILE_IO, "Failed to create temp file.", e);
-        }
-    }
-
-    private static String getFileExtensionWithDot(File file) {
-        String fileName = file.getName();
-        int dotIndex = fileName.lastIndexOf('.');
-        if (dotIndex >= 0) {
-            return fileName.substring(dotIndex);
-        } else {
-            return "";
-        }
-    }
-
-    /**
      * Writes byte array to the given {@link File}.
      */
-    private static void writeBytesToFile(
+    static void writeBytesToFile(
             @NonNull File tempFile, @NonNull byte[] bytes) throws ImageCaptureException {
         try (FileOutputStream output = new FileOutputStream(tempFile)) {
             InvalidJpegDataParser invalidJpegDataParser = new InvalidJpegDataParser();
@@ -115,174 +68,6 @@
         }
     }
 
-    private static void updateFileExif(
-            @NonNull File tempFile,
-            @NonNull Exif originalExif,
-            @NonNull ImageCapture.OutputFileOptions options,
-            int rotationDegrees)
-            throws ImageCaptureException {
-        try {
-            // Create new exif based on the original exif.
-            Exif exif = Exif.createFromFile(tempFile);
-            originalExif.copyToCroppedImage(exif);
-
-            if (exif.getRotation() == 0 && rotationDegrees != 0) {
-                // When the HAL does not handle rotation, exif rotation is 0. In which case we
-                // apply the packet rotation.
-                // See: EXIF_ROTATION_AVAILABILITY
-                exif.rotate(rotationDegrees);
-            }
-
-            // Overwrite exif based on metadata.
-            ImageCapture.Metadata metadata = options.getMetadata();
-            if (metadata.isReversedHorizontal()) {
-                exif.flipHorizontally();
-            }
-            if (metadata.isReversedVertical()) {
-                exif.flipVertically();
-            }
-            if (metadata.getLocation() != null) {
-                exif.attachLocation(metadata.getLocation());
-            }
-            exif.save();
-        } catch (IOException e) {
-            throw new ImageCaptureException(ERROR_FILE_IO, "Failed to update Exif data", e);
-        }
-    }
-
-    /**
-     * Copies the file to target, deletes the original file and returns the target's {@link Uri}.
-     *
-     * @return null if the target is {@link OutputStream}.
-     */
-    @Nullable
-    static Uri moveFileToTarget(
-            @NonNull File tempFile, @NonNull ImageCapture.OutputFileOptions options)
-            throws ImageCaptureException {
-        Uri uri = null;
-        try {
-            if (isSaveToMediaStore(options)) {
-                uri = copyFileToMediaStore(tempFile, options);
-            } else if (isSaveToOutputStream(options)) {
-                copyFileToOutputStream(tempFile, requireNonNull(options.getOutputStream()));
-            } else if (isSaveToFile(options)) {
-                uri = copyFileToFile(tempFile, requireNonNull(options.getFile()));
-            }
-        } catch (IOException e) {
-            throw new ImageCaptureException(
-                    ERROR_FILE_IO, "Failed to write to OutputStream.", null);
-        } finally {
-            tempFile.delete();
-        }
-        return uri;
-    }
-
-    private static Uri copyFileToMediaStore(
-            @NonNull File file,
-            @NonNull ImageCapture.OutputFileOptions options)
-            throws ImageCaptureException {
-        ContentResolver contentResolver = requireNonNull(options.getContentResolver());
-        ContentValues values = options.getContentValues() != null
-                ? new ContentValues(options.getContentValues())
-                : new ContentValues();
-        setContentValuePendingFlag(values, PENDING);
-        Uri uri = null;
-        try {
-            uri = contentResolver.insert(options.getSaveCollection(), values);
-            if (uri == null) {
-                throw new ImageCaptureException(
-                        ERROR_FILE_IO, "Failed to insert a MediaStore URI.", null);
-            }
-            copyTempFileToUri(file, uri, contentResolver);
-        } catch (IOException | SecurityException e) {
-            throw new ImageCaptureException(
-                    ERROR_FILE_IO, "Failed to write to MediaStore URI: " + uri, e);
-        } finally {
-            if (uri != null) {
-                updateUriPendingStatus(uri, contentResolver, NOT_PENDING);
-            }
-        }
-        return uri;
-    }
-
-    private static Uri copyFileToFile(@NonNull File source, @NonNull File target)
-            throws ImageCaptureException {
-        // Normally File#renameTo will overwrite the targetFile even if it already exists.
-        // Just in case of unexpected behavior on certain platforms or devices, delete the
-        // target file before renaming.
-        if (target.exists()) {
-            target.delete();
-        }
-        if (!source.renameTo(target)) {
-            throw new ImageCaptureException(
-                    ERROR_FILE_IO,
-                    "Failed to overwrite the file: " + target.getAbsolutePath(),
-                    null);
-        }
-        return Uri.fromFile(target);
-    }
-
-    /**
-     * Copies temp file to {@link Uri}.
-     */
-    private static void copyTempFileToUri(
-            @NonNull File tempFile,
-            @NonNull Uri uri,
-            @NonNull ContentResolver contentResolver) throws IOException {
-        try (OutputStream outputStream = contentResolver.openOutputStream(uri)) {
-            if (outputStream == null) {
-                throw new FileNotFoundException(uri + " cannot be resolved.");
-            }
-            copyFileToOutputStream(tempFile, outputStream);
-        }
-    }
-
-    @SuppressWarnings("IOStreamConstructor")
-    private static void copyFileToOutputStream(@NonNull File file,
-            @NonNull OutputStream outputStream)
-            throws IOException {
-        try (InputStream in = new FileInputStream(file)) {
-            byte[] buf = new byte[COPY_BUFFER_SIZE];
-            int len;
-            while ((len = in.read(buf)) > 0) {
-                outputStream.write(buf, 0, len);
-            }
-        }
-    }
-
-    /**
-     * Removes IS_PENDING flag during the writing to {@link Uri}.
-     */
-    private static void updateUriPendingStatus(@NonNull Uri outputUri,
-            @NonNull ContentResolver contentResolver, int isPending) {
-        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
-            ContentValues values = new ContentValues();
-            setContentValuePendingFlag(values, isPending);
-            contentResolver.update(outputUri, values, null, null);
-        }
-    }
-
-    /** Set IS_PENDING flag to {@link ContentValues}. */
-    private static void setContentValuePendingFlag(@NonNull ContentValues values, int isPending) {
-        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
-            values.put(MediaStore.Images.Media.IS_PENDING, isPending);
-        }
-    }
-
-    private static boolean isSaveToMediaStore(ImageCapture.OutputFileOptions outputFileOptions) {
-        return outputFileOptions.getSaveCollection() != null
-                && outputFileOptions.getContentResolver() != null
-                && outputFileOptions.getContentValues() != null;
-    }
-
-    private static boolean isSaveToFile(ImageCapture.OutputFileOptions outputFileOptions) {
-        return outputFileOptions.getFile() != null;
-    }
-
-    private static boolean isSaveToOutputStream(ImageCapture.OutputFileOptions outputFileOptions) {
-        return outputFileOptions.getOutputStream() != null;
-    }
-
     /**
      * Input packet.
      */
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/imagecapture/ProcessingNode.java b/camera/camera-core/src/main/java/androidx/camera/core/imagecapture/ProcessingNode.java
index 5243d9c8..fddd9b7 100644
--- a/camera/camera-core/src/main/java/androidx/camera/core/imagecapture/ProcessingNode.java
+++ b/camera/camera-core/src/main/java/androidx/camera/core/imagecapture/ProcessingNode.java
@@ -17,11 +17,13 @@
 package androidx.camera.core.imagecapture;
 
 import static android.graphics.ImageFormat.JPEG;
+import static android.graphics.ImageFormat.RAW_SENSOR;
 import static android.graphics.ImageFormat.YUV_420_888;
 
 import static androidx.camera.core.ImageCapture.ERROR_UNKNOWN;
 import static androidx.camera.core.impl.utils.executor.CameraXExecutors.mainThreadExecutor;
 import static androidx.camera.core.internal.utils.ImageUtil.isJpegFormats;
+import static androidx.camera.core.internal.utils.ImageUtil.isRawFormats;
 import static androidx.core.util.Preconditions.checkArgument;
 import static androidx.core.util.Preconditions.checkState;
 
@@ -29,6 +31,7 @@
 
 import android.graphics.Bitmap;
 import android.graphics.ImageFormat;
+import android.hardware.camera2.CameraCharacteristics;
 
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
@@ -66,6 +69,9 @@
     @Nullable
     final InternalImageProcessor mImageProcessor;
 
+    @Nullable
+    private final CameraCharacteristics mCameraCharacteristics;
+
     private ProcessingNode.In mInputEdge;
     private Operation<InputPacket, Packet<ImageProxy>> mInput2Packet;
     private Operation<Image2JpegBytes.In, Packet<byte[]>> mImage2JpegBytes;
@@ -84,18 +90,23 @@
      *                         {@link CameraXExecutors#ioExecutor()}
      */
     @VisibleForTesting
-    ProcessingNode(@NonNull Executor blockingExecutor) {
-        this(blockingExecutor, /*imageProcessor=*/null, DeviceQuirks.getAll());
+    ProcessingNode(@NonNull Executor blockingExecutor,
+            @Nullable CameraCharacteristics cameraCharacteristics) {
+        this(blockingExecutor, cameraCharacteristics,
+                /*imageProcessor=*/null, DeviceQuirks.getAll());
     }
 
     @VisibleForTesting
-    ProcessingNode(@NonNull Executor blockingExecutor, @NonNull Quirks quirks) {
-        this(blockingExecutor, /*imageProcessor=*/null, quirks);
+    ProcessingNode(@NonNull Executor blockingExecutor,
+            @NonNull Quirks quirks,
+            @Nullable CameraCharacteristics cameraCharacteristics) {
+        this(blockingExecutor, cameraCharacteristics, /*imageProcessor=*/null, quirks);
     }
 
     ProcessingNode(@NonNull Executor blockingExecutor,
+            @Nullable CameraCharacteristics cameraCharacteristics,
             @Nullable InternalImageProcessor imageProcessor) {
-        this(blockingExecutor, imageProcessor, DeviceQuirks.getAll());
+        this(blockingExecutor, cameraCharacteristics, imageProcessor, DeviceQuirks.getAll());
     }
 
     /**
@@ -104,6 +115,7 @@
      * @param imageProcessor   external effect for post-processing.
      */
     ProcessingNode(@NonNull Executor blockingExecutor,
+            @Nullable CameraCharacteristics cameraCharacteristics,
             @Nullable InternalImageProcessor imageProcessor,
             @NonNull Quirks quirks) {
         boolean isLowMemoryDevice = DeviceQuirks.get(LowMemoryQuirk.class) != null;
@@ -113,6 +125,7 @@
             mBlockingExecutor = blockingExecutor;
         }
         mImageProcessor = imageProcessor;
+        mCameraCharacteristics = cameraCharacteristics;
         mQuirks = quirks;
         mHasIncorrectJpegMetadataQuirk = quirks.contains(IncorrectJpegMetadataQuirk.class);
     }
@@ -216,17 +229,33 @@
     ImageCapture.OutputFileResults processOnDiskCapture(@NonNull InputPacket inputPacket)
             throws ImageCaptureException {
         int format = mInputEdge.getOutputFormat();
-        checkArgument(isJpegFormats(format), String.format("On-disk capture only support JPEG and"
-                + " JPEG/R output formats. Output format: %s", format));
+        checkArgument(isJpegFormats(format)
+                || isRawFormats(format),
+                String.format("On-disk capture only support JPEG and"
+                + " JPEG/R and RAW output formats. Output format: %s", format));
         ProcessingRequest request = inputPacket.getProcessingRequest();
         Packet<ImageProxy> originalImage = mInput2Packet.apply(inputPacket);
-        Packet<byte[]> jpegBytes = mImage2JpegBytes.apply(
-                Image2JpegBytes.In.of(originalImage, request.getJpegQuality()));
-        if (jpegBytes.hasCropping() || mBitmapEffect != null) {
-            jpegBytes = cropAndMaybeApplyEffect(jpegBytes, request.getJpegQuality());
+
+        switch (format) {
+            case RAW_SENSOR:
+                DngImage2Disk dngImage2Disk = new DngImage2Disk(
+                        requireNonNull(mCameraCharacteristics),
+                        originalImage.getCameraCaptureResult().getCaptureResult());
+                return dngImage2Disk.apply(DngImage2Disk.In.of(
+                        originalImage.getData(),
+                        originalImage.getRotationDegrees(),
+                        requireNonNull(request.getOutputFileOptions())));
+            case JPEG:
+            default:
+                Packet<byte[]> jpegBytes = mImage2JpegBytes.apply(
+                        Image2JpegBytes.In.of(originalImage, request.getJpegQuality()));
+                if (jpegBytes.hasCropping() || mBitmapEffect != null) {
+                    jpegBytes = cropAndMaybeApplyEffect(jpegBytes, request.getJpegQuality());
+                }
+                return mJpegBytes2Disk.apply(
+                        JpegBytes2Disk.In.of(jpegBytes,
+                                requireNonNull(request.getOutputFileOptions())));
         }
-        return mJpegBytes2Disk.apply(
-                JpegBytes2Disk.In.of(jpegBytes, requireNonNull(request.getOutputFileOptions())));
     }
 
     @NonNull
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/internal/CameraUseCaseAdapter.java b/camera/camera-core/src/main/java/androidx/camera/core/internal/CameraUseCaseAdapter.java
index dc922ee..498abdf 100644
--- a/camera/camera-core/src/main/java/androidx/camera/core/internal/CameraUseCaseAdapter.java
+++ b/camera/camera-core/src/main/java/androidx/camera/core/internal/CameraUseCaseAdapter.java
@@ -23,6 +23,7 @@
 import static androidx.camera.core.DynamicRange.ENCODING_SDR;
 import static androidx.camera.core.DynamicRange.ENCODING_UNSPECIFIED;
 import static androidx.camera.core.ImageCapture.OUTPUT_FORMAT_JPEG_ULTRA_HDR;
+import static androidx.camera.core.ImageCapture.OUTPUT_FORMAT_RAW;
 import static androidx.camera.core.impl.ImageCaptureConfig.OPTION_OUTPUT_FORMAT;
 import static androidx.camera.core.impl.UseCaseConfig.OPTION_CAPTURE_TYPE;
 import static androidx.camera.core.impl.utils.TransformUtils.rectToSize;
@@ -1041,14 +1042,20 @@
                 throw new IllegalArgumentException("Extensions are not supported for use with "
                         + "Ultra HDR image capture.");
             }
+
+            if (hasRawImageCapture(useCases)) {
+                throw new IllegalArgumentException("Extensions are not supported for use with "
+                        + "Raw image capture.");
+            }
         }
 
         // TODO(b/322311893): throw exception to block feature combination of effect with Ultra
         //  HDR, until ImageProcessor and SurfaceProcessor can support JPEG/R format.
         synchronized (mLock) {
-            if (!mEffects.isEmpty() && hasUltraHdrImageCapture(useCases)) {
-                throw new IllegalArgumentException("Ultra HDR image capture does not support for "
-                        + "use with CameraEffect.");
+            if (!mEffects.isEmpty() && (hasUltraHdrImageCapture(useCases)
+                    || hasRawImageCapture(useCases))) {
+                throw new IllegalArgumentException("Ultra HDR image and Raw capture does not "
+                        + "support for use with CameraEffect.");
             }
         }
     }
@@ -1088,6 +1095,23 @@
         return false;
     }
 
+    private static boolean hasRawImageCapture(@NonNull Collection<UseCase> useCases) {
+        for (UseCase useCase : useCases) {
+            if (!isImageCapture(useCase)) {
+                continue;
+            }
+
+            UseCaseConfig<?> config = useCase.getCurrentConfig();
+            if (config.containsOption(OPTION_OUTPUT_FORMAT)
+                    && (checkNotNull(config.retrieveOption(OPTION_OUTPUT_FORMAT))
+                    == OUTPUT_FORMAT_RAW)) {
+                return true;
+            }
+
+        }
+        return false;
+    }
+
     /**
      * An identifier for a {@link CameraUseCaseAdapter}.
      *
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/internal/compat/quirk/IncorrectJpegMetadataQuirk.java b/camera/camera-core/src/main/java/androidx/camera/core/internal/compat/quirk/IncorrectJpegMetadataQuirk.java
index 98e547c..217fca5 100644
--- a/camera/camera-core/src/main/java/androidx/camera/core/internal/compat/quirk/IncorrectJpegMetadataQuirk.java
+++ b/camera/camera-core/src/main/java/androidx/camera/core/internal/compat/quirk/IncorrectJpegMetadataQuirk.java
@@ -30,16 +30,19 @@
 
 /**
  * <p>QuirkSummary
- *     Bug Id: 309005680
+ *     Bug Id: 309005680, 356428987
  *     Description: Quirk required to check whether the captured JPEG image has incorrect metadata.
  *                  For example, Samsung A24 device has the problem and result in the captured
- *                  image can't be parsed and saved successfully.
- *     Device(s): Samsung Galaxy A24 device.
+ *                  image can't be parsed and saved successfully. Samsung S10e and S10+ devices are
+ *                  also reported to have the similar issue.
+ *     Device(s): Samsung Galaxy A24, S10e, S10+ device.
  */
 public final class IncorrectJpegMetadataQuirk implements Quirk {
 
     private static final Set<String> SAMSUNG_DEVICES = new HashSet<>(Arrays.asList(
-            "A24" // Samsung Galaxy A24 series devices
+            "A24", // Samsung Galaxy A24 series devices
+            "BEYOND0", // Samsung Galaxy S10e series devices
+            "BEYOND2" // Samsung Galaxy S10+ series devices
     ));
 
     static boolean load() {
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/internal/utils/ImageUtil.java b/camera/camera-core/src/main/java/androidx/camera/core/internal/utils/ImageUtil.java
index 88dea73..c5c01ec 100644
--- a/camera/camera-core/src/main/java/androidx/camera/core/internal/utils/ImageUtil.java
+++ b/camera/camera-core/src/main/java/androidx/camera/core/internal/utils/ImageUtil.java
@@ -340,6 +340,11 @@
         return imageFormat == ImageFormat.JPEG || imageFormat == ImageFormat.JPEG_R;
     }
 
+    /** True if the given image format is RAW_SENSOR. */
+    public static boolean isRawFormats(int imageFormat) {
+        return imageFormat == ImageFormat.RAW_SENSOR;
+    }
+
     /** True if the given aspect ratio is meaningful and has effect on the given size. */
     public static boolean isAspectRatioValid(@NonNull Size sourceSize,
             @Nullable Rational aspectRatio) {
diff --git a/camera/camera-core/src/test/java/androidx/camera/core/imagecapture/DngImage2DiskTest.kt b/camera/camera-core/src/test/java/androidx/camera/core/imagecapture/DngImage2DiskTest.kt
new file mode 100644
index 0000000..ff91a2c
--- /dev/null
+++ b/camera/camera-core/src/test/java/androidx/camera/core/imagecapture/DngImage2DiskTest.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.core.imagecapture
+
+import android.graphics.ImageFormat
+import android.hardware.camera2.DngCreator
+import android.media.ExifInterface
+import android.media.Image
+import android.os.Build
+import androidx.camera.core.ImageCapture.OutputFileOptions
+import androidx.camera.core.imagecapture.FileUtil.moveFileToTarget
+import androidx.camera.core.imagecapture.Utils.ROTATION_DEGREES
+import androidx.camera.core.imagecapture.Utils.TEMP_FILE
+import androidx.camera.testing.impl.fakes.FakeImageInfo
+import androidx.camera.testing.impl.fakes.FakeImageProxy
+import com.google.common.truth.Truth.assertThat
+import java.io.File
+import java.io.OutputStream
+import java.util.UUID
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.ArgumentMatchers.any
+import org.mockito.ArgumentMatchers.eq
+import org.mockito.Mockito.mock
+import org.mockito.Mockito.verify
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import org.robolectric.annotation.internal.DoNotInstrument
+
+/** Unit tests for [DngImage2Disk] */
+@RunWith(RobolectricTestRunner::class)
+@DoNotInstrument
+@Config(minSdk = Build.VERSION_CODES.LOLLIPOP)
+class DngImage2DiskTest {
+
+    private val dngCreator = mock(DngCreator::class.java)
+    private val operation = DngImage2Disk(dngCreator)
+
+    @Test
+    fun copyToDestination_tempFileDeleted() {
+        // Arrange: create a file with a string.
+        val fileContent = "fileContent"
+        TEMP_FILE.writeText(fileContent, Charsets.UTF_8)
+        val destination =
+            File.createTempFile("unit_test_" + UUID.randomUUID().toString(), ".temp").also {
+                it.deleteOnExit()
+            }
+        // Act: move the file to the destination.
+        moveFileToTarget(TEMP_FILE, OutputFileOptions.Builder(destination).build())
+        // Assert: the temp file is deleted and the destination file has the same content.
+        assertThat(File(TEMP_FILE.absolutePath).exists()).isFalse()
+        assertThat(File(destination.absolutePath).readText(Charsets.UTF_8)).isEqualTo(fileContent)
+    }
+
+    @Test
+    fun writeImageToFile_dngCreatorCalled() {
+        val options = OutputFileOptions.Builder(TEMP_FILE).build()
+        val imageProxy = FakeImageProxy(FakeImageInfo())
+        imageProxy.format = ImageFormat.RAW_SENSOR
+        imageProxy.image = mock(Image::class.java)
+        val input = DngImage2Disk.In.of(imageProxy, ROTATION_DEGREES, options)
+
+        val result = operation.apply(input)
+        assertThat(result.savedUri).isNotNull()
+        assertThat(result.savedUri?.path).isEqualTo(TEMP_FILE.absolutePath)
+        verify(dngCreator).setOrientation(ExifInterface.ORIENTATION_ROTATE_180)
+        verify(dngCreator).writeImage(any(OutputStream::class.java), eq(imageProxy.image!!))
+    }
+}
diff --git a/camera/camera-core/src/test/java/androidx/camera/core/imagecapture/FakeImagePipeline.kt b/camera/camera-core/src/test/java/androidx/camera/core/imagecapture/FakeImagePipeline.kt
index 637dee6..54de138 100644
--- a/camera/camera-core/src/test/java/androidx/camera/core/imagecapture/FakeImagePipeline.kt
+++ b/camera/camera-core/src/test/java/androidx/camera/core/imagecapture/FakeImagePipeline.kt
@@ -16,6 +16,7 @@
 
 package androidx.camera.core.imagecapture
 
+import android.hardware.camera2.CameraCharacteristics
 import android.util.Size
 import androidx.annotation.MainThread
 import androidx.camera.core.ImageCaptureException
@@ -25,10 +26,14 @@
 import androidx.camera.core.impl.ImageCaptureConfig
 import androidx.core.util.Pair
 import com.google.common.util.concurrent.ListenableFuture
+import org.mockito.Mockito.mock
 
 /** Fake [ImagePipeline] class for testing. */
-class FakeImagePipeline(config: ImageCaptureConfig, cameraSurfaceSize: Size) :
-    ImagePipeline(config, cameraSurfaceSize) {
+class FakeImagePipeline(
+    config: ImageCaptureConfig,
+    cameraSurfaceSize: Size,
+    cameraCharacteristics: CameraCharacteristics
+) : ImagePipeline(config, cameraSurfaceSize, cameraCharacteristics) {
 
     private var currentProcessingRequest: ProcessingRequest? = null
     private var receivedProcessingRequest: MutableSet<ProcessingRequest> = mutableSetOf()
@@ -43,7 +48,12 @@
         var sNextRequestId = 0
     }
 
-    constructor() : this(createEmptyImageCaptureConfig(), Size(640, 480))
+    constructor() :
+        this(
+            createEmptyImageCaptureConfig(),
+            Size(640, 480),
+            mock(CameraCharacteristics::class.java)
+        )
 
     @MainThread
     internal override fun createRequests(
diff --git a/camera/camera-core/src/test/java/androidx/camera/core/imagecapture/ImagePipelineTest.kt b/camera/camera-core/src/test/java/androidx/camera/core/imagecapture/ImagePipelineTest.kt
index bcfa85b..8ede6e5 100644
--- a/camera/camera-core/src/test/java/androidx/camera/core/imagecapture/ImagePipelineTest.kt
+++ b/camera/camera-core/src/test/java/androidx/camera/core/imagecapture/ImagePipelineTest.kt
@@ -18,6 +18,7 @@
 
 import android.graphics.ImageFormat
 import android.graphics.Rect
+import android.hardware.camera2.CameraCharacteristics
 import android.hardware.camera2.CameraDevice
 import android.os.Build
 import android.os.Looper.getMainLooper
@@ -59,6 +60,7 @@
 import androidx.camera.testing.impl.TestImageUtil.createJpegFakeImageProxy
 import androidx.camera.testing.impl.TestImageUtil.createJpegrBytes
 import androidx.camera.testing.impl.TestImageUtil.createJpegrFakeImageProxy
+import androidx.camera.testing.impl.TestImageUtil.createRawFakeImageProxy
 import androidx.camera.testing.impl.TestImageUtil.createYuvFakeImageProxy
 import androidx.camera.testing.impl.fakes.FakeImageInfo
 import androidx.camera.testing.impl.fakes.FakeImageReaderProxy
@@ -69,6 +71,7 @@
 import org.junit.Before
 import org.junit.Test
 import org.junit.runner.RunWith
+import org.mockito.Mockito.mock
 import org.robolectric.RobolectricTestRunner
 import org.robolectric.Shadows.shadowOf
 import org.robolectric.annotation.Config
@@ -90,11 +93,13 @@
 
     private lateinit var imagePipeline: ImagePipeline
     private lateinit var imageCaptureConfig: ImageCaptureConfig
+    private lateinit var cameraCharacteristics: CameraCharacteristics
 
     @Before
     fun setUp() {
         imageCaptureConfig = createImageCaptureConfig()
-        imagePipeline = ImagePipeline(imageCaptureConfig, SIZE)
+        cameraCharacteristics = mock(CameraCharacteristics::class.java)
+        imagePipeline = ImagePipeline(imageCaptureConfig, SIZE, cameraCharacteristics)
     }
 
     @After
@@ -114,7 +119,7 @@
                 .setCaptureOptionUnpacker { _, builder -> builder.templateType = TEMPLATE_TYPE }
         builder.mutableConfig.insertOption(ImageInputConfig.OPTION_INPUT_FORMAT, ImageFormat.JPEG)
         // Act.
-        val pipeline = ImagePipeline(builder.useCaseConfig, SIZE)
+        val pipeline = ImagePipeline(builder.useCaseConfig, SIZE, cameraCharacteristics)
         // Assert.
         assertThat(pipeline.captureNode.inputEdge.imageReaderProxyProvider)
             .isEqualTo(imageReaderProxyProvider)
@@ -133,6 +138,7 @@
             ImagePipeline(
                 imageCaptureConfig,
                 SIZE,
+                cameraCharacteristics,
                 /*cameraEffect=*/ null,
                 /*isVirtualCamera=*/ true
             )
@@ -149,7 +155,13 @@
     @Test
     fun createPipelineWithEffect_processingNodeContainsEffect() {
         assertThat(
-                ImagePipeline(imageCaptureConfig, SIZE, GrayscaleImageEffect(), false)
+                ImagePipeline(
+                        imageCaptureConfig,
+                        SIZE,
+                        cameraCharacteristics,
+                        GrayscaleImageEffect(),
+                        false
+                    )
                     .processingNode
                     .mImageProcessor
             )
@@ -174,7 +186,22 @@
     fun createRequests_verifyCameraRequest_whenFormatIsJpegr() {
         // Arrange.
         imageCaptureConfig = createImageCaptureConfig(inputFormat = ImageFormat.JPEG_R)
-        imagePipeline = ImagePipeline(imageCaptureConfig, SIZE)
+        imagePipeline = ImagePipeline(imageCaptureConfig, SIZE, cameraCharacteristics)
+        val captureInput = imagePipeline.captureNode.inputEdge
+
+        // Act: create requests
+        val result =
+            imagePipeline.createRequests(IN_MEMORY_REQUEST, CALLBACK, Futures.immediateFuture(null))
+
+        // Assert: CameraRequest is constructed correctly.
+        verifyCaptureRequest(captureInput, result)
+    }
+
+    @Test
+    fun createRequests_verifyCameraRequest_whenFormatIsRAw() {
+        // Arrange.
+        imageCaptureConfig = createImageCaptureConfig(inputFormat = ImageFormat.RAW_SENSOR)
+        imagePipeline = ImagePipeline(imageCaptureConfig, SIZE, cameraCharacteristics)
         val captureInput = imagePipeline.captureNode.inputEdge
 
         // Act: create requests
@@ -245,6 +272,7 @@
             ImagePipeline(
                 imageCaptureConfig,
                 SIZE,
+                cameraCharacteristics,
                 null,
                 false,
                 postviewSize,
@@ -267,7 +295,15 @@
         // Arrange.
         val postviewSize = Size(640, 480)
         imagePipeline =
-            ImagePipeline(imageCaptureConfig, SIZE, null, false, postviewSize, ImageFormat.JPEG)
+            ImagePipeline(
+                imageCaptureConfig,
+                SIZE,
+                cameraCharacteristics,
+                null,
+                false,
+                postviewSize,
+                ImageFormat.JPEG
+            )
 
         // Act: create SessionConfig
         val sessionConfig = imagePipeline.createSessionConfigBuilder(SIZE).build()
@@ -288,6 +324,7 @@
             ImagePipeline(
                 imageCaptureConfig,
                 SIZE,
+                cameraCharacteristics,
                 null,
                 false,
                 postviewSize,
@@ -356,7 +393,7 @@
         builder.mutableConfig.insertOption(OPTION_BUFFER_FORMAT, ImageFormat.YUV_420_888)
         builder.mutableConfig.insertOption(OPTION_IO_EXECUTOR, mainThreadExecutor())
         builder.mutableConfig.insertOption(ImageInputConfig.OPTION_INPUT_FORMAT, ImageFormat.JPEG)
-        val pipeline = ImagePipeline(builder.useCaseConfig, SIZE)
+        val pipeline = ImagePipeline(builder.useCaseConfig, SIZE, cameraCharacteristics)
 
         // Arrange & act.
         sendInMemoryRequest(pipeline, ImageFormat.YUV_420_888)
@@ -380,7 +417,7 @@
     fun sendInMemoryRequest_receivesImageProxy_whenFormatIsJpegr() {
         // Arrange & act.
         imageCaptureConfig = createImageCaptureConfig(inputFormat = ImageFormat.JPEG_R)
-        imagePipeline = ImagePipeline(imageCaptureConfig, SIZE)
+        imagePipeline = ImagePipeline(imageCaptureConfig, SIZE, cameraCharacteristics)
         val image = sendInMemoryRequest(imagePipeline, ImageFormat.JPEG_R)
 
         // Assert: the image is received by TakePictureCallback.
@@ -389,6 +426,19 @@
         assertThat(CALLBACK.inMemoryResult!!.planes).isEqualTo(image.planes)
     }
 
+    @Test
+    fun sendInMemoryRequest_receivesImageProxy_whenFormatIsRaw() {
+        // Arrange & act.
+        imageCaptureConfig = createImageCaptureConfig(inputFormat = ImageFormat.RAW_SENSOR)
+        imagePipeline = ImagePipeline(imageCaptureConfig, SIZE, cameraCharacteristics)
+        val image = sendInMemoryRequest(imagePipeline, ImageFormat.RAW_SENSOR)
+
+        // Assert: the image is received by TakePictureCallback.
+        assertThat(image.format).isEqualTo(ImageFormat.RAW_SENSOR)
+        assertThat(CALLBACK.inMemoryResult!!.format).isEqualTo(ImageFormat.RAW_SENSOR)
+        assertThat(CALLBACK.inMemoryResult!!.planes).isEqualTo(image.planes)
+    }
+
     /** Creates a ImageProxy and sends it to the pipeline. */
     private fun sendInMemoryRequest(
         pipeline: ImagePipeline,
@@ -416,6 +466,9 @@
                     val jpegBytes = createJpegrBytes(WIDTH, HEIGHT)
                     createJpegrFakeImageProxy(imageInfo, jpegBytes)
                 }
+                ImageFormat.RAW_SENSOR -> {
+                    createRawFakeImageProxy(imageInfo, WIDTH, HEIGHT)
+                }
                 else -> {
                     val jpegBytes = createJpegBytes(WIDTH, HEIGHT)
                     createJpegFakeImageProxy(imageInfo, jpegBytes)
diff --git a/camera/camera-core/src/test/java/androidx/camera/core/imagecapture/JpegBytes2DiskTest.kt b/camera/camera-core/src/test/java/androidx/camera/core/imagecapture/JpegBytes2DiskTest.kt
index c479676..b4738db 100644
--- a/camera/camera-core/src/test/java/androidx/camera/core/imagecapture/JpegBytes2DiskTest.kt
+++ b/camera/camera-core/src/test/java/androidx/camera/core/imagecapture/JpegBytes2DiskTest.kt
@@ -24,7 +24,7 @@
 import android.util.Size
 import androidx.camera.core.ImageCapture
 import androidx.camera.core.ImageCapture.OutputFileOptions
-import androidx.camera.core.imagecapture.JpegBytes2Disk.moveFileToTarget
+import androidx.camera.core.imagecapture.FileUtil.moveFileToTarget
 import androidx.camera.core.imagecapture.Utils.ALTITUDE
 import androidx.camera.core.imagecapture.Utils.CAMERA_CAPTURE_RESULT
 import androidx.camera.core.imagecapture.Utils.EXIF_DESCRIPTION
diff --git a/camera/camera-core/src/test/java/androidx/camera/core/imagecapture/ProcessingNodeTest.kt b/camera/camera-core/src/test/java/androidx/camera/core/imagecapture/ProcessingNodeTest.kt
index d6139bf..b6ab8c8 100644
--- a/camera/camera-core/src/test/java/androidx/camera/core/imagecapture/ProcessingNodeTest.kt
+++ b/camera/camera-core/src/test/java/androidx/camera/core/imagecapture/ProcessingNodeTest.kt
@@ -18,6 +18,7 @@
 
 import android.graphics.ImageFormat
 import android.graphics.Rect
+import android.hardware.camera2.CameraCharacteristics
 import android.os.Build
 import android.os.Looper.getMainLooper
 import androidx.camera.core.ImageCaptureException
@@ -43,6 +44,7 @@
 import org.junit.Before
 import org.junit.Test
 import org.junit.runner.RunWith
+import org.mockito.Mockito.mock
 import org.robolectric.RobolectricTestRunner
 import org.robolectric.Shadows.shadowOf
 import org.robolectric.annotation.Config
@@ -56,8 +58,10 @@
 class ProcessingNodeTest {
 
     private lateinit var processingNodeIn: ProcessingNode.In
+    private var cameraCharacteristics: CameraCharacteristics =
+        mock(CameraCharacteristics::class.java)
 
-    private var node = ProcessingNode(mainThreadExecutor())
+    private var node = ProcessingNode(mainThreadExecutor(), cameraCharacteristics)
 
     @Before
     fun setUp() {
@@ -219,7 +223,12 @@
     fun singleExecutorForLowMemoryQuirkEnabled() {
         listOf("sm-a520w", "motog3").forEach { model ->
             setStaticField(Build::class.java, "MODEL", model)
-            assertThat(isSequentialExecutor(ProcessingNode(mainThreadExecutor()).mBlockingExecutor))
+            assertThat(
+                    isSequentialExecutor(
+                        ProcessingNode(mainThreadExecutor(), cameraCharacteristics)
+                            .mBlockingExecutor
+                    )
+                )
                 .isTrue()
         }
     }
@@ -230,7 +239,7 @@
         setStaticField(Build::class.java, "DEVICE", "a24")
 
         // Creates the ProcessingNode after updating the device name to load the correct quirks
-        node = ProcessingNode(mainThreadExecutor())
+        node = ProcessingNode(mainThreadExecutor(), cameraCharacteristics)
 
         processingNodeIn = ProcessingNode.In.of(ImageFormat.JPEG, ImageFormat.JPEG)
         node.transform(processingNodeIn)
diff --git a/camera/camera-core/src/test/java/androidx/camera/core/imagecapture/TakePictureManagerTest.kt b/camera/camera-core/src/test/java/androidx/camera/core/imagecapture/TakePictureManagerTest.kt
index 4f02ad7..62f161b 100644
--- a/camera/camera-core/src/test/java/androidx/camera/core/imagecapture/TakePictureManagerTest.kt
+++ b/camera/camera-core/src/test/java/androidx/camera/core/imagecapture/TakePictureManagerTest.kt
@@ -16,6 +16,7 @@
 
 package androidx.camera.core.imagecapture
 
+import android.hardware.camera2.CameraCharacteristics
 import android.os.Build
 import android.os.Looper.getMainLooper
 import android.util.Size
@@ -35,6 +36,7 @@
 import org.junit.After
 import org.junit.Test
 import org.junit.runner.RunWith
+import org.mockito.Mockito.mock
 import org.robolectric.RobolectricTestRunner
 import org.robolectric.Shadows.shadowOf
 import org.robolectric.annotation.Config
@@ -52,6 +54,7 @@
     private val takePictureManager =
         TakePictureManagerImpl(imageCaptureControl).also { it.imagePipeline = imagePipeline }
     private val exception = ImageCaptureException(ImageCapture.ERROR_UNKNOWN, "", null)
+    private val cameraCharacteristics = mock(CameraCharacteristics::class.java)
 
     @After
     fun tearDown() {
@@ -426,7 +429,11 @@
         // Arrange.
         // Uses the real ImagePipeline implementation to do the test
         takePictureManager.mImagePipeline =
-            ImagePipeline(Utils.createEmptyImageCaptureConfig(), Size(640, 480))
+            ImagePipeline(
+                Utils.createEmptyImageCaptureConfig(),
+                Size(640, 480),
+                cameraCharacteristics
+            )
         val request1 = FakeTakePictureRequest(FakeTakePictureRequest.Type.IN_MEMORY)
         val request2 = FakeTakePictureRequest(FakeTakePictureRequest.Type.IN_MEMORY)
 
@@ -448,7 +455,11 @@
         // Arrange.
         // Uses the real ImagePipeline implementation to do the test
         takePictureManager.mImagePipeline =
-            ImagePipeline(Utils.createEmptyImageCaptureConfig(), Size(640, 480))
+            ImagePipeline(
+                Utils.createEmptyImageCaptureConfig(),
+                Size(640, 480),
+                cameraCharacteristics
+            )
         val request1 = FakeTakePictureRequest(FakeTakePictureRequest.Type.IN_MEMORY)
         val request2 = FakeTakePictureRequest(FakeTakePictureRequest.Type.IN_MEMORY)
 
@@ -465,7 +476,11 @@
     fun requestFailure_failureReportedIfQuirkDisabled() {
         // Arrange: use the real ImagePipeline implementation to do the test
         takePictureManager.mImagePipeline =
-            ImagePipeline(Utils.createEmptyImageCaptureConfig(), Size(640, 480))
+            ImagePipeline(
+                Utils.createEmptyImageCaptureConfig(),
+                Size(640, 480),
+                cameraCharacteristics
+            )
 
         // Create a request and offer it to the manager.
         imageCaptureControl.shouldUsePendingResult = true
@@ -500,7 +515,11 @@
 
         // Use the real ImagePipeline implementation to do the test
         takePictureManager.mImagePipeline =
-            ImagePipeline(Utils.createEmptyImageCaptureConfig(), Size(640, 480))
+            ImagePipeline(
+                Utils.createEmptyImageCaptureConfig(),
+                Size(640, 480),
+                cameraCharacteristics
+            )
 
         // Create a request and offer it to the manager.
         imageCaptureControl.shouldUsePendingResult = true
diff --git a/camera/camera-core/src/test/java/androidx/camera/core/internal/CameraUseCaseAdapterTest.kt b/camera/camera-core/src/test/java/androidx/camera/core/internal/CameraUseCaseAdapterTest.kt
index 42652ae..d402847 100644
--- a/camera/camera-core/src/test/java/androidx/camera/core/internal/CameraUseCaseAdapterTest.kt
+++ b/camera/camera-core/src/test/java/androidx/camera/core/internal/CameraUseCaseAdapterTest.kt
@@ -18,6 +18,7 @@
 
 import android.graphics.ImageFormat.JPEG
 import android.graphics.ImageFormat.JPEG_R
+import android.graphics.ImageFormat.RAW_SENSOR
 import android.graphics.Matrix
 import android.graphics.Rect
 import android.os.Build
@@ -332,6 +333,36 @@
         adapter.addUseCases(setOf(imageCapture))
     }
 
+    @RequiresApi(23)
+    @Test(expected = CameraException::class)
+    fun useRawWithExtensions_throwsException() {
+        // Arrange: enable extensions.
+        val extensionsConfig = createCoexistingRequiredRuleCameraConfig(FakeSessionProcessor())
+        val cameraId = "fakeCameraId"
+        val fakeManager = FakeCameraDeviceSurfaceManager()
+        fakeManager.setValidSurfaceCombos(
+            setOf(listOf(INTERNAL_DEFINED_IMAGE_FORMAT_PRIVATE, RAW_SENSOR))
+        )
+        val fakeCamera = FakeCamera(cameraId)
+        val adapter =
+            CameraUseCaseAdapter(
+                fakeCamera,
+                null,
+                RestrictedCameraInfo(fakeCamera.cameraInfoInternal, extensionsConfig),
+                null,
+                CompositionSettings.DEFAULT,
+                CompositionSettings.DEFAULT,
+                FakeCameraCoordinator(),
+                fakeManager,
+                FakeUseCaseConfigFactory(),
+            )
+
+        // Act: add ImageCapture that sets Ultra HDR.
+        val imageCapture =
+            ImageCapture.Builder().setOutputFormat(ImageCapture.OUTPUT_FORMAT_RAW).build()
+        adapter.addUseCases(setOf(imageCapture))
+    }
+
     @RequiresApi(34) // Ultra HDR only supported on API 34+
     @Test(expected = CameraException::class)
     fun useUltraHdrWithCameraEffect_throwsException() {
@@ -359,6 +390,29 @@
     }
 
     @Test(expected = CameraException::class)
+    fun useRawWithCameraEffect_throwsException() {
+        // Arrange: add an image effect.
+        val cameraId = "fakeCameraId"
+        val fakeManager = FakeCameraDeviceSurfaceManager()
+        fakeManager.setValidSurfaceCombos(
+            setOf(listOf(INTERNAL_DEFINED_IMAGE_FORMAT_PRIVATE, RAW_SENSOR))
+        )
+        val adapter =
+            CameraUseCaseAdapter(
+                FakeCamera(cameraId),
+                FakeCameraCoordinator(),
+                fakeManager,
+                FakeUseCaseConfigFactory(),
+            )
+        adapter.setEffects(listOf(imageEffect))
+
+        // Act: add ImageCapture that sets Ultra HDR.
+        val imageCapture =
+            ImageCapture.Builder().setOutputFormat(ImageCapture.OUTPUT_FORMAT_RAW).build()
+        adapter.addUseCases(setOf(imageCapture))
+    }
+
+    @Test(expected = CameraException::class)
     fun addStreamSharing_throwsException() {
         val streamSharing =
             StreamSharing(
diff --git a/camera/camera-core/src/test/java/androidx/camera/core/internal/compat/workaround/JpegMetadataCorrectorTest.kt b/camera/camera-core/src/test/java/androidx/camera/core/internal/compat/workaround/JpegMetadataCorrectorTest.kt
index 9737c77..d1fb879 100644
--- a/camera/camera-core/src/test/java/androidx/camera/core/internal/compat/workaround/JpegMetadataCorrectorTest.kt
+++ b/camera/camera-core/src/test/java/androidx/camera/core/internal/compat/workaround/JpegMetadataCorrectorTest.kt
@@ -47,6 +47,20 @@
     }
 
     @Test
+    fun needCorrectJpegMetadataOnSamsungS10e() {
+        ReflectionHelpers.setStaticField(Build::class.java, "BRAND", "SAMSUNG")
+        ReflectionHelpers.setStaticField(Build::class.java, "DEVICE", "beyond0")
+        assertThat(JpegMetadataCorrector(DeviceQuirks.getAll()).needCorrectJpegMetadata()).isTrue()
+    }
+
+    @Test
+    fun needCorrectJpegMetadataOnSamsungS10Plus() {
+        ReflectionHelpers.setStaticField(Build::class.java, "BRAND", "SAMSUNG")
+        ReflectionHelpers.setStaticField(Build::class.java, "DEVICE", "beyond2")
+        assertThat(JpegMetadataCorrector(DeviceQuirks.getAll()).needCorrectJpegMetadata()).isTrue()
+    }
+
+    @Test
     fun doesNotNeedCorrectJpegMetadataOnSamsungA23() {
         ReflectionHelpers.setStaticField(Build::class.java, "BRAND", "SAMSUNG")
         ReflectionHelpers.setStaticField(Build::class.java, "DEVICE", "a23")
diff --git a/camera/camera-testing/src/main/java/androidx/camera/testing/fakes/FakeCameraInfoInternal.java b/camera/camera-testing/src/main/java/androidx/camera/testing/fakes/FakeCameraInfoInternal.java
index 92063b0..03b2840 100644
--- a/camera/camera-testing/src/main/java/androidx/camera/testing/fakes/FakeCameraInfoInternal.java
+++ b/camera/camera-testing/src/main/java/androidx/camera/testing/fakes/FakeCameraInfoInternal.java
@@ -50,6 +50,7 @@
 import androidx.core.util.Preconditions;
 import androidx.lifecycle.LiveData;
 import androidx.lifecycle.MutableLiveData;
+import androidx.test.core.app.ApplicationProvider;
 
 import java.util.ArrayList;
 import java.util.Arrays;
@@ -118,34 +119,36 @@
     }
 
     public FakeCameraInfoInternal(@NonNull String cameraId) {
-        this(cameraId, 0, CameraSelector.LENS_FACING_BACK, null);
+        this(cameraId, 0, CameraSelector.LENS_FACING_BACK,
+                ApplicationProvider.getApplicationContext());
     }
 
     public FakeCameraInfoInternal(@NonNull String cameraId,
             @CameraSelector.LensFacing int lensFacing) {
-        this(cameraId, 0, lensFacing, null);
+        this(cameraId, 0, lensFacing,
+                ApplicationProvider.getApplicationContext());
     }
 
     public FakeCameraInfoInternal(int sensorRotation, @CameraSelector.LensFacing int lensFacing) {
-        this("0", sensorRotation, lensFacing, null);
+        this("0", sensorRotation, lensFacing,
+                ApplicationProvider.getApplicationContext());
     }
 
     public FakeCameraInfoInternal(@NonNull String cameraId, int sensorRotation,
             @CameraSelector.LensFacing int lensFacing) {
-        this(cameraId, sensorRotation, lensFacing, null);
+        this(cameraId, sensorRotation, lensFacing,
+                ApplicationProvider.getApplicationContext());
     }
 
     @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
     public FakeCameraInfoInternal(@NonNull String cameraId, int sensorRotation,
             @CameraSelector.LensFacing int lensFacing,
-            @Nullable Context context) {
+            @NonNull Context context) {
         mCameraId = cameraId;
         mSensorRotation = sensorRotation;
         mLensFacing = lensFacing;
         mZoomLiveData = new MutableLiveData<>(ImmutableZoomState.create(1.0f, 4.0f, 1.0f, 0.0f));
-        if (context != null) {
-            mCameraManager = (CameraManager) context.getSystemService(Context.CAMERA_SERVICE);
-        }
+        mCameraManager = (CameraManager) context.getSystemService(Context.CAMERA_SERVICE);
     }
 
     /**
diff --git a/camera/camera-testing/src/main/java/androidx/camera/testing/impl/ImageProxyUtil.java b/camera/camera-testing/src/main/java/androidx/camera/testing/impl/ImageProxyUtil.java
index 00869ea4..db3c92a 100644
--- a/camera/camera-testing/src/main/java/androidx/camera/testing/impl/ImageProxyUtil.java
+++ b/camera/camera-testing/src/main/java/androidx/camera/testing/impl/ImageProxyUtil.java
@@ -69,6 +69,27 @@
         return planes;
     }
 
+    /**
+     * Creates {@link android.graphics.ImageFormat.RAW_SENSOR} image planes.
+     *
+     * @param width image width.
+     * @param height image height.
+     * @param incrementValue true if the data value will increment by position, e.g. 1, 2, 3, etc,.
+     * @return image planes in image proxy.
+     */
+    @NonNull
+    public static ImageProxy.PlaneProxy[] createRawImagePlanes(
+            final int width,
+            final int height,
+            final int pixelStride,
+            final boolean incrementValue) {
+        ImageProxy.PlaneProxy[] planes = new ImageProxy.PlaneProxy[1];
+
+        planes[0] =
+                createPlane(width, height, pixelStride, /*dataValue=*/ 1, incrementValue);
+        return planes;
+    }
+
     @NonNull
     private static ImageProxy.PlaneProxy createPlane(
             final int width,
diff --git a/camera/camera-testing/src/main/java/androidx/camera/testing/impl/TestImageUtil.java b/camera/camera-testing/src/main/java/androidx/camera/testing/impl/TestImageUtil.java
index 91d37ce..1914e96 100644
--- a/camera/camera-testing/src/main/java/androidx/camera/testing/impl/TestImageUtil.java
+++ b/camera/camera-testing/src/main/java/androidx/camera/testing/impl/TestImageUtil.java
@@ -19,8 +19,10 @@
 import static android.graphics.BitmapFactory.decodeByteArray;
 import static android.graphics.ImageFormat.JPEG;
 import static android.graphics.ImageFormat.JPEG_R;
+import static android.graphics.ImageFormat.RAW_SENSOR;
 import static android.graphics.ImageFormat.YUV_420_888;
 
+import static androidx.camera.testing.impl.ImageProxyUtil.createRawImagePlanes;
 import static androidx.camera.testing.impl.ImageProxyUtil.createYUV420ImagePlanes;
 import static androidx.core.util.Preconditions.checkState;
 
@@ -105,6 +107,20 @@
     }
 
     /**
+     * Creates a [FakeImageProxy] with [RAW_SENSOR] format.
+     */
+    @NonNull
+    public static FakeImageProxy createRawFakeImageProxy(@NonNull ImageInfo imageInfo,
+            int width, int height) {
+        FakeImageProxy image = new FakeImageProxy(imageInfo);
+        image.setFormat(RAW_SENSOR);
+        image.setPlanes(createRawImagePlanes(width, height, 2, false));
+        image.setWidth(width);
+        image.setHeight(height);
+        return image;
+    }
+
+    /**
      * Creates a {@link FakeImageProxy} from JPEG bytes.
      */
     @NonNull
diff --git a/camera/camera-testing/src/main/java/androidx/camera/testing/impl/testrule/CameraTestActivityScenarioRule.kt b/camera/camera-testing/src/main/java/androidx/camera/testing/impl/testrule/CameraTestActivityScenarioRule.kt
new file mode 100644
index 0000000..9c93ed9
--- /dev/null
+++ b/camera/camera-testing/src/main/java/androidx/camera/testing/impl/testrule/CameraTestActivityScenarioRule.kt
@@ -0,0 +1,49 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.camera.testing.impl.testrule
+
+import android.app.Activity
+import android.content.Intent
+import androidx.camera.testing.impl.InternalTestConvenience.useInCameraTest
+import androidx.test.core.app.ActivityScenario
+import org.junit.rules.TestRule
+import org.junit.runner.Description
+import org.junit.runners.model.Statement
+
+/**
+ * A [TestRule] to use [ActivityScenario] in a safer way for internal camera tests.
+ *
+ * See [useInCameraTest] for details.
+ */
+public class CameraTestActivityScenarioRule<A : Activity>
+private constructor(private val activityScenarioLazy: Lazy<ActivityScenario<A>>) : TestRule {
+    public constructor(
+        activityClass: Class<A>
+    ) : this(lazy { ActivityScenario.launch(activityClass) })
+
+    public constructor(intent: Intent) : this(lazy { ActivityScenario.launch(intent) })
+
+    public val scenario: ActivityScenario<A>
+        get() = activityScenarioLazy.value
+
+    override fun apply(base: Statement, description: Description): Statement =
+        object : Statement() {
+            override fun evaluate() {
+                scenario.useInCameraTest { base.evaluate() }
+            }
+        }
+}
diff --git a/camera/camera-view/src/androidTest/java/androidx/camera/view/PreviewViewBitmapTest.kt b/camera/camera-view/src/androidTest/java/androidx/camera/view/PreviewViewBitmapTest.kt
index 0da6eab..e62c82a 100644
--- a/camera/camera-view/src/androidTest/java/androidx/camera/view/PreviewViewBitmapTest.kt
+++ b/camera/camera-view/src/androidTest/java/androidx/camera/view/PreviewViewBitmapTest.kt
@@ -32,9 +32,9 @@
 import androidx.camera.testing.impl.CoreAppTestUtil
 import androidx.camera.testing.impl.fakes.FakeActivity
 import androidx.camera.testing.impl.fakes.FakeLifecycleOwner
+import androidx.camera.testing.impl.testrule.CameraTestActivityScenarioRule
 import androidx.lifecycle.Observer
 import androidx.test.core.app.ApplicationProvider
-import androidx.test.ext.junit.rules.ActivityScenarioRule
 import androidx.test.filters.LargeTest
 import androidx.test.filters.SdkSuppress
 import androidx.test.platform.app.InstrumentationRegistry
@@ -55,7 +55,7 @@
 @RunWith(Parameterized::class)
 @SdkSuppress(minSdkVersion = 21)
 class PreviewViewBitmapTest(private val implName: String, private val cameraConfig: CameraXConfig) {
-    @get:Rule val activityRule = ActivityScenarioRule(FakeActivity::class.java)
+    @get:Rule val activityRule = CameraTestActivityScenarioRule(FakeActivity::class.java)
 
     @get:Rule
     var useCamera =
diff --git a/camera/camera-view/src/androidTest/java/androidx/camera/view/PreviewViewStreamStateTest.kt b/camera/camera-view/src/androidTest/java/androidx/camera/view/PreviewViewStreamStateTest.kt
index 2467886..e9f52ea 100644
--- a/camera/camera-view/src/androidTest/java/androidx/camera/view/PreviewViewStreamStateTest.kt
+++ b/camera/camera-view/src/androidTest/java/androidx/camera/view/PreviewViewStreamStateTest.kt
@@ -30,10 +30,10 @@
 import androidx.camera.testing.impl.CoreAppTestUtil
 import androidx.camera.testing.impl.fakes.FakeActivity
 import androidx.camera.testing.impl.fakes.FakeLifecycleOwner
+import androidx.camera.testing.impl.testrule.CameraTestActivityScenarioRule
 import androidx.lifecycle.LifecycleOwner
 import androidx.lifecycle.Observer
 import androidx.test.core.app.ApplicationProvider
-import androidx.test.ext.junit.rules.ActivityScenarioRule
 import androidx.test.filters.FlakyTest
 import androidx.test.filters.LargeTest
 import androidx.test.filters.SdkSuppress
@@ -69,8 +69,8 @@
         CameraUtil.grantCameraPermissionAndPreTestAndPostTest(PreTestCameraIdList(cameraConfig))
 
     @get:Rule
-    val activityRule: ActivityScenarioRule<FakeActivity> =
-        ActivityScenarioRule(FakeActivity::class.java)
+    val activityRule: CameraTestActivityScenarioRule<FakeActivity> =
+        CameraTestActivityScenarioRule(FakeActivity::class.java)
 
     @get:Rule
     val cameraPipeConfigTestRule =
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 c8427ff..aec2319 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
@@ -29,6 +29,7 @@
 import static androidx.camera.core.ImageCapture.FLASH_MODE_SCREEN;
 import static androidx.camera.core.ImageCapture.OUTPUT_FORMAT_JPEG;
 import static androidx.camera.core.ImageCapture.OUTPUT_FORMAT_JPEG_ULTRA_HDR;
+import static androidx.camera.core.ImageCapture.OUTPUT_FORMAT_RAW;
 import static androidx.camera.core.ImageCapture.getImageCaptureCapabilities;
 import static androidx.camera.core.MirrorMode.MIRROR_MODE_ON_FRONT_ONLY;
 import static androidx.camera.integration.core.CameraXViewModel.getConfiguredCameraXCameraImplementation;
@@ -983,19 +984,9 @@
                     public void onClick(View view) {
                         mImageSavedIdlingResource.increment();
                         mStartCaptureTime = SystemClock.elapsedRealtime();
-                        createDefaultPictureFolderIfNotExist();
-                        Format formatter = new SimpleDateFormat("yyyy-MM-dd-HH-mm-ss-SSS",
-                                Locale.US);
-                        String fileName = "CoreTestApp-" + formatter.format(
-                                Calendar.getInstance().getTime()) + ".jpg";
-                        ContentValues contentValues = new ContentValues();
-                        contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, fileName);
-                        contentValues.put(MediaStore.MediaColumns.MIME_TYPE, "image/jpeg");
+
                         ImageCapture.OutputFileOptions outputFileOptions =
-                                new ImageCapture.OutputFileOptions.Builder(
-                                        getContentResolver(),
-                                        MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
-                                        contentValues).build();
+                                createOutputFileOptions(mImageOutputFormat);
                         getImageCapture().takePicture(outputFileOptions,
                                 mImageCaptureExecutorService,
                                 new ImageCapture.OnImageSavedCallback() {
@@ -1039,6 +1030,40 @@
                 });
     }
 
+    @SuppressLint("RestrictedApiAndroidX")
+    @NonNull
+    private ImageCapture.OutputFileOptions createOutputFileOptions(
+            @ImageCapture.OutputFormat int imageOutputFormat) {
+        createDefaultPictureFolderIfNotExist();
+        Format formatter = new SimpleDateFormat("yyyy-MM-dd-HH-mm-ss-SSS",
+                Locale.US);
+
+        String suffix = "";
+        String mimetype = "";
+        switch (imageOutputFormat) {
+            case OUTPUT_FORMAT_RAW:
+                suffix = ".dng";
+                mimetype = "image/x-adobe-dng";
+                break;
+            case OUTPUT_FORMAT_JPEG_ULTRA_HDR:
+            case OUTPUT_FORMAT_JPEG:
+                suffix = ".jpg";
+                mimetype = "image/jpeg";
+                break;
+        }
+        String fileName = "CoreTestApp-" + formatter.format(
+                Calendar.getInstance().getTime()) + suffix;
+
+        ContentValues contentValues = new ContentValues();
+        contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, fileName);
+        contentValues.put(MediaStore.MediaColumns.MIME_TYPE, mimetype);
+        return new ImageCapture.OutputFileOptions.Builder(
+                        getContentResolver(),
+                        MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
+                        contentValues).build();
+    }
+
+
     private String getImageCaptureErrorMessage(@NonNull ImageCaptureException exception) {
         String errorCodeString;
         int errorCode = exception.getImageCaptureError();
@@ -2620,36 +2645,46 @@
         return DYNAMIC_RANGE_UI_DATA.get(itemId).mDynamicRange;
     }
 
+    @SuppressLint("RestrictedApiAndroidX")
     @NonNull
     private static String getImageOutputFormatIconName(@ImageCapture.OutputFormat int format) {
         if (format == OUTPUT_FORMAT_JPEG) {
             return "Jpeg";
         } else if (format == OUTPUT_FORMAT_JPEG_ULTRA_HDR) {
             return "Ultra HDR";
+        } else if (format == OUTPUT_FORMAT_RAW) {
+            return "Raw";
         }
         return "?";
     }
 
+    @SuppressLint("RestrictedApiAndroidX")
     @NonNull
     private static String getImageOutputFormatMenuItemName(@ImageCapture.OutputFormat int format) {
         if (format == OUTPUT_FORMAT_JPEG) {
             return "Jpeg";
         } else if (format == OUTPUT_FORMAT_JPEG_ULTRA_HDR) {
             return "Ultra HDR";
+        } else if (format == OUTPUT_FORMAT_RAW) {
+            return "Raw";
         }
         return "Unknown format";
     }
 
+    @SuppressLint("RestrictedApiAndroidX")
     private static int imageOutputFormatToItemId(@ImageCapture.OutputFormat int format) {
         if (format == OUTPUT_FORMAT_JPEG) {
             return 0;
         } else if (format == OUTPUT_FORMAT_JPEG_ULTRA_HDR) {
             return 1;
+        } else if (format == OUTPUT_FORMAT_RAW) {
+            return 2;
         } else {
             throw new IllegalArgumentException("Undefined output format: " + format);
         }
     }
 
+    @SuppressLint("RestrictedApiAndroidX")
     @ImageCapture.OutputFormat
     private static int itemIdToImageOutputFormat(int itemId) {
         switch (itemId) {
@@ -2657,6 +2692,8 @@
                 return OUTPUT_FORMAT_JPEG;
             case 1:
                 return OUTPUT_FORMAT_JPEG_ULTRA_HDR;
+            case 2:
+                return OUTPUT_FORMAT_RAW;
             default:
                 throw new IllegalArgumentException("Undefined item id: " + itemId);
         }
diff --git a/camera/integration-tests/extensionstestapp/src/androidTest/java/androidx/camera/integration/extensions/ImageCaptureExtenderValidationTest.kt b/camera/integration-tests/extensionstestapp/src/androidTest/java/androidx/camera/integration/extensions/ImageCaptureExtenderValidationTest.kt
index 4dad639..355947d 100644
--- a/camera/integration-tests/extensionstestapp/src/androidTest/java/androidx/camera/integration/extensions/ImageCaptureExtenderValidationTest.kt
+++ b/camera/integration-tests/extensionstestapp/src/androidTest/java/androidx/camera/integration/extensions/ImageCaptureExtenderValidationTest.kt
@@ -21,8 +21,8 @@
 import android.hardware.camera2.CameraCharacteristics
 import android.os.Build
 import android.util.Rational
-import androidx.camera.camera2.interop.Camera2CameraInfo
 import androidx.camera.core.CameraSelector
+import androidx.camera.core.impl.CameraInfoInternal
 import androidx.camera.core.impl.utils.AspectRatioUtil
 import androidx.camera.core.internal.utils.SizeUtil
 import androidx.camera.extensions.ExtensionsManager
@@ -97,7 +97,8 @@
                 cameraProvider.bindToLifecycle(FakeLifecycleOwner(), extensionCameraSelector)
             }
 
-        cameraCharacteristics = Camera2CameraInfo.extractCameraCharacteristics(camera.cameraInfo)
+        cameraCharacteristics =
+            (camera.cameraInfo as CameraInfoInternal).cameraCharacteristics as CameraCharacteristics
     }
 
     @After
diff --git a/camera/integration-tests/extensionstestapp/src/androidTest/java/androidx/camera/integration/extensions/PreviewExtenderValidationTest.kt b/camera/integration-tests/extensionstestapp/src/androidTest/java/androidx/camera/integration/extensions/PreviewExtenderValidationTest.kt
index b7321cb..da1eb32 100644
--- a/camera/integration-tests/extensionstestapp/src/androidTest/java/androidx/camera/integration/extensions/PreviewExtenderValidationTest.kt
+++ b/camera/integration-tests/extensionstestapp/src/androidTest/java/androidx/camera/integration/extensions/PreviewExtenderValidationTest.kt
@@ -19,8 +19,8 @@
 import android.content.Context
 import android.hardware.camera2.CameraCharacteristics
 import android.os.Build
-import androidx.camera.camera2.interop.Camera2CameraInfo
 import androidx.camera.core.CameraSelector
+import androidx.camera.core.impl.CameraInfoInternal
 import androidx.camera.extensions.ExtensionsManager
 import androidx.camera.extensions.impl.PreviewExtenderImpl.ProcessorType
 import androidx.camera.extensions.impl.PreviewImageProcessorImpl
@@ -95,7 +95,8 @@
                 cameraProvider.bindToLifecycle(FakeLifecycleOwner(), extensionCameraSelector)
             }
 
-        cameraCharacteristics = Camera2CameraInfo.extractCameraCharacteristics(camera.cameraInfo)
+        cameraCharacteristics =
+            (camera.cameraInfo as CameraInfoInternal).cameraCharacteristics as CameraCharacteristics
     }
 
     @After
diff --git a/car/app/app/src/main/java/androidx/car/app/messaging/model/ConversationItem.java b/car/app/app/src/main/java/androidx/car/app/messaging/model/ConversationItem.java
index 1f975fd2..c94e4a0 100644
--- a/car/app/app/src/main/java/androidx/car/app/messaging/model/ConversationItem.java
+++ b/car/app/app/src/main/java/androidx/car/app/messaging/model/ConversationItem.java
@@ -340,13 +340,12 @@
          * <p>The host creates indexed lists to help users navigate through long lists more easily
          * by sorting, filtering, or some other means.
          *
-         * <p>For example, a media app may, by default, show a user's playlists sorted by date
-         * created. If the app provides these playlists via the {@code SectionedItemTemplate} and
-         * enables {@code #isAlphabeticalIndexingAllowed}, the user will be able to jump to their
-         * playlists that start with the letter "H". When this happens, the list is reconstructed
-         * and sorted alphabetically, then shown to the user, jumping down to the letter "H". If
-         * the item is set to {@code #setIndexable(false)}, the item will not show up in this newly
-         * sorted list.
+         * <p>For example, a messaging app may show conversations by last message received. If the
+         * app provides these conversations via the {@code SectionedItemTemplate} and enables
+         * {@code #isAlphabeticalIndexingAllowed}, the user will be able to jump to their
+         * conversations that start with a given letter they chose. The messaging app can choose
+         * to hide, for example, service messages from this filtered list by setting this {@code
+         * #setIndexable(false)}.
          *
          * <p>Individual items can be set to be included or excluded from filtered lists, but it's
          * also possible to enable/disable the creation of filtered lists as a whole via the
diff --git a/car/app/app/src/main/java/androidx/car/app/serialization/ListDelegate.kt b/car/app/app/src/main/java/androidx/car/app/serialization/ListDelegate.kt
index 4e99e87..355023f 100644
--- a/car/app/app/src/main/java/androidx/car/app/serialization/ListDelegate.kt
+++ b/car/app/app/src/main/java/androidx/car/app/serialization/ListDelegate.kt
@@ -32,7 +32,8 @@
     /**
      * Host-side interface for requesting items in range `[startIndex, endIndex]` (both inclusive).
      *
-     * The sublist is returned to the host as a [List], via [OnDoneCallback.onSuccess]
+     * The sublist is returned to the host as a [List], via [OnDoneCallback.onSuccess] on the main
+     * thread.
      */
     @SuppressLint("ExecutorRegistration")
     fun requestItemRange(startIndex: Int, endIndex: Int, callback: OnDoneCallback)
diff --git a/collection/collection/src/commonMain/kotlin/androidx/collection/internal/LockExt.kt b/collection/collection/src/commonMain/kotlin/androidx/collection/internal/LockExt.kt
index 16ba576..e0b4519 100644
--- a/collection/collection/src/commonMain/kotlin/androidx/collection/internal/LockExt.kt
+++ b/collection/collection/src/commonMain/kotlin/androidx/collection/internal/LockExt.kt
@@ -19,6 +19,9 @@
 import kotlin.contracts.InvocationKind
 import kotlin.contracts.contract
 
+// Workaround for applying callsInPlace to expect fun no longer works.
+// See: https://youtrack.jetbrains.com/issue/KT-29963
+@Suppress("LEAKED_IN_PLACE_LAMBDA", "WRONG_INVOCATION_KIND")
 internal inline fun <T> Lock.synchronized(block: () -> T): T {
     contract { callsInPlace(block, InvocationKind.EXACTLY_ONCE) }
     return synchronizedImpl(block)
diff --git a/collection/collection/src/nativeMain/kotlin/androidx/collection/internal/Lock.native.kt b/collection/collection/src/nativeMain/kotlin/androidx/collection/internal/Lock.native.kt
index 964b216..e0728603 100644
--- a/collection/collection/src/nativeMain/kotlin/androidx/collection/internal/Lock.native.kt
+++ b/collection/collection/src/nativeMain/kotlin/androidx/collection/internal/Lock.native.kt
@@ -38,7 +38,9 @@
 
     private val lockImpl = LockImpl()
 
-    @Suppress("unused") // The returned Cleaner must be assigned to a property
+    // unused - The returned Cleaner must be assigned to a property
+    // TODO(/365786168) Replace with kotlin.native.ref.createCleaner, after kotlin bump to 1.9+
+    @Suppress("unused", "DEPRECATION")
     @OptIn(ExperimentalStdlibApi::class)
     private val cleaner = createCleaner(lockImpl, LockImpl::destroy)
 
diff --git a/compose/foundation/foundation-layout/src/commonMain/kotlin/androidx/compose/foundation/layout/FlowLayout.kt b/compose/foundation/foundation-layout/src/commonMain/kotlin/androidx/compose/foundation/layout/FlowLayout.kt
index a263d46..05f1276 100644
--- a/compose/foundation/foundation-layout/src/commonMain/kotlin/androidx/compose/foundation/layout/FlowLayout.kt
+++ b/compose/foundation/foundation-layout/src/commonMain/kotlin/androidx/compose/foundation/layout/FlowLayout.kt
@@ -890,8 +890,8 @@
     if (children.isEmpty()) {
         return 0
     }
-    val mainAxisSizes = IntArray(children.size) { 0 }
-    val crossAxisSizes = IntArray(children.size) { 0 }
+    val mainAxisSizes = IntArray(children.size)
+    val crossAxisSizes = IntArray(children.size)
 
     for (index in children.indices) {
         val child = children[index]
diff --git a/compose/foundation/foundation-layout/src/commonMain/kotlin/androidx/compose/foundation/layout/RowColumnMeasurePolicy.kt b/compose/foundation/foundation-layout/src/commonMain/kotlin/androidx/compose/foundation/layout/RowColumnMeasurePolicy.kt
index e795e0b..0a9e390 100644
--- a/compose/foundation/foundation-layout/src/commonMain/kotlin/androidx/compose/foundation/layout/RowColumnMeasurePolicy.kt
+++ b/compose/foundation/foundation-layout/src/commonMain/kotlin/androidx/compose/foundation/layout/RowColumnMeasurePolicy.kt
@@ -255,7 +255,7 @@
             crossAxisSpace,
             max(crossAxisMin, beforeCrossAxisAlignmentLine + afterCrossAxisAlignmentLine)
         )
-    val mainAxisPositions = IntArray(subSize) { 0 }
+    val mainAxisPositions = IntArray(subSize)
     populateMainAxisPositions(
         mainAxisLayoutSize,
         childrenMainAxisSize,
@@ -276,8 +276,3 @@
         endIndex
     )
 }
-
-internal expect inline fun initCause(
-    exception: IllegalArgumentException,
-    cause: Exception
-): Throwable
diff --git a/compose/foundation/foundation/api/current.ignore b/compose/foundation/foundation/api/current.ignore
index 0d0e4ff..2763e69 100644
--- a/compose/foundation/foundation/api/current.ignore
+++ b/compose/foundation/foundation/api/current.ignore
@@ -21,3 +21,7 @@
 
 ParameterNameChange: androidx.compose.foundation.gestures.DraggableAnchors#positionOf(T) parameter #0:
     Attempted to change parameter name from value to anchor in method androidx.compose.foundation.gestures.DraggableAnchors.positionOf
+
+
+RemovedClass: androidx.compose.foundation.draganddrop.AndroidDragAndDropSource_androidKt:
+    Removed class androidx.compose.foundation.draganddrop.AndroidDragAndDropSource_androidKt
diff --git a/compose/foundation/foundation/api/current.txt b/compose/foundation/foundation/api/current.txt
index bc4dd47..6081c4a 100644
--- a/compose/foundation/foundation/api/current.txt
+++ b/compose/foundation/foundation/api/current.txt
@@ -371,16 +371,9 @@
 
 package androidx.compose.foundation.draganddrop {
 
-  public final class AndroidDragAndDropSource_androidKt {
-    method public static androidx.compose.ui.Modifier dragAndDropSource(androidx.compose.ui.Modifier, kotlin.jvm.functions.Function2<? super androidx.compose.foundation.draganddrop.DragAndDropSourceScope,? super kotlin.coroutines.Continuation<? super kotlin.Unit>,? extends java.lang.Object?> block);
-  }
-
   public final class DragAndDropSourceKt {
-    method public static androidx.compose.ui.Modifier dragAndDropSource(androidx.compose.ui.Modifier, kotlin.jvm.functions.Function1<? super androidx.compose.ui.graphics.drawscope.DrawScope,kotlin.Unit> drawDragDecoration, kotlin.jvm.functions.Function2<? super androidx.compose.foundation.draganddrop.DragAndDropSourceScope,? super kotlin.coroutines.Continuation<? super kotlin.Unit>,? extends java.lang.Object?> block);
-  }
-
-  public interface DragAndDropSourceScope extends androidx.compose.ui.input.pointer.PointerInputScope {
-    method public void startTransfer(androidx.compose.ui.draganddrop.DragAndDropTransferData transferData);
+    method public static androidx.compose.ui.Modifier dragAndDropSource(androidx.compose.ui.Modifier, kotlin.jvm.functions.Function1<? super androidx.compose.ui.geometry.Offset,androidx.compose.ui.draganddrop.DragAndDropTransferData?> transferData);
+    method public static androidx.compose.ui.Modifier dragAndDropSource(androidx.compose.ui.Modifier, kotlin.jvm.functions.Function1<? super androidx.compose.ui.graphics.drawscope.DrawScope,kotlin.Unit> drawDragDecoration, kotlin.jvm.functions.Function1<? super androidx.compose.ui.geometry.Offset,androidx.compose.ui.draganddrop.DragAndDropTransferData?> transferData);
   }
 
   public final class DragAndDropTargetKt {
diff --git a/compose/foundation/foundation/api/restricted_current.ignore b/compose/foundation/foundation/api/restricted_current.ignore
index 0d0e4ff..2763e69 100644
--- a/compose/foundation/foundation/api/restricted_current.ignore
+++ b/compose/foundation/foundation/api/restricted_current.ignore
@@ -21,3 +21,7 @@
 
 ParameterNameChange: androidx.compose.foundation.gestures.DraggableAnchors#positionOf(T) parameter #0:
     Attempted to change parameter name from value to anchor in method androidx.compose.foundation.gestures.DraggableAnchors.positionOf
+
+
+RemovedClass: androidx.compose.foundation.draganddrop.AndroidDragAndDropSource_androidKt:
+    Removed class androidx.compose.foundation.draganddrop.AndroidDragAndDropSource_androidKt
diff --git a/compose/foundation/foundation/api/restricted_current.txt b/compose/foundation/foundation/api/restricted_current.txt
index dbc823f..5f71eff 100644
--- a/compose/foundation/foundation/api/restricted_current.txt
+++ b/compose/foundation/foundation/api/restricted_current.txt
@@ -373,16 +373,9 @@
 
 package androidx.compose.foundation.draganddrop {
 
-  public final class AndroidDragAndDropSource_androidKt {
-    method public static androidx.compose.ui.Modifier dragAndDropSource(androidx.compose.ui.Modifier, kotlin.jvm.functions.Function2<? super androidx.compose.foundation.draganddrop.DragAndDropSourceScope,? super kotlin.coroutines.Continuation<? super kotlin.Unit>,? extends java.lang.Object?> block);
-  }
-
   public final class DragAndDropSourceKt {
-    method public static androidx.compose.ui.Modifier dragAndDropSource(androidx.compose.ui.Modifier, kotlin.jvm.functions.Function1<? super androidx.compose.ui.graphics.drawscope.DrawScope,kotlin.Unit> drawDragDecoration, kotlin.jvm.functions.Function2<? super androidx.compose.foundation.draganddrop.DragAndDropSourceScope,? super kotlin.coroutines.Continuation<? super kotlin.Unit>,? extends java.lang.Object?> block);
-  }
-
-  public interface DragAndDropSourceScope extends androidx.compose.ui.input.pointer.PointerInputScope {
-    method public void startTransfer(androidx.compose.ui.draganddrop.DragAndDropTransferData transferData);
+    method public static androidx.compose.ui.Modifier dragAndDropSource(androidx.compose.ui.Modifier, kotlin.jvm.functions.Function1<? super androidx.compose.ui.geometry.Offset,androidx.compose.ui.draganddrop.DragAndDropTransferData?> transferData);
+    method public static androidx.compose.ui.Modifier dragAndDropSource(androidx.compose.ui.Modifier, kotlin.jvm.functions.Function1<? super androidx.compose.ui.graphics.drawscope.DrawScope,kotlin.Unit> drawDragDecoration, kotlin.jvm.functions.Function1<? super androidx.compose.ui.geometry.Offset,androidx.compose.ui.draganddrop.DragAndDropTransferData?> transferData);
   }
 
   public final class DragAndDropTargetKt {
diff --git a/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/ListDemos.kt b/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/ListDemos.kt
index a328cd2..dfb7495 100644
--- a/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/ListDemos.kt
+++ b/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/ListDemos.kt
@@ -34,7 +34,6 @@
 import androidx.compose.foundation.gestures.FlingBehavior
 import androidx.compose.foundation.gestures.ScrollScope
 import androidx.compose.foundation.gestures.animateScrollBy
-import androidx.compose.foundation.gestures.detectTapGestures
 import androidx.compose.foundation.horizontalScroll
 import androidx.compose.foundation.interaction.collectIsDraggedAsState
 import androidx.compose.foundation.layout.Arrangement
@@ -1045,15 +1044,9 @@
 private fun LazyItemScope.DragAndDropItem(index: Int, color: Color) {
     Box(
         Modifier.dragAndDropSource {
-                detectTapGestures(
-                    onLongPress = {
-                        startTransfer(
-                            DragAndDropTransferData(
-                                clipData = ClipData.newPlainText("item_id", index.toString()),
-                                localState = index
-                            )
-                        )
-                    }
+                DragAndDropTransferData(
+                    clipData = ClipData.newPlainText("item_id", index.toString()),
+                    localState = index
                 )
             }
             .animateItem()
diff --git a/compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridContentPaddingTest.kt b/compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridContentPaddingTest.kt
index 84a7799..6131d42 100644
--- a/compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridContentPaddingTest.kt
+++ b/compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridContentPaddingTest.kt
@@ -20,7 +20,11 @@
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Spacer
 import androidx.compose.foundation.lazy.list.assertIsNotPlaced
+import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
 import androidx.compose.ui.platform.testTag
 import androidx.compose.ui.test.onNodeWithTag
 import androidx.compose.ui.unit.Dp
@@ -343,4 +347,37 @@
 
         rule.onNodeWithTag("19").assertMainAxisStartPositionInRootIsEqualTo(itemSizeDp * 3f)
     }
+
+    @Test
+    fun pinnedItemWorksIsPlacedOnceInContentPadding() {
+        state = LazyStaggeredGridState(initialFirstVisibleItemIndex = 0)
+        val focusRequester = FocusRequester()
+        rule.setContent {
+            Box(Modifier.axisSize(itemSizeDp * 2, itemSizeDp * 4)) {
+                LazyStaggeredGrid(
+                    lanes = 1,
+                    modifier = Modifier.testTag(LazyStaggeredGrid),
+                    contentPadding = PaddingValues(beforeContent = itemSizeDp),
+                    state = state
+                ) {
+                    item {
+                        LaunchedEffect(Unit) { focusRequester.requestFocus() }
+                        BasicTextField(
+                            "Test",
+                            onValueChange = {},
+                            modifier =
+                                Modifier.focusRequester(focusRequester).mainAxisSize(itemSizeDp)
+                        )
+                    }
+
+                    items(10) { Spacer(Modifier.mainAxisSize(itemSizeDp).testTag("$it")) }
+                }
+            }
+        }
+
+        // scroll to the end
+        state.scrollBy(itemSizeDp / 2)
+
+        rule.onNodeWithTag("0").assertMainAxisStartPositionInRootIsEqualTo(itemSizeDp * 1.5f)
+    }
 }
diff --git a/compose/foundation/foundation/samples/src/main/java/androidx/compose/foundation/samples/DragAndDropSamples.kt b/compose/foundation/foundation/samples/src/main/java/androidx/compose/foundation/samples/DragAndDropSamples.kt
index 75dd94e..4ba070c 100644
--- a/compose/foundation/foundation/samples/src/main/java/androidx/compose/foundation/samples/DragAndDropSamples.kt
+++ b/compose/foundation/foundation/samples/src/main/java/androidx/compose/foundation/samples/DragAndDropSamples.kt
@@ -33,7 +33,6 @@
 import androidx.compose.foundation.border
 import androidx.compose.foundation.draganddrop.dragAndDropSource
 import androidx.compose.foundation.draganddrop.dragAndDropTarget
-import androidx.compose.foundation.gestures.detectTapGestures
 import androidx.compose.foundation.horizontalScroll
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Box
@@ -117,15 +116,9 @@
         modifier =
             modifier
                 .dragAndDropSource {
-                    detectTapGestures(
-                        onLongPress = {
-                            startTransfer(
-                                DragAndDropTransferData(
-                                    clipData = ClipData.newPlainText(label, label),
-                                    flags = View.DRAG_FLAG_GLOBAL,
-                                )
-                            )
-                        }
+                    DragAndDropTransferData(
+                        clipData = ClipData.newPlainText(label, label),
+                        flags = View.DRAG_FLAG_GLOBAL,
                     )
                 }
                 .border(
@@ -158,6 +151,22 @@
         )
     }
     var backgroundColor by remember { mutableStateOf(Color.Transparent) }
+    val dragAndDropTarget = remember {
+        object : DragAndDropTarget {
+            override fun onStarted(event: DragAndDropEvent) {
+                backgroundColor = Color.DarkGray.copy(alpha = 0.2f)
+            }
+
+            override fun onDrop(event: DragAndDropEvent): Boolean {
+                onDragAndDropEventDropped(event)
+                return true
+            }
+
+            override fun onEnded(event: DragAndDropEvent) {
+                backgroundColor = Color.Transparent
+            }
+        }
+    }
     Box(
         modifier =
             Modifier.fillMaxSize()
@@ -169,21 +178,7 @@
                                 }
                             hasValidMimeType
                         },
-                    target =
-                        object : DragAndDropTarget {
-                            override fun onStarted(event: DragAndDropEvent) {
-                                backgroundColor = Color.DarkGray.copy(alpha = 0.2f)
-                            }
-
-                            override fun onDrop(event: DragAndDropEvent): Boolean {
-                                onDragAndDropEventDropped(event)
-                                return true
-                            }
-
-                            override fun onEnded(event: DragAndDropEvent) {
-                                backgroundColor = Color.Transparent
-                            }
-                        },
+                    target = dragAndDropTarget,
                 )
                 .background(backgroundColor)
                 .border(width = 4.dp, color = Color.Magenta, shape = RoundedCornerShape(16.dp)),
@@ -288,7 +283,7 @@
             Modifier.size(56.dp).background(color = color).dragAndDropSource(
                 drawDragDecoration = { drawRect(color) },
             ) {
-                detectTapGestures(onLongPress = { startTransfer(color.toDragAndDropTransfer()) })
+                color.toDragAndDropTransfer()
             }
     )
 }
@@ -325,15 +320,13 @@
     dragAndDropSource(
         drawDragDecoration = { drawRoundRect(state.color) },
     ) {
-        detectTapGestures(onLongPress = { startTransfer(state.color.toDragAndDropTransfer()) })
+        state.color.toDragAndDropTransfer()
     }
 
-private fun Modifier.stateDropTarget(state: State) =
-    dragAndDropTarget(
-        shouldStartDragAndDrop = { startEvent ->
-            startEvent.mimeTypes().contains(ClipDescription.MIMETYPE_TEXT_INTENT)
-        },
-        target =
+@Composable
+private fun Modifier.stateDropTarget(state: State): Modifier {
+    val dragAndDropTarget =
+        remember(state) {
             object : DragAndDropTarget {
                 override fun onStarted(event: DragAndDropEvent) {
                     state.onStarted()
@@ -371,7 +364,14 @@
                     }
                 }
             }
+        }
+    return dragAndDropTarget(
+        shouldStartDragAndDrop = { startEvent ->
+            startEvent.mimeTypes().contains(ClipDescription.MIMETYPE_TEXT_INTENT)
+        },
+        target = dragAndDropTarget
     )
+}
 
 @Stable
 private class State(
diff --git a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/content/internal/ReceiveContentDragAndDropNode.android.kt b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/content/internal/ReceiveContentDragAndDropNode.android.kt
index d3f1862..a4471df 100644
--- a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/content/internal/ReceiveContentDragAndDropNode.android.kt
+++ b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/content/internal/ReceiveContentDragAndDropNode.android.kt
@@ -19,8 +19,8 @@
 import androidx.compose.foundation.ExperimentalFoundationApi
 import androidx.compose.foundation.content.TransferableContent
 import androidx.compose.ui.draganddrop.DragAndDropEvent
-import androidx.compose.ui.draganddrop.DragAndDropModifierNode
 import androidx.compose.ui.draganddrop.DragAndDropTarget
+import androidx.compose.ui.draganddrop.DragAndDropTargetModifierNode
 import androidx.compose.ui.draganddrop.toAndroidDragEvent
 import androidx.compose.ui.platform.toClipEntry
 import androidx.compose.ui.platform.toClipMetadata
@@ -29,8 +29,8 @@
 internal actual fun ReceiveContentDragAndDropNode(
     receiveContentConfiguration: ReceiveContentConfiguration,
     dragAndDropRequestPermission: (DragAndDropEvent) -> Unit
-): DragAndDropModifierNode {
-    return DragAndDropModifierNode(
+): DragAndDropTargetModifierNode {
+    return DragAndDropTargetModifierNode(
         shouldStartDragAndDrop = {
             // accept any dragging item. The actual decider will be the onReceive callback.
             true
diff --git a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/draganddrop/AndroidDragAndDropSource.android.kt b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/draganddrop/AndroidDragAndDropSource.android.kt
index 9871638..d9aae34 100644
--- a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/draganddrop/AndroidDragAndDropSource.android.kt
+++ b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/draganddrop/AndroidDragAndDropSource.android.kt
@@ -17,90 +17,26 @@
 package androidx.compose.foundation.draganddrop
 
 import android.graphics.Picture
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.CacheDrawModifierNode
+import androidx.compose.foundation.gestures.detectTapGestures
+import androidx.compose.runtime.Immutable
 import androidx.compose.ui.draw.CacheDrawScope
 import androidx.compose.ui.draw.DrawResult
 import androidx.compose.ui.graphics.drawscope.DrawScope
 import androidx.compose.ui.graphics.drawscope.draw
 import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
 import androidx.compose.ui.graphics.nativeCanvas
-import androidx.compose.ui.input.pointer.PointerInputScope
-import androidx.compose.ui.node.DelegatingNode
-import androidx.compose.ui.node.ModifierNodeElement
-import androidx.compose.ui.platform.InspectorInfo
 
-/**
- * A Modifier that allows an element it is applied to to be treated like a source for drag and drop
- * operations. It displays the element dragged as a drag shadow.
- *
- * Learn how to use [Modifier.dragAndDropSource]:
- *
- * @sample androidx.compose.foundation.samples.TextDragAndDropSourceSample
- * @param block A lambda with a [DragAndDropSourceScope] as a receiver which provides a
- *   [PointerInputScope] to detect the drag gesture, after which a drag and drop gesture can be
- *   started with [DragAndDropSourceScope.startTransfer].
- */
-fun Modifier.dragAndDropSource(block: suspend DragAndDropSourceScope.() -> Unit): Modifier =
-    this then
-        DragAndDropSourceWithDefaultShadowElement(
-            dragAndDropSourceHandler = block,
-        )
-
-private class DragAndDropSourceWithDefaultShadowElement(
-    /** @see Modifier.dragAndDropSource */
-    val dragAndDropSourceHandler: suspend DragAndDropSourceScope.() -> Unit
-) : ModifierNodeElement<DragSourceNodeWithDefaultPainter>() {
-    override fun create() =
-        DragSourceNodeWithDefaultPainter(
-            dragAndDropSourceHandler = dragAndDropSourceHandler,
-        )
-
-    override fun update(node: DragSourceNodeWithDefaultPainter) =
-        with(node) {
-            dragAndDropSourceHandler =
-                this@DragAndDropSourceWithDefaultShadowElement.dragAndDropSourceHandler
-        }
-
-    override fun InspectorInfo.inspectableProperties() {
-        name = "dragSourceWithDefaultPainter"
-        properties["dragAndDropSourceHandler"] = dragAndDropSourceHandler
-    }
-
-    override fun equals(other: Any?): Boolean {
-        if (this === other) return true
-        if (other !is DragAndDropSourceWithDefaultShadowElement) return false
-
-        return dragAndDropSourceHandler == other.dragAndDropSourceHandler
-    }
-
-    override fun hashCode(): Int {
-        return dragAndDropSourceHandler.hashCode()
+@Immutable
+internal actual object DragAndDropSourceDefaults {
+    actual val DefaultStartDetector: DragAndDropStartDetector = {
+        detectTapGestures(onLongPress = { offset -> requestDragAndDropTransfer(offset) })
     }
 }
 
-private class DragSourceNodeWithDefaultPainter(
-    var dragAndDropSourceHandler: suspend DragAndDropSourceScope.() -> Unit
-) : DelegatingNode() {
-
-    init {
-        val cacheDrawScopeDragShadowCallback =
-            CacheDrawScopeDragShadowCallback().also {
-                delegate(CacheDrawModifierNode(it::cachePicture))
-            }
-        delegate(
-            DragAndDropSourceNode(
-                drawDragDecoration = { cacheDrawScopeDragShadowCallback.drawDragShadow(this) },
-                dragAndDropSourceHandler = { dragAndDropSourceHandler.invoke(this) }
-            )
-        )
-    }
-}
-
-private class CacheDrawScopeDragShadowCallback {
+internal actual class CacheDrawScopeDragShadowCallback {
     private var cachedPicture: Picture? = null
 
-    fun drawDragShadow(drawScope: DrawScope) =
+    actual fun drawDragShadow(drawScope: DrawScope) =
         with(drawScope) {
             when (val picture = cachedPicture) {
                 null ->
@@ -111,7 +47,7 @@
             }
         }
 
-    fun cachePicture(scope: CacheDrawScope): DrawResult =
+    actual fun cachePicture(scope: CacheDrawScope): DrawResult =
         with(scope) {
             val picture = Picture()
             cachedPicture = picture
diff --git a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/input/internal/TextFieldDragAndDropNode.android.kt b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/input/internal/TextFieldDragAndDropNode.android.kt
index 07d110e..04d66f2 100644
--- a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/input/internal/TextFieldDragAndDropNode.android.kt
+++ b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/input/internal/TextFieldDragAndDropNode.android.kt
@@ -18,8 +18,8 @@
 
 import androidx.compose.foundation.content.MediaType
 import androidx.compose.ui.draganddrop.DragAndDropEvent
-import androidx.compose.ui.draganddrop.DragAndDropModifierNode
 import androidx.compose.ui.draganddrop.DragAndDropTarget
+import androidx.compose.ui.draganddrop.DragAndDropTargetModifierNode
 import androidx.compose.ui.draganddrop.toAndroidDragEvent
 import androidx.compose.ui.geometry.Offset
 import androidx.compose.ui.platform.ClipEntry
@@ -37,8 +37,8 @@
     onChanged: ((event: DragAndDropEvent) -> Unit)?,
     onExited: ((event: DragAndDropEvent) -> Unit)?,
     onEnded: ((event: DragAndDropEvent) -> Unit)?,
-): DragAndDropModifierNode {
-    return DragAndDropModifierNode(
+): DragAndDropTargetModifierNode {
+    return DragAndDropTargetModifierNode(
         shouldStartDragAndDrop = { dragAndDropEvent ->
             // If there's a receiveContent modifier wrapping around this TextField, initially all
             // dragging items should be accepted for drop. This is expected to be met by the caller
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/content/internal/ReceiveContentDragAndDropNode.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/content/internal/ReceiveContentDragAndDropNode.kt
index d49df0d..ce5ee66 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/content/internal/ReceiveContentDragAndDropNode.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/content/internal/ReceiveContentDragAndDropNode.kt
@@ -17,9 +17,9 @@
 package androidx.compose.foundation.content.internal
 
 import androidx.compose.ui.draganddrop.DragAndDropEvent
-import androidx.compose.ui.draganddrop.DragAndDropModifierNode
+import androidx.compose.ui.draganddrop.DragAndDropTargetModifierNode
 
 internal expect fun ReceiveContentDragAndDropNode(
     receiveContentConfiguration: ReceiveContentConfiguration,
     dragAndDropRequestPermission: (DragAndDropEvent) -> Unit
-): DragAndDropModifierNode
+): DragAndDropTargetModifierNode
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/draganddrop/DragAndDropSource.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/draganddrop/DragAndDropSource.kt
index 3a08e9e..13d45d1 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/draganddrop/DragAndDropSource.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/draganddrop/DragAndDropSource.kt
@@ -16,15 +16,22 @@
 
 package androidx.compose.foundation.draganddrop
 
+import androidx.compose.runtime.Immutable
 import androidx.compose.ui.Modifier
-import androidx.compose.ui.draganddrop.DragAndDropModifierNode
+import androidx.compose.ui.draganddrop.DragAndDropSourceModifierNode
 import androidx.compose.ui.draganddrop.DragAndDropTransferData
+import androidx.compose.ui.draw.CacheDrawModifierNode
+import androidx.compose.ui.draw.CacheDrawScope
+import androidx.compose.ui.draw.DrawResult
+import androidx.compose.ui.geometry.Offset
 import androidx.compose.ui.graphics.drawscope.DrawScope
 import androidx.compose.ui.input.pointer.PointerInputScope
 import androidx.compose.ui.input.pointer.SuspendingPointerInputModifierNode
+import androidx.compose.ui.layout.LayoutCoordinates
 import androidx.compose.ui.node.DelegatingNode
 import androidx.compose.ui.node.LayoutAwareModifierNode
 import androidx.compose.ui.node.ModifierNodeElement
+import androidx.compose.ui.node.PointerInputModifierNode
 import androidx.compose.ui.platform.InspectorInfo
 import androidx.compose.ui.unit.IntSize
 import androidx.compose.ui.unit.toSize
@@ -33,16 +40,52 @@
  * A scope that allows for the detection of the start of a drag and drop gesture, and subsequently
  * starting a drag and drop session.
  */
-interface DragAndDropSourceScope : PointerInputScope {
+internal interface DragAndDropStartDetectorScope : PointerInputScope {
     /**
-     * Starts a drag and drop session with [transferData] as the data to be transferred on gesture
-     * completion
+     * Requests a drag and drop transfer.
+     *
+     * @param offset the offset value representing position of the input pointer.
      */
-    fun startTransfer(transferData: DragAndDropTransferData)
+    fun requestDragAndDropTransfer(offset: Offset = Offset.Unspecified)
 }
 
 /**
- * A Modifier that allows an element it is applied to to be treated like a source for drag and drop
+ * This typealias represents a suspend function with [DragAndDropStartDetectorScope] that is used to
+ * detect the start of a drag and drop gesture and initiate a drag and drop session.
+ */
+internal typealias DragAndDropStartDetector = suspend DragAndDropStartDetectorScope.() -> Unit
+
+/** Contains the default values used by [Modifier.dragAndDropSource]. */
+@Immutable
+internal expect object DragAndDropSourceDefaults {
+    /**
+     * The default start detector for drag and drop operations. It might vary on different
+     * platforms.
+     */
+    val DefaultStartDetector: DragAndDropStartDetector
+}
+
+/**
+ * A [Modifier] that allows an element it is applied to be treated like a source for drag and drop
+ * operations. It displays the element dragged as a drag shadow.
+ *
+ * Learn how to use [Modifier.dragAndDropSource]:
+ *
+ * @sample androidx.compose.foundation.samples.TextDragAndDropSourceSample
+ * @param transferData A function that receives the current offset of the drag operation and returns
+ *   the [DragAndDropTransferData] to be transferred. If null is returned, the drag and drop
+ *   transfer won't be started.
+ */
+fun Modifier.dragAndDropSource(transferData: (Offset) -> DragAndDropTransferData?): Modifier =
+    this then
+        DragAndDropSourceWithDefaultShadowElement(
+            // TODO: Expose this as public argument
+            detectDragStart = DragAndDropSourceDefaults.DefaultStartDetector,
+            transferData = transferData
+        )
+
+/**
+ * A [Modifier] that allows an element it is applied to be treated like a source for drag and drop
  * operations.
  *
  * Learn how to use [Modifier.dragAndDropSource] while providing a custom drag shadow:
@@ -50,72 +93,157 @@
  * @sample androidx.compose.foundation.samples.DragAndDropSourceWithColoredDragShadowSample
  * @param drawDragDecoration provides the visual representation of the item dragged during the drag
  *   and drop gesture.
- * @param block A lambda with a [DragAndDropSourceScope] as a receiver which provides a
- *   [PointerInputScope] to detect the drag gesture, after which a drag and drop gesture can be
- *   started with [DragAndDropSourceScope.startTransfer].
+ * @param transferData A function that receives the current offset of the drag operation and returns
+ *   the [DragAndDropTransferData] to be transferred. If null is returned, the drag and drop
+ *   transfer won't be started.
  */
 fun Modifier.dragAndDropSource(
     drawDragDecoration: DrawScope.() -> Unit,
-    block: suspend DragAndDropSourceScope.() -> Unit
+    transferData: (Offset) -> DragAndDropTransferData?
 ): Modifier =
     this then
         DragAndDropSourceElement(
             drawDragDecoration = drawDragDecoration,
-            dragAndDropSourceHandler = block,
+            // TODO: Expose this as public argument
+            detectDragStart = DragAndDropSourceDefaults.DefaultStartDetector,
+            transferData = transferData
         )
 
 private data class DragAndDropSourceElement(
     /** @see Modifier.dragAndDropSource */
     val drawDragDecoration: DrawScope.() -> Unit,
     /** @see Modifier.dragAndDropSource */
-    val dragAndDropSourceHandler: suspend DragAndDropSourceScope.() -> Unit
+    val detectDragStart: DragAndDropStartDetector,
+    /** @see Modifier.dragAndDropSource */
+    val transferData: (Offset) -> DragAndDropTransferData?
 ) : ModifierNodeElement<DragAndDropSourceNode>() {
     override fun create() =
         DragAndDropSourceNode(
             drawDragDecoration = drawDragDecoration,
-            dragAndDropSourceHandler = dragAndDropSourceHandler,
+            detectDragStart = detectDragStart,
+            transferData = transferData
         )
 
     override fun update(node: DragAndDropSourceNode) =
         with(node) {
             drawDragDecoration = [email protected]
-            dragAndDropSourceHandler = [email protected]
+            detectDragStart = [email protected]
+            transferData = [email protected]
         }
 
     override fun InspectorInfo.inspectableProperties() {
         name = "dragSource"
         properties["drawDragDecoration"] = drawDragDecoration
-        properties["dragAndDropSourceHandler"] = dragAndDropSourceHandler
+        properties["detectDragStart"] = detectDragStart
+        properties["transferData"] = transferData
     }
 }
 
 internal class DragAndDropSourceNode(
     var drawDragDecoration: DrawScope.() -> Unit,
-    var dragAndDropSourceHandler: suspend DragAndDropSourceScope.() -> Unit
+    var detectDragStart: DragAndDropStartDetector,
+    var transferData: (Offset) -> DragAndDropTransferData?
 ) : DelegatingNode(), LayoutAwareModifierNode {
 
     private var size: IntSize = IntSize.Zero
 
-    init {
-        val dragAndDropModifierNode = delegate(DragAndDropModifierNode())
-
+    private val dragAndDropModifierNode =
         delegate(
-            SuspendingPointerInputModifierNode {
-                dragAndDropSourceHandler(
-                    object : DragAndDropSourceScope, PointerInputScope by this {
-                        override fun startTransfer(transferData: DragAndDropTransferData) =
-                            dragAndDropModifierNode.drag(
-                                transferData = transferData,
-                                decorationSize = size.toSize(),
-                                drawDragDecoration = drawDragDecoration
-                            )
-                    }
-                )
+            DragAndDropSourceModifierNode { offset ->
+                val transferData = transferData(offset)
+                if (transferData != null) {
+                    startDragAndDropTransfer(
+                        transferData = transferData,
+                        decorationSize = size.toSize(),
+                        drawDragDecoration = drawDragDecoration
+                    )
+                }
             }
         )
+
+    private var inputModifierNode: PointerInputModifierNode? = null
+
+    override fun onAttach() {
+        if (dragAndDropModifierNode.isRequestDragAndDropTransferRequired) {
+            inputModifierNode =
+                delegate(
+                    SuspendingPointerInputModifierNode {
+                        detectDragStart(
+                            object : DragAndDropStartDetectorScope, PointerInputScope by this {
+                                override fun requestDragAndDropTransfer(offset: Offset) {
+                                    dragAndDropModifierNode.requestDragAndDropTransfer(offset)
+                                }
+                            }
+                        )
+                    }
+                )
+        }
+    }
+
+    override fun onDetach() {
+        inputModifierNode?.let { undelegate(it) }
+    }
+
+    override fun onPlaced(coordinates: LayoutCoordinates) {
+        dragAndDropModifierNode.onPlaced(coordinates)
     }
 
     override fun onRemeasured(size: IntSize) {
         this.size = size
+        dragAndDropModifierNode.onRemeasured(size)
     }
 }
+
+private data class DragAndDropSourceWithDefaultShadowElement(
+    /** @see Modifier.dragAndDropSource */
+    var detectDragStart: DragAndDropStartDetector,
+    /** @see Modifier.dragAndDropSource */
+    var transferData: (Offset) -> DragAndDropTransferData?
+) : ModifierNodeElement<DragSourceNodeWithDefaultPainter>() {
+    override fun create() =
+        DragSourceNodeWithDefaultPainter(
+            detectDragStart = detectDragStart,
+            transferData = transferData
+        )
+
+    override fun update(node: DragSourceNodeWithDefaultPainter) =
+        with(node) {
+            detectDragStart = [email protected]
+            transferData = [email protected]
+        }
+
+    override fun InspectorInfo.inspectableProperties() {
+        name = "dragSourceWithDefaultPainter"
+        properties["detectDragStart"] = detectDragStart
+        properties["transferData"] = transferData
+    }
+}
+
+private class DragSourceNodeWithDefaultPainter(
+    detectDragStart: DragAndDropStartDetector,
+    transferData: (Offset) -> DragAndDropTransferData?
+) : DelegatingNode() {
+
+    private val cacheDrawScopeDragShadowCallback =
+        CacheDrawScopeDragShadowCallback().also {
+            delegate(CacheDrawModifierNode(it::cachePicture))
+        }
+
+    private val dragAndDropModifierNode =
+        delegate(
+            DragAndDropSourceNode(
+                drawDragDecoration = { cacheDrawScopeDragShadowCallback.drawDragShadow(this) },
+                detectDragStart = detectDragStart,
+                transferData = transferData
+            )
+        )
+
+    var detectDragStart: DragAndDropStartDetector by dragAndDropModifierNode::detectDragStart
+    var transferData: (Offset) -> DragAndDropTransferData? by dragAndDropModifierNode::transferData
+}
+
+internal expect class CacheDrawScopeDragShadowCallback() {
+    fun drawDragShadow(drawScope: DrawScope)
+
+    fun cachePicture(scope: CacheDrawScope): DrawResult
+}
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/draganddrop/DragAndDropTarget.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/draganddrop/DragAndDropTarget.kt
index de3a61b..3263171 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/draganddrop/DragAndDropTarget.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/draganddrop/DragAndDropTarget.kt
@@ -18,8 +18,8 @@
 
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.draganddrop.DragAndDropEvent
-import androidx.compose.ui.draganddrop.DragAndDropModifierNode
 import androidx.compose.ui.draganddrop.DragAndDropTarget
+import androidx.compose.ui.draganddrop.DragAndDropTargetModifierNode
 import androidx.compose.ui.node.DelegatingNode
 import androidx.compose.ui.node.ModifierNodeElement
 import androidx.compose.ui.platform.InspectorInfo
@@ -37,8 +37,6 @@
  *
  * All drag and drop target modifiers in the hierarchy will be given an opportunity to participate
  * in a given drag and drop session via [shouldStartDragAndDrop].
- *
- * @see [DragAndDropModifierNode.acceptDragAndDropTransfer]
  */
 fun Modifier.dragAndDropTarget(
     shouldStartDragAndDrop: (startEvent: DragAndDropEvent) -> Boolean,
@@ -89,7 +87,7 @@
     private var target: DragAndDropTarget
 ) : DelegatingNode() {
 
-    private var dragAndDropNode: DragAndDropModifierNode? = null
+    private var dragAndDropNode: DragAndDropTargetModifierNode? = null
 
     override fun onAttach() {
         createAndAttachDragAndDropModifierNode()
@@ -114,7 +112,7 @@
     private fun createAndAttachDragAndDropModifierNode() {
         dragAndDropNode =
             delegate(
-                DragAndDropModifierNode(
+                DragAndDropTargetModifierNode(
                     // We wrap the this.shouldStartDragAndDrop invocation in a lambda as it might
                     // change over
                     // time, and updates to shouldStartDragAndDrop are not destructive.
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridMeasure.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridMeasure.kt
index 608b82b..81a4eb3 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridMeasure.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridMeasure.kt
@@ -33,10 +33,10 @@
 import androidx.compose.ui.unit.IntSize
 import androidx.compose.ui.unit.constrainHeight
 import androidx.compose.ui.unit.constrainWidth
-import androidx.compose.ui.unit.max
 import androidx.compose.ui.util.fastForEach
 import androidx.compose.ui.util.fastForEachIndexed
 import androidx.compose.ui.util.fastForEachReversed
+import androidx.compose.ui.util.fastJoinToString
 import androidx.compose.ui.util.fastMaxOfOrNull
 import androidx.compose.ui.util.fastRoundToInt
 import androidx.compose.ui.util.packInts
@@ -783,6 +783,8 @@
                 it - beforeContentPadding + afterContentPadding
             }
 
+        debugLog { "pinned items: $pinnedItems" }
+
         var extraItemOffset = itemScrollOffsets[0]
         val extraItemsBefore =
             calculateExtraItems(
@@ -799,16 +801,24 @@
                     when (lane) {
                         Unset,
                         FullSpan -> {
-                            firstItemIndices.all { it > itemIndex }
+                            measuredItems.all {
+                                val firstIndex = it.firstOrNull()?.index ?: -1
+                                firstIndex > itemIndex
+                            }
                         }
                         else -> {
-                            firstItemIndices[lane] > itemIndex
+                            val firstIndex = measuredItems[lane].firstOrNull()?.index ?: -1
+                            firstIndex > itemIndex
                         }
                     }
                 },
                 beforeVisibleBounds = true
             )
 
+        debugLog {
+            "extra items before: ${extraItemsBefore.fastJoinToString { it.index.toString() }}"
+        }
+
         val visibleItems =
             calculateVisibleItems(
                 measuredItems,
@@ -847,6 +857,10 @@
                 beforeVisibleBounds = false
             )
 
+        debugLog {
+            "extra items after: ${extraItemsAfter.fastJoinToString { it.index.toString() }}"
+        }
+
         val positionedItems = mutableListOf<LazyStaggeredGridMeasuredItem>()
         positionedItems.addAll(extraItemsBefore)
         positionedItems.addAll(visibleItems)
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/internal/TextFieldDecoratorModifier.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/internal/TextFieldDecoratorModifier.kt
index b2feaa3..319e13f 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/internal/TextFieldDecoratorModifier.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/internal/TextFieldDecoratorModifier.kt
@@ -699,6 +699,18 @@
         }
     }
 
+    override fun onPlaced(coordinates: LayoutCoordinates) {
+        // If the node implements the same interface, it must manually forward calls to
+        //  all its delegatable nodes.
+        dragAndDropNode.onPlaced(coordinates)
+    }
+
+    override fun onRemeasured(size: IntSize) {
+        // If the node implements the same interface, it must manually forward calls to
+        //  all its delegatable nodes.
+        dragAndDropNode.onRemeasured(size)
+    }
+
     private fun startInputSession(fromTap: Boolean) {
         if (!fromTap && !keyboardOptions.showKeyboardOnFocusOrDefault) return
 
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/internal/TextFieldDragAndDropNode.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/internal/TextFieldDragAndDropNode.kt
index 242a2c4..b6b51e1 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/internal/TextFieldDragAndDropNode.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/internal/TextFieldDragAndDropNode.kt
@@ -19,7 +19,7 @@
 import androidx.compose.foundation.ExperimentalFoundationApi
 import androidx.compose.foundation.content.MediaType
 import androidx.compose.ui.draganddrop.DragAndDropEvent
-import androidx.compose.ui.draganddrop.DragAndDropModifierNode
+import androidx.compose.ui.draganddrop.DragAndDropTargetModifierNode
 import androidx.compose.ui.geometry.Offset
 import androidx.compose.ui.platform.ClipEntry
 import androidx.compose.ui.platform.ClipMetadata
@@ -35,4 +35,4 @@
     onChanged: ((event: DragAndDropEvent) -> Unit)? = null,
     onExited: ((event: DragAndDropEvent) -> Unit)? = null,
     onEnded: ((event: DragAndDropEvent) -> Unit)? = null,
-): DragAndDropModifierNode
+): DragAndDropTargetModifierNode
diff --git a/compose/foundation/foundation/src/commonStubsMain/kotlin/androidx/compose/foundation/content/internal/ReceiveContentDragAndDropNode.commonStubs.kt b/compose/foundation/foundation/src/commonStubsMain/kotlin/androidx/compose/foundation/content/internal/ReceiveContentDragAndDropNode.commonStubs.kt
index 9667098..b4b223d 100644
--- a/compose/foundation/foundation/src/commonStubsMain/kotlin/androidx/compose/foundation/content/internal/ReceiveContentDragAndDropNode.commonStubs.kt
+++ b/compose/foundation/foundation/src/commonStubsMain/kotlin/androidx/compose/foundation/content/internal/ReceiveContentDragAndDropNode.commonStubs.kt
@@ -18,9 +18,9 @@
 
 import androidx.compose.foundation.implementedInJetBrainsFork
 import androidx.compose.ui.draganddrop.DragAndDropEvent
-import androidx.compose.ui.draganddrop.DragAndDropModifierNode
+import androidx.compose.ui.draganddrop.DragAndDropTargetModifierNode
 
 internal actual fun ReceiveContentDragAndDropNode(
     receiveContentConfiguration: ReceiveContentConfiguration,
     dragAndDropRequestPermission: (DragAndDropEvent) -> Unit
-): DragAndDropModifierNode = implementedInJetBrainsFork()
+): DragAndDropTargetModifierNode = implementedInJetBrainsFork()
diff --git a/compose/foundation/foundation/src/commonStubsMain/kotlin/androidx/compose/foundation/draganddrop/DragAndDropSource.commonStubs.kt b/compose/foundation/foundation/src/commonStubsMain/kotlin/androidx/compose/foundation/draganddrop/DragAndDropSource.commonStubs.kt
new file mode 100644
index 0000000..f942926
--- /dev/null
+++ b/compose/foundation/foundation/src/commonStubsMain/kotlin/androidx/compose/foundation/draganddrop/DragAndDropSource.commonStubs.kt
@@ -0,0 +1,32 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.compose.foundation.draganddrop
+
+import androidx.compose.foundation.implementedInJetBrainsFork
+import androidx.compose.ui.draw.CacheDrawScope
+import androidx.compose.ui.draw.DrawResult
+import androidx.compose.ui.graphics.drawscope.DrawScope
+
+internal actual object DragAndDropSourceDefaults {
+    actual val DefaultStartDetector: DragAndDropStartDetector = implementedInJetBrainsFork()
+}
+
+internal actual class CacheDrawScopeDragShadowCallback actual constructor() {
+    actual fun drawDragShadow(drawScope: DrawScope): Unit = implementedInJetBrainsFork()
+
+    actual fun cachePicture(scope: CacheDrawScope): DrawResult = implementedInJetBrainsFork()
+}
diff --git a/compose/foundation/foundation/src/commonStubsMain/kotlin/androidx/compose/foundation/text/input/internal/TextFieldDragAndDropNode.commonStubs.kt b/compose/foundation/foundation/src/commonStubsMain/kotlin/androidx/compose/foundation/text/input/internal/TextFieldDragAndDropNode.commonStubs.kt
index 73d4929..8a11a48 100644
--- a/compose/foundation/foundation/src/commonStubsMain/kotlin/androidx/compose/foundation/text/input/internal/TextFieldDragAndDropNode.commonStubs.kt
+++ b/compose/foundation/foundation/src/commonStubsMain/kotlin/androidx/compose/foundation/text/input/internal/TextFieldDragAndDropNode.commonStubs.kt
@@ -19,7 +19,7 @@
 import androidx.compose.foundation.content.MediaType
 import androidx.compose.foundation.implementedInJetBrainsFork
 import androidx.compose.ui.draganddrop.DragAndDropEvent
-import androidx.compose.ui.draganddrop.DragAndDropModifierNode
+import androidx.compose.ui.draganddrop.DragAndDropTargetModifierNode
 import androidx.compose.ui.geometry.Offset
 import androidx.compose.ui.platform.ClipEntry
 import androidx.compose.ui.platform.ClipMetadata
@@ -34,4 +34,4 @@
     onChanged: ((event: DragAndDropEvent) -> Unit)?,
     onExited: ((event: DragAndDropEvent) -> Unit)?,
     onEnded: ((event: DragAndDropEvent) -> Unit)?,
-): DragAndDropModifierNode = implementedInJetBrainsFork()
+): DragAndDropTargetModifierNode = implementedInJetBrainsFork()
diff --git a/compose/material/material/src/commonMain/kotlin/androidx/compose/material/AlertDialog.kt b/compose/material/material/src/commonMain/kotlin/androidx/compose/material/AlertDialog.kt
index 625bee6..12ec8c2 100644
--- a/compose/material/material/src/commonMain/kotlin/androidx/compose/material/AlertDialog.kt
+++ b/compose/material/material/src/commonMain/kotlin/androidx/compose/material/AlertDialog.kt
@@ -428,7 +428,7 @@
                 val arrangement = Arrangement.Bottom
                 // TODO(soboleva): rtl support
                 // Handle vertical direction
-                val mainAxisPositions = IntArray(childrenMainAxisSizes.size) { 0 }
+                val mainAxisPositions = IntArray(childrenMainAxisSizes.size)
                 with(arrangement) {
                     arrange(mainAxisLayoutSize, childrenMainAxisSizes, mainAxisPositions)
                 }
diff --git a/compose/material/material/src/commonMain/kotlin/androidx/compose/material/ListItem.kt b/compose/material/material/src/commonMain/kotlin/androidx/compose/material/ListItem.kt
index 9a7d402..da1918b 100644
--- a/compose/material/material/src/commonMain/kotlin/androidx/compose/material/ListItem.kt
+++ b/compose/material/material/src/commonMain/kotlin/androidx/compose/material/ListItem.kt
@@ -370,7 +370,7 @@
 
         val containerWidth =
             placeables.fastFold(0) { maxWidth, placeable -> max(maxWidth, placeable.width) }
-        val y = Array(placeables.size) { 0 }
+        val y = IntArray(placeables.size)
         var containerHeight = 0
         placeables.fastForEachIndexed { index, placeable ->
             val toPreviousBaseline =
diff --git a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/AlertDialog.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/AlertDialog.kt
index 6d6a123..091e453 100644
--- a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/AlertDialog.kt
+++ b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/AlertDialog.kt
@@ -430,7 +430,7 @@
                             if (j < placeables.lastIndex) mainAxisSpacing.roundToPx() else 0
                     }
                 val arrangement = Arrangement.End
-                val mainAxisPositions = IntArray(childrenMainAxisSizes.size) { 0 }
+                val mainAxisPositions = IntArray(childrenMainAxisSizes.size)
                 with(arrangement) {
                     arrange(
                         mainAxisLayoutSize,
diff --git a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/ButtonGroup.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/ButtonGroup.kt
index dcf62a2..ace32d4 100644
--- a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/ButtonGroup.kt
+++ b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/ButtonGroup.kt
@@ -391,7 +391,7 @@
 
         // Compute the row size and position the children.
         val mainAxisLayoutSize = max((fixedSpace + weightedSpace).coerceAtLeast(0), mainAxisMin)
-        val mainAxisPositions = IntArray(size) { 0 }
+        val mainAxisPositions = IntArray(size)
         val measureScope = this
         with(horizontalArrangement) {
             measureScope.arrange(
diff --git a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/pulltorefresh/PullToRefresh.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/pulltorefresh/PullToRefresh.kt
index 903051e..ba0cb07 100644
--- a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/pulltorefresh/PullToRefresh.kt
+++ b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/pulltorefresh/PullToRefresh.kt
@@ -65,10 +65,10 @@
 import androidx.compose.ui.graphics.drawscope.Stroke
 import androidx.compose.ui.graphics.drawscope.clipRect
 import androidx.compose.ui.graphics.drawscope.rotate
-import androidx.compose.ui.graphics.graphicsLayer
 import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
 import androidx.compose.ui.input.nestedscroll.NestedScrollSource
 import androidx.compose.ui.input.nestedscroll.nestedScrollModifierNode
+import androidx.compose.ui.layout.layout
 import androidx.compose.ui.node.CompositionLocalConsumerModifierNode
 import androidx.compose.ui.node.DelegatableNode
 import androidx.compose.ui.node.DelegatingNode
@@ -455,12 +455,22 @@
                             [email protected]()
                         }
                     }
-                    .graphicsLayer {
-                        val showElevation = state.distanceFraction > 0f || isRefreshing
-                        translationY = state.distanceFraction * threshold.roundToPx() - size.height
-                        shadowElevation = if (showElevation) elevation.toPx() else 0f
-                        this.shape = shape
-                        clip = true
+                    .layout { measurable, constraints ->
+                        val placeable = measurable.measure(constraints)
+                        layout(placeable.width, placeable.height) {
+                            placeable.placeWithLayer(
+                                0,
+                                0,
+                                layerBlock = {
+                                    val showElevation = state.distanceFraction > 0f || isRefreshing
+                                    translationY =
+                                        state.distanceFraction * threshold.roundToPx() - size.height
+                                    shadowElevation = if (showElevation) elevation.toPx() else 0f
+                                    this.shape = shape
+                                    clip = true
+                                }
+                            )
+                        }
                     }
                     .background(color = containerColor, shape = shape),
             contentAlignment = Alignment.Center,
diff --git a/compose/ui/ui-graphics/api/current.txt b/compose/ui/ui-graphics/api/current.txt
index a3306fe..6c2af78 100644
--- a/compose/ui/ui-graphics/api/current.txt
+++ b/compose/ui/ui-graphics/api/current.txt
@@ -495,6 +495,7 @@
 
   public final class ImageBitmapKt {
     method public static androidx.compose.ui.graphics.ImageBitmap ImageBitmap(int width, int height, optional int config, optional boolean hasAlpha, optional androidx.compose.ui.graphics.colorspace.ColorSpace colorSpace);
+    method public static androidx.compose.ui.graphics.ImageBitmap decodeToImageBitmap(byte[]);
     method public static androidx.compose.ui.graphics.PixelMap toPixelMap(androidx.compose.ui.graphics.ImageBitmap, optional int startX, optional int startY, optional int width, optional int height, optional int[] buffer, optional int bufferOffset, optional int stride);
   }
 
diff --git a/compose/ui/ui-graphics/api/restricted_current.txt b/compose/ui/ui-graphics/api/restricted_current.txt
index 423e117..bcf64b0 100644
--- a/compose/ui/ui-graphics/api/restricted_current.txt
+++ b/compose/ui/ui-graphics/api/restricted_current.txt
@@ -540,6 +540,7 @@
 
   public final class ImageBitmapKt {
     method public static androidx.compose.ui.graphics.ImageBitmap ImageBitmap(int width, int height, optional int config, optional boolean hasAlpha, optional androidx.compose.ui.graphics.colorspace.ColorSpace colorSpace);
+    method public static androidx.compose.ui.graphics.ImageBitmap decodeToImageBitmap(byte[]);
     method public static androidx.compose.ui.graphics.PixelMap toPixelMap(androidx.compose.ui.graphics.ImageBitmap, optional int startX, optional int startY, optional int width, optional int height, optional int[] buffer, optional int bufferOffset, optional int stride);
   }
 
diff --git a/compose/ui/ui-graphics/src/androidMain/kotlin/androidx/compose/ui/graphics/AndroidImageBitmap.android.kt b/compose/ui/ui-graphics/src/androidMain/kotlin/androidx/compose/ui/graphics/AndroidImageBitmap.android.kt
index 93f415a..45d0f75 100644
--- a/compose/ui/ui-graphics/src/androidMain/kotlin/androidx/compose/ui/graphics/AndroidImageBitmap.android.kt
+++ b/compose/ui/ui-graphics/src/androidMain/kotlin/androidx/compose/ui/graphics/AndroidImageBitmap.android.kt
@@ -17,6 +17,7 @@
 package androidx.compose.ui.graphics
 
 import android.graphics.Bitmap
+import android.graphics.BitmapFactory
 import android.os.Build
 import android.util.DisplayMetrics
 import androidx.annotation.RequiresApi
@@ -29,6 +30,10 @@
  */
 fun Bitmap.asImageBitmap(): ImageBitmap = AndroidImageBitmap(this)
 
+internal actual fun createImageBitmap(bytes: ByteArray): ImageBitmap {
+    return BitmapFactory.decodeByteArray(bytes, 0, bytes.size).asImageBitmap()
+}
+
 internal actual fun ActualImageBitmap(
     width: Int,
     height: Int,
diff --git a/compose/ui/ui-graphics/src/commonMain/kotlin/androidx/compose/ui/graphics/ImageBitmap.kt b/compose/ui/ui-graphics/src/commonMain/kotlin/androidx/compose/ui/graphics/ImageBitmap.kt
index 4a3d61b..e022cb3 100644
--- a/compose/ui/ui-graphics/src/commonMain/kotlin/androidx/compose/ui/graphics/ImageBitmap.kt
+++ b/compose/ui/ui-graphics/src/commonMain/kotlin/androidx/compose/ui/graphics/ImageBitmap.kt
@@ -233,3 +233,12 @@
     hasAlpha: Boolean = true,
     colorSpace: ColorSpace = ColorSpaces.Srgb
 ): ImageBitmap = ActualImageBitmap(width, height, config, hasAlpha, colorSpace)
+
+/**
+ * Decodes a byte array of a Bitmap to an ImageBitmap.
+ *
+ * @return The converted ImageBitmap.
+ */
+fun ByteArray.decodeToImageBitmap(): ImageBitmap = createImageBitmap(this)
+
+internal expect fun createImageBitmap(bytes: ByteArray): ImageBitmap
diff --git a/compose/ui/ui-graphics/src/commonStubsMain/kotlin/androidx/compose/ui/graphics/ImageBitmap.commonStubs.kt b/compose/ui/ui-graphics/src/commonStubsMain/kotlin/androidx/compose/ui/graphics/ImageBitmap.commonStubs.kt
index 49b11c7..3d641f4 100644
--- a/compose/ui/ui-graphics/src/commonStubsMain/kotlin/androidx/compose/ui/graphics/ImageBitmap.commonStubs.kt
+++ b/compose/ui/ui-graphics/src/commonStubsMain/kotlin/androidx/compose/ui/graphics/ImageBitmap.commonStubs.kt
@@ -25,3 +25,5 @@
     hasAlpha: Boolean,
     colorSpace: ColorSpace
 ): ImageBitmap = implementedInJetBrainsFork()
+
+internal actual fun createImageBitmap(bytes: ByteArray): ImageBitmap = implementedInJetBrainsFork()
diff --git a/compose/ui/ui-test/src/androidMain/kotlin/androidx/compose/ui/test/ComposeUiTest.android.kt b/compose/ui/ui-test/src/androidMain/kotlin/androidx/compose/ui/test/ComposeUiTest.android.kt
index 23ba0f4..72a1457 100644
--- a/compose/ui/ui-test/src/androidMain/kotlin/androidx/compose/ui/test/ComposeUiTest.android.kt
+++ b/compose/ui/ui-test/src/androidMain/kotlin/androidx/compose/ui/test/ComposeUiTest.android.kt
@@ -238,6 +238,13 @@
 abstract class AndroidComposeUiTestEnvironment<A : ComponentActivity>(
     private val effectContext: CoroutineContext = EmptyCoroutineContext
 ) {
+    /**
+     * Returns the current host activity of type [A]. If no such activity is available, for example
+     * if you've navigated to a different activity and the original host has now been destroyed,
+     * this will return `null`.
+     */
+    protected abstract val activity: A?
+
     private val idlingResourceRegistry = IdlingResourceRegistry()
 
     internal val composeRootRegistry = ComposeRootRegistry()
@@ -337,13 +344,6 @@
     private val testContext = TestContext(testOwner)
 
     /**
-     * Returns the current host activity of type [A]. If no such activity is available, for example
-     * if you've navigated to a different activity and the original host has now been destroyed,
-     * this will return `null`.
-     */
-    protected abstract val activity: A?
-
-    /**
      * The receiver scope of the test passed to [runTest]. Note that some of the properties and
      * methods will only work during the call to [runTest], as they require that the environment has
      * been set up.
diff --git a/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/platform/extensions/SpannableExtensions.android.kt b/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/platform/extensions/SpannableExtensions.android.kt
index b73a903..80672e0 100644
--- a/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/platform/extensions/SpannableExtensions.android.kt
+++ b/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/platform/extensions/SpannableExtensions.android.kt
@@ -345,7 +345,7 @@
     // Sort all span start and end points.
     // S1--S2--E1--S3--E3--E2
     val spanCount = spanStyles.size
-    val transitionOffsets = Array(spanCount * 2) { 0 }
+    val transitionOffsets = IntArray(spanCount * 2)
     spanStyles.fastForEachIndexed { idx, spanStyle ->
         transitionOffsets[idx] = spanStyle.start
         transitionOffsets[idx + spanCount] = spanStyle.end
diff --git a/compose/ui/ui/api/current.txt b/compose/ui/ui/api/current.txt
index 738fb68..bc950b7 100644
--- a/compose/ui/ui/api/current.txt
+++ b/compose/ui/ui/api/current.txt
@@ -298,14 +298,26 @@
     ctor public DragAndDropEvent(android.view.DragEvent dragEvent);
   }
 
-  public interface DragAndDropModifierNode extends androidx.compose.ui.node.DelegatableNode androidx.compose.ui.draganddrop.DragAndDropTarget {
-    method public boolean acceptDragAndDropTransfer(androidx.compose.ui.draganddrop.DragAndDropEvent startEvent);
-    method public void drag(androidx.compose.ui.draganddrop.DragAndDropTransferData transferData, long decorationSize, kotlin.jvm.functions.Function1<? super androidx.compose.ui.graphics.drawscope.DrawScope,kotlin.Unit> drawDragDecoration);
+  @Deprecated public interface DragAndDropModifierNode extends androidx.compose.ui.node.DelegatableNode androidx.compose.ui.draganddrop.DragAndDropTarget {
+    method @Deprecated public boolean acceptDragAndDropTransfer(androidx.compose.ui.draganddrop.DragAndDropEvent startEvent);
+    method @Deprecated public void drag(androidx.compose.ui.draganddrop.DragAndDropTransferData transferData, long decorationSize, kotlin.jvm.functions.Function1<? super androidx.compose.ui.graphics.drawscope.DrawScope,kotlin.Unit> drawDragDecoration);
   }
 
   public final class DragAndDropNodeKt {
-    method public static androidx.compose.ui.draganddrop.DragAndDropModifierNode DragAndDropModifierNode();
-    method public static androidx.compose.ui.draganddrop.DragAndDropModifierNode DragAndDropModifierNode(kotlin.jvm.functions.Function1<? super androidx.compose.ui.draganddrop.DragAndDropEvent,java.lang.Boolean> shouldStartDragAndDrop, androidx.compose.ui.draganddrop.DragAndDropTarget target);
+    method @Deprecated public static androidx.compose.ui.draganddrop.DragAndDropModifierNode DragAndDropModifierNode();
+    method @Deprecated public static androidx.compose.ui.draganddrop.DragAndDropModifierNode DragAndDropModifierNode(kotlin.jvm.functions.Function1<? super androidx.compose.ui.draganddrop.DragAndDropEvent,java.lang.Boolean> shouldStartDragAndDrop, androidx.compose.ui.draganddrop.DragAndDropTarget target);
+    method public static androidx.compose.ui.draganddrop.DragAndDropSourceModifierNode DragAndDropSourceModifierNode(kotlin.jvm.functions.Function2<? super androidx.compose.ui.draganddrop.DragAndDropStartTransferScope,? super androidx.compose.ui.geometry.Offset,kotlin.Unit> onStartTransfer);
+    method public static androidx.compose.ui.draganddrop.DragAndDropTargetModifierNode DragAndDropTargetModifierNode(kotlin.jvm.functions.Function1<? super androidx.compose.ui.draganddrop.DragAndDropEvent,java.lang.Boolean> shouldStartDragAndDrop, androidx.compose.ui.draganddrop.DragAndDropTarget target);
+  }
+
+  public sealed interface DragAndDropSourceModifierNode extends androidx.compose.ui.node.LayoutAwareModifierNode {
+    method public boolean isRequestDragAndDropTransferRequired();
+    method public void requestDragAndDropTransfer(long offset);
+    property public abstract boolean isRequestDragAndDropTransferRequired;
+  }
+
+  public interface DragAndDropStartTransferScope {
+    method public boolean startDragAndDropTransfer(androidx.compose.ui.draganddrop.DragAndDropTransferData transferData, long decorationSize, kotlin.jvm.functions.Function1<? super androidx.compose.ui.graphics.drawscope.DrawScope,kotlin.Unit> drawDragDecoration);
   }
 
   public interface DragAndDropTarget {
@@ -318,6 +330,9 @@
     method public default void onStarted(androidx.compose.ui.draganddrop.DragAndDropEvent event);
   }
 
+  public sealed interface DragAndDropTargetModifierNode extends androidx.compose.ui.node.LayoutAwareModifierNode {
+  }
+
   public final class DragAndDropTransferData {
     ctor public DragAndDropTransferData(android.content.ClipData clipData, optional Object? localState, optional int flags);
     method public android.content.ClipData getClipData();
diff --git a/compose/ui/ui/api/restricted_current.txt b/compose/ui/ui/api/restricted_current.txt
index 41d810c..9d1d078 100644
--- a/compose/ui/ui/api/restricted_current.txt
+++ b/compose/ui/ui/api/restricted_current.txt
@@ -298,14 +298,26 @@
     ctor public DragAndDropEvent(android.view.DragEvent dragEvent);
   }
 
-  public interface DragAndDropModifierNode extends androidx.compose.ui.node.DelegatableNode androidx.compose.ui.draganddrop.DragAndDropTarget {
-    method public boolean acceptDragAndDropTransfer(androidx.compose.ui.draganddrop.DragAndDropEvent startEvent);
-    method public void drag(androidx.compose.ui.draganddrop.DragAndDropTransferData transferData, long decorationSize, kotlin.jvm.functions.Function1<? super androidx.compose.ui.graphics.drawscope.DrawScope,kotlin.Unit> drawDragDecoration);
+  @Deprecated public interface DragAndDropModifierNode extends androidx.compose.ui.node.DelegatableNode androidx.compose.ui.draganddrop.DragAndDropTarget {
+    method @Deprecated public boolean acceptDragAndDropTransfer(androidx.compose.ui.draganddrop.DragAndDropEvent startEvent);
+    method @Deprecated public void drag(androidx.compose.ui.draganddrop.DragAndDropTransferData transferData, long decorationSize, kotlin.jvm.functions.Function1<? super androidx.compose.ui.graphics.drawscope.DrawScope,kotlin.Unit> drawDragDecoration);
   }
 
   public final class DragAndDropNodeKt {
-    method public static androidx.compose.ui.draganddrop.DragAndDropModifierNode DragAndDropModifierNode();
-    method public static androidx.compose.ui.draganddrop.DragAndDropModifierNode DragAndDropModifierNode(kotlin.jvm.functions.Function1<? super androidx.compose.ui.draganddrop.DragAndDropEvent,java.lang.Boolean> shouldStartDragAndDrop, androidx.compose.ui.draganddrop.DragAndDropTarget target);
+    method @Deprecated public static androidx.compose.ui.draganddrop.DragAndDropModifierNode DragAndDropModifierNode();
+    method @Deprecated public static androidx.compose.ui.draganddrop.DragAndDropModifierNode DragAndDropModifierNode(kotlin.jvm.functions.Function1<? super androidx.compose.ui.draganddrop.DragAndDropEvent,java.lang.Boolean> shouldStartDragAndDrop, androidx.compose.ui.draganddrop.DragAndDropTarget target);
+    method public static androidx.compose.ui.draganddrop.DragAndDropSourceModifierNode DragAndDropSourceModifierNode(kotlin.jvm.functions.Function2<? super androidx.compose.ui.draganddrop.DragAndDropStartTransferScope,? super androidx.compose.ui.geometry.Offset,kotlin.Unit> onStartTransfer);
+    method public static androidx.compose.ui.draganddrop.DragAndDropTargetModifierNode DragAndDropTargetModifierNode(kotlin.jvm.functions.Function1<? super androidx.compose.ui.draganddrop.DragAndDropEvent,java.lang.Boolean> shouldStartDragAndDrop, androidx.compose.ui.draganddrop.DragAndDropTarget target);
+  }
+
+  public sealed interface DragAndDropSourceModifierNode extends androidx.compose.ui.node.LayoutAwareModifierNode {
+    method public boolean isRequestDragAndDropTransferRequired();
+    method public void requestDragAndDropTransfer(long offset);
+    property public abstract boolean isRequestDragAndDropTransferRequired;
+  }
+
+  public interface DragAndDropStartTransferScope {
+    method public boolean startDragAndDropTransfer(androidx.compose.ui.draganddrop.DragAndDropTransferData transferData, long decorationSize, kotlin.jvm.functions.Function1<? super androidx.compose.ui.graphics.drawscope.DrawScope,kotlin.Unit> drawDragDecoration);
   }
 
   public interface DragAndDropTarget {
@@ -318,6 +330,9 @@
     method public default void onStarted(androidx.compose.ui.draganddrop.DragAndDropEvent event);
   }
 
+  public sealed interface DragAndDropTargetModifierNode extends androidx.compose.ui.node.LayoutAwareModifierNode {
+  }
+
   public final class DragAndDropTransferData {
     ctor public DragAndDropTransferData(android.content.ClipData clipData, optional Object? localState, optional int flags);
     method public android.content.ClipData getClipData();
diff --git a/compose/ui/ui/benchmark/src/androidTest/java/androidx/compose/ui/benchmark/focus/FocusBenchmark.kt b/compose/ui/ui/benchmark/src/androidTest/java/androidx/compose/ui/benchmark/focus/FocusBenchmark.kt
index b228824..30501e3 100644
--- a/compose/ui/ui/benchmark/src/androidTest/java/androidx/compose/ui/benchmark/focus/FocusBenchmark.kt
+++ b/compose/ui/ui/benchmark/src/androidTest/java/androidx/compose/ui/benchmark/focus/FocusBenchmark.kt
@@ -16,11 +16,20 @@
 
 package androidx.compose.ui.benchmark.focus
 
+import android.view.KeyEvent
+import android.view.KeyEvent.ACTION_DOWN
+import android.view.KeyEvent.ACTION_UP
+import android.view.KeyEvent.KEYCODE_TAB
 import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.runtime.Composable
+import androidx.compose.testutils.ComposeTestCase
 import androidx.compose.testutils.LayeredComposeTestCase
 import androidx.compose.testutils.benchmark.ComposeBenchmarkRule
 import androidx.compose.testutils.benchmark.benchmarkToFirstPixel
+import androidx.compose.testutils.doFramesUntilNoChangesPending
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.focus.focusTarget
 import androidx.test.ext.junit.runners.AndroidJUnit4
@@ -46,4 +55,29 @@
             }
         }
     }
+
+    @Test
+    fun focusTraversal() {
+        composeBenchmarkRule.runBenchmarkFor({
+            object : ComposeTestCase {
+                @Composable
+                override fun Content() {
+                    Column(Modifier.fillMaxSize()) {
+                        repeat(10) {
+                            Row(Modifier.focusTarget()) {
+                                repeat(10) { Box(Modifier.focusTarget()) }
+                            }
+                        }
+                    }
+                }
+            }
+        }) {
+            composeBenchmarkRule.runOnUiThread { doFramesUntilNoChangesPending() }
+
+            composeBenchmarkRule.measureRepeatedOnUiThread {
+                getHostView().dispatchKeyEvent(KeyEvent(ACTION_DOWN, KEYCODE_TAB))
+                getHostView().dispatchKeyEvent(KeyEvent(ACTION_UP, KEYCODE_TAB))
+            }
+        }
+    }
 }
diff --git a/compose/ui/ui/build.gradle b/compose/ui/ui/build.gradle
index d7c6cc5..acc6d86 100644
--- a/compose/ui/ui/build.gradle
+++ b/compose/ui/ui/build.gradle
@@ -93,7 +93,7 @@
                 implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.3")
                 implementation("androidx.emoji2:emoji2:1.2.0")
 
-                implementation("androidx.profileinstaller:profileinstaller:1.3.1")
+                implementation("androidx.profileinstaller:profileinstaller:1.4.0")
 
                 // `compose-ui` has a transitive dependency on `lifecycle-livedata-core`, and
                 // converting `lifecycle-runtime-compose` to KMP triggered a Gradle bug. Adding
diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/draganddrop/AndroidDragAndDropTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/draganddrop/AndroidDragAndDropTest.kt
index 84c80b10..d0cc359 100644
--- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/draganddrop/AndroidDragAndDropTest.kt
+++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/draganddrop/AndroidDragAndDropTest.kt
@@ -75,16 +75,24 @@
 
     private lateinit var density: Density
 
-    /**
+    /*
      * Sets up a grid of drop targets resembling the following for testing:
+     *    accepts                 accepts
+     * ┌───────────┐           ┌───────────┐
+     * │           │           │           │
+     * │           │           │           │
+     * │           │           │           │
+     * └───────────┘           └───────────┘
      *
-     * accepts accepts ┌───────────┐ ┌───────────┐ │ │ │ │ │ │ │ │ │ │ │ │ └───────────┘
-     * └───────────┘
+     *    accepts                 rejects
+     * ┌───────────┐  accepts  ┌───────────┐
+     * │  accepts  │  ┌─────┐  │  accepts  │
+     * │─────┐     │  │     │  │     ┌─────│
+     * │     │     │  └─────┘  │     │     │
+     * └─────┘─────┘           └─────└─────┘
      *
-     * accepts rejects ┌───────────┐ accepts ┌───────────┐ │ accepts │ ┌─────┐ │ accepts │ │─────┐ │
-     * │ │ │ ┌─────│ │ │ │ └─────┘ │ │ │ └─────┘─────┘ └─────└─────┘
-     *
-     * parent <------> child offset
+     * parent <------> child
+     *         offset
      */
     @Before
     fun setup() {
diff --git a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/draganddrop/AndroidDragAndDropManager.android.kt b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/draganddrop/AndroidDragAndDropManager.android.kt
new file mode 100644
index 0000000..4a9de60
--- /dev/null
+++ b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/draganddrop/AndroidDragAndDropManager.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.compose.ui.draganddrop
+
+import android.view.DragEvent
+import android.view.View
+import androidx.collection.ArraySet
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.drawscope.DrawScope
+import androidx.compose.ui.node.ModifierNodeElement
+import androidx.compose.ui.platform.InspectorInfo
+
+/** A Class that provides access [View.OnDragListener] APIs for a [DragAndDropNode]. */
+internal class AndroidDragAndDropManager(
+    private val startDrag:
+        (
+            transferData: DragAndDropTransferData,
+            decorationSize: Size,
+            drawDragDecoration: DrawScope.() -> Unit
+        ) -> Boolean
+) : View.OnDragListener, DragAndDropManager {
+
+    private val rootDragAndDropNode = DragAndDropNode()
+
+    /**
+     * A collection [DragAndDropNode] instances that registered interested in a drag and drop
+     * session by returning true in [DragAndDropNode.onStarted].
+     */
+    private val interestedTargets = ArraySet<DragAndDropTarget>()
+
+    override val modifier: Modifier =
+        object : ModifierNodeElement<DragAndDropNode>() {
+            override fun create() = rootDragAndDropNode
+
+            override fun update(node: DragAndDropNode) = Unit
+
+            override fun InspectorInfo.inspectableProperties() {
+                name = "RootDragAndDropNode"
+            }
+
+            override fun hashCode(): Int = rootDragAndDropNode.hashCode()
+
+            override fun equals(other: Any?) = other === this
+        }
+
+    override val isRequestDragAndDropTransferRequired: Boolean
+        get() = true
+
+    override fun requestDragAndDropTransfer(node: DragAndDropNode, offset: Offset) {
+        var isTransferStarted = false
+        val dragAndDropSourceScope =
+            object : DragAndDropStartTransferScope {
+                override fun startDragAndDropTransfer(
+                    transferData: DragAndDropTransferData,
+                    decorationSize: Size,
+                    drawDragDecoration: DrawScope.() -> Unit
+                ): Boolean {
+                    isTransferStarted =
+                        startDrag(
+                            transferData,
+                            decorationSize,
+                            drawDragDecoration,
+                        )
+                    return isTransferStarted
+                }
+            }
+        with(node) { dragAndDropSourceScope.startDragAndDropTransfer(offset) { isTransferStarted } }
+    }
+
+    override fun onDrag(view: View, event: DragEvent): Boolean {
+        val dragAndDropEvent = DragAndDropEvent(dragEvent = event)
+        return when (event.action) {
+            DragEvent.ACTION_DRAG_STARTED -> {
+                val accepted = rootDragAndDropNode.acceptDragAndDropTransfer(dragAndDropEvent)
+                interestedTargets.forEach { it.onStarted(dragAndDropEvent) }
+                accepted
+            }
+            DragEvent.ACTION_DROP -> {
+                rootDragAndDropNode.onDrop(dragAndDropEvent)
+            }
+            DragEvent.ACTION_DRAG_ENTERED -> {
+                rootDragAndDropNode.onEntered(dragAndDropEvent)
+                false
+            }
+            DragEvent.ACTION_DRAG_LOCATION -> {
+                rootDragAndDropNode.onMoved(dragAndDropEvent)
+                false
+            }
+            DragEvent.ACTION_DRAG_EXITED -> {
+                rootDragAndDropNode.onExited(dragAndDropEvent)
+                false
+            }
+            DragEvent.ACTION_DRAG_ENDED -> {
+                rootDragAndDropNode.onEnded(dragAndDropEvent)
+                interestedTargets.clear()
+                false
+            }
+            else -> false
+        }
+    }
+
+    override fun registerTargetInterest(target: DragAndDropTarget) {
+        interestedTargets.add(target)
+    }
+
+    override fun isInterestedTarget(target: DragAndDropTarget): Boolean {
+        return interestedTargets.contains(target)
+    }
+}
diff --git a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/AndroidComposeView.android.kt b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/AndroidComposeView.android.kt
index 259b52c..0a56be2 100644
--- a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/AndroidComposeView.android.kt
+++ b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/AndroidComposeView.android.kt
@@ -33,7 +33,6 @@
 import android.os.SystemClock
 import android.util.LongSparseArray
 import android.util.SparseArray
-import android.view.DragEvent
 import android.view.FocusFinder
 import android.view.KeyEvent as AndroidKeyEvent
 import android.view.MotionEvent
@@ -64,7 +63,6 @@
 import androidx.annotation.DoNotInline
 import androidx.annotation.RequiresApi
 import androidx.annotation.VisibleForTesting
-import androidx.collection.ArraySet
 import androidx.compose.runtime.collection.mutableVectorOf
 import androidx.compose.runtime.derivedStateOf
 import androidx.compose.runtime.getValue
@@ -85,11 +83,8 @@
 import androidx.compose.ui.autofill.performAutofill
 import androidx.compose.ui.autofill.populateViewStructure
 import androidx.compose.ui.contentcapture.AndroidContentCaptureManager
+import androidx.compose.ui.draganddrop.AndroidDragAndDropManager
 import androidx.compose.ui.draganddrop.ComposeDragShadowBuilder
-import androidx.compose.ui.draganddrop.DragAndDropEvent
-import androidx.compose.ui.draganddrop.DragAndDropManager
-import androidx.compose.ui.draganddrop.DragAndDropModifierNode
-import androidx.compose.ui.draganddrop.DragAndDropNode
 import androidx.compose.ui.draganddrop.DragAndDropTransferData
 import androidx.compose.ui.focus.FocusDirection
 import androidx.compose.ui.focus.FocusDirection.Companion.Down
@@ -166,7 +161,6 @@
 import androidx.compose.ui.node.LayoutNode.UsageByParent
 import androidx.compose.ui.node.LayoutNodeDrawScope
 import androidx.compose.ui.node.MeasureAndLayoutDelegate
-import androidx.compose.ui.node.ModifierNodeElement
 import androidx.compose.ui.node.Nodes
 import androidx.compose.ui.node.OwnedLayer
 import androidx.compose.ui.node.Owner
@@ -268,8 +262,6 @@
             onLayoutDirection = ::layoutDirection
         )
 
-    private val dragAndDropModifierOnDragListener = DragAndDropModifierOnDragListener(::startDrag)
-
     override fun getImportantForAutofill(): Int {
         return View.IMPORTANT_FOR_AUTOFILL_YES
     }
@@ -299,7 +291,7 @@
             }
         }
 
-    override val dragAndDropManager: DragAndDropManager = dragAndDropModifierOnDragListener
+    override val dragAndDropManager = AndroidDragAndDropManager(::startDrag)
 
     private val _windowInfo: WindowInfoImpl = WindowInfoImpl()
     override val windowInfo: WindowInfo
@@ -428,7 +420,7 @@
                     .then(rotaryInputModifier)
                     .then(keyInputModifier)
                     .then(focusOwner.modifier)
-                    .then(dragAndDropModifierOnDragListener.modifier)
+                    .then(dragAndDropManager.modifier)
         }
 
     override val rootForTest: RootForTest = this
@@ -802,7 +794,7 @@
         clipChildren = false
         ViewCompat.setAccessibilityDelegate(this, composeAccessibilityDelegate)
         ViewRootForTest.onViewCreatedCallback?.invoke(this)
-        setOnDragListener(this.dragAndDropModifierOnDragListener)
+        setOnDragListener(dragAndDropManager)
         root.attach(this)
 
         // Support for this feature in Compose is tracked here: b/207654434
@@ -2773,88 +2765,6 @@
         )
 }
 
-/** A Class that provides access [View.OnDragListener] APIs for a [DragAndDropNode]. */
-private class DragAndDropModifierOnDragListener(
-    private val startDrag:
-        (
-            transferData: DragAndDropTransferData,
-            decorationSize: Size,
-            drawDragDecoration: DrawScope.() -> Unit
-        ) -> Boolean
-) : View.OnDragListener, DragAndDropManager {
-
-    private val rootDragAndDropNode = DragAndDropNode { null }
-
-    /**
-     * A collection [DragAndDropModifierNode] instances that registered interested in a drag and
-     * drop session by returning true in [DragAndDropModifierNode.onStarted].
-     */
-    private val interestedNodes = ArraySet<DragAndDropModifierNode>()
-
-    override val modifier: Modifier =
-        object : ModifierNodeElement<DragAndDropNode>() {
-            override fun create() = rootDragAndDropNode
-
-            override fun update(node: DragAndDropNode) = Unit
-
-            override fun InspectorInfo.inspectableProperties() {
-                name = "RootDragAndDropNode"
-            }
-
-            override fun hashCode(): Int = rootDragAndDropNode.hashCode()
-
-            override fun equals(other: Any?) = other === this
-        }
-
-    override fun onDrag(view: View, event: DragEvent): Boolean {
-        val dragAndDropEvent = DragAndDropEvent(dragEvent = event)
-        return when (event.action) {
-            DragEvent.ACTION_DRAG_STARTED -> {
-                val accepted = rootDragAndDropNode.acceptDragAndDropTransfer(dragAndDropEvent)
-                interestedNodes.forEach { it.onStarted(dragAndDropEvent) }
-                accepted
-            }
-            DragEvent.ACTION_DROP -> rootDragAndDropNode.onDrop(dragAndDropEvent)
-            DragEvent.ACTION_DRAG_ENTERED -> {
-                rootDragAndDropNode.onEntered(dragAndDropEvent)
-                false
-            }
-            DragEvent.ACTION_DRAG_LOCATION -> {
-                rootDragAndDropNode.onMoved(dragAndDropEvent)
-                false
-            }
-            DragEvent.ACTION_DRAG_EXITED -> {
-                rootDragAndDropNode.onExited(dragAndDropEvent)
-                false
-            }
-            DragEvent.ACTION_DRAG_ENDED -> {
-                rootDragAndDropNode.onEnded(dragAndDropEvent)
-                false
-            }
-            else -> false
-        }
-    }
-
-    override fun drag(
-        transferData: DragAndDropTransferData,
-        decorationSize: Size,
-        drawDragDecoration: DrawScope.() -> Unit,
-    ): Boolean =
-        startDrag(
-            transferData,
-            decorationSize,
-            drawDragDecoration,
-        )
-
-    override fun registerNodeInterest(node: DragAndDropModifierNode) {
-        interestedNodes.add(node)
-    }
-
-    override fun isInterestedNode(node: DragAndDropModifierNode): Boolean {
-        return interestedNodes.contains(node)
-    }
-}
-
 private fun View.containsDescendant(other: View): Boolean {
     if (other == this) return false
     var viewParent = other.parent
diff --git a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/window/AndroidDialog.android.kt b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/window/AndroidDialog.android.kt
index cdf731a..0e07822 100644
--- a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/window/AndroidDialog.android.kt
+++ b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/window/AndroidDialog.android.kt
@@ -344,6 +344,7 @@
                 )
                 .also { it.gravity = Gravity.CENTER }
         )
+        frameLayout.clipChildren = false
         ViewCompat.setOnApplyWindowInsetsListener(frameLayout, this)
         ViewCompat.setWindowInsetsAnimationCallback(
             frameLayout,
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/draganddrop/DragAndDrop.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/draganddrop/DragAndDrop.kt
index ff75eb9..f511843 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/draganddrop/DragAndDrop.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/draganddrop/DragAndDrop.kt
@@ -17,6 +17,8 @@
 package androidx.compose.ui.draganddrop
 
 import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.drawscope.DrawScope
 
 /**
  * Definition for a type representing transferable data. It could be a remote URI, rich text data on
@@ -33,6 +35,27 @@
  */
 internal expect val DragAndDropEvent.positionInRoot: Offset
 
+/** A scope that allows starting a drag and drop session. */
+interface DragAndDropStartTransferScope {
+    /**
+     * Initiates a drag-and-drop operation for transferring data.
+     *
+     * @param transferData the data to be transferred after successful completion of the drag and
+     *   drop gesture.
+     * @param decorationSize the size of the drag decoration to be drawn.
+     * @param drawDragDecoration provides the visual representation of the item dragged during the
+     *   drag and drop gesture.
+     * @return true if the method completes successfully, or false if it fails anywhere. Returning
+     *   false means the system was unable to do a drag because of another ongoing operation or some
+     *   other reasons.
+     */
+    fun startDragAndDropTransfer(
+        transferData: DragAndDropTransferData,
+        decorationSize: Size,
+        drawDragDecoration: DrawScope.() -> Unit,
+    ): Boolean
+}
+
 /** Provides a means of receiving a transfer data from a drag and drop session. */
 interface DragAndDropTarget {
 
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/draganddrop/DragAndDropManager.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/draganddrop/DragAndDropManager.kt
index ae06107..5425cd9 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/draganddrop/DragAndDropManager.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/draganddrop/DragAndDropManager.kt
@@ -17,9 +17,9 @@
 package androidx.compose.ui.draganddrop
 
 import androidx.compose.ui.Modifier
-import androidx.compose.ui.geometry.Size
-import androidx.compose.ui.graphics.drawscope.DrawScope
+import androidx.compose.ui.geometry.Offset
 
+/** A platform implementation for drag and drop functionality. */
 internal interface DragAndDropManager {
 
     /**
@@ -29,32 +29,28 @@
     val modifier: Modifier
 
     /**
-     * Initiates a drag-and-drop operation for transferring data.
-     *
-     * @param transferData the data to be transferred after successful completion of the drag and
-     *   drop gesture.
-     * @param decorationSize the size of the drag decoration to be drawn.
-     * @param drawDragDecoration provides the visual representation of the item dragged during the
-     *   drag and drop gesture.
-     * @return true if the method completes successfully, or false if it fails anywhere. Returning
-     *   false means the system was unable to do a drag because of another ongoing operation or some
-     *   other reasons.
+     * Returns a boolean value indicating whether requesting drag and drop transfer is required. If
+     * it's not, the transfer might be initiated only be system and calling
+     * [requestDragAndDropTransfer] will be ignored.
      */
-    fun drag(
-        transferData: DragAndDropTransferData,
-        decorationSize: Size,
-        drawDragDecoration: DrawScope.() -> Unit,
-    ): Boolean
+    val isRequestDragAndDropTransferRequired: Boolean
 
     /**
-     * Called to notify this [DragAndDropManager] that a [DragAndDropModifierNode] is interested in
+     * Requests a drag and drop transfer. It might ignored in case if the operation performed by
+     * system. [isRequestDragAndDropTransferRequired] can be used to check if it should be used
+     * explicitly.
+     */
+    fun requestDragAndDropTransfer(node: DragAndDropNode, offset: Offset)
+
+    /**
+     * Called to notify this [DragAndDropManager] that a [DragAndDropTarget] is interested in
      * receiving events for a particular drag and drop session.
      */
-    fun registerNodeInterest(node: DragAndDropModifierNode)
+    fun registerTargetInterest(target: DragAndDropTarget)
 
     /**
-     * Called to check if a [DragAndDropModifierNode] has previously registered interest for a drag
-     * and drop session.
+     * Called to check if a [DragAndDropTarget] has previously registered interest for a drag and
+     * drop session.
      */
-    fun isInterestedNode(node: DragAndDropModifierNode): Boolean
+    fun isInterestedTarget(target: DragAndDropTarget): Boolean
 }
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/draganddrop/DragAndDropNode.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/draganddrop/DragAndDropNode.kt
index 20dba97..480d5f8 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/draganddrop/DragAndDropNode.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/draganddrop/DragAndDropNode.kt
@@ -19,10 +19,12 @@
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.geometry.Offset
 import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.geometry.toRect
 import androidx.compose.ui.graphics.drawscope.DrawScope
 import androidx.compose.ui.internal.checkPrecondition
 import androidx.compose.ui.layout.positionInRoot
 import androidx.compose.ui.node.DelegatableNode
+import androidx.compose.ui.node.LayoutAwareModifierNode
 import androidx.compose.ui.node.TraversableNode
 import androidx.compose.ui.node.TraversableNode.Companion.TraverseDescendantsAction
 import androidx.compose.ui.node.TraversableNode.Companion.TraverseDescendantsAction.CancelTraversal
@@ -31,12 +33,22 @@
 import androidx.compose.ui.node.requireLayoutNode
 import androidx.compose.ui.node.requireOwner
 import androidx.compose.ui.node.traverseDescendants
+import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.unit.toSize
+import kotlin.js.JsName
+import kotlin.jvm.JvmName
 
 /**
  * A [Modifier.Node] providing low level access to platform drag and drop operations. In most cases,
  * you will want to delegate to the [DragAndDropModifierNode] returned by the eponymous factory
  * method.
  */
+@Deprecated(
+    message =
+        "This interface is deprecated in favor to " +
+            "DragAndDropSourceModifierNode and DragAndDropTargetModifierNode",
+    replaceWith = ReplaceWith("DragAndDropSourceModifierNode")
+)
 interface DragAndDropModifierNode : DelegatableNode, DragAndDropTarget {
     /**
      * Begins a drag and drop session for transferring data.
@@ -47,6 +59,7 @@
      * @param drawDragDecoration provides the visual representation of the item dragged during the
      *   drag and drop gesture.
      */
+    @Deprecated("Use DragAndDropSourceModifierNode.requestDragAndDropTransfer instead")
     fun drag(
         transferData: DragAndDropTransferData,
         decorationSize: Size,
@@ -67,10 +80,51 @@
 }
 
 /**
+ * A [Modifier.Node] that can be used as a source for platform drag and drop operations. In most
+ * cases, you will want to delegate to the [DragAndDropSourceModifierNode] returned by the eponymous
+ * factory method.
+ */
+sealed interface DragAndDropSourceModifierNode : LayoutAwareModifierNode {
+    /**
+     * Returns a boolean value indicating whether requesting drag and drop transfer is required.
+     *
+     * This variable is used to check if the platform requires drag and drop transfer initiated by
+     * application explicitly, for example via a custom gesture.
+     *
+     * @see requestDragAndDropTransfer
+     */
+    val isRequestDragAndDropTransferRequired: Boolean
+
+    /**
+     * Requests a drag and drop transfer. [isRequestDragAndDropTransferRequired] can be used to
+     * check if it required to be performed.
+     *
+     * @param offset the offset value representing position of the input pointer.
+     */
+    fun requestDragAndDropTransfer(offset: Offset)
+}
+
+/**
+ * A [Modifier.Node] that can be used as a target for platform drag and drop operations. In most
+ * cases, you will want to delegate to the [DragAndDropTargetModifierNode] returned by the eponymous
+ * factory method.
+ *
+ * This interface does not define any additional methods or properties. It simply serves as a marker
+ * interface to identify nodes that can be used as drag and drop target modifiers.
+ */
+sealed interface DragAndDropTargetModifierNode : LayoutAwareModifierNode
+
+/**
  * Creates a [Modifier.Node] for starting platform drag and drop sessions with the intention of
  * transferring data. A drag and stop session is started by calling [DragAndDropModifierNode.drag].
  */
-fun DragAndDropModifierNode(): DragAndDropModifierNode = DragAndDropNode { null }
+@Deprecated(
+    message = "Use DragAndDropSourceModifierNode instead",
+    replaceWith = ReplaceWith("DragAndDropSourceModifierNode")
+)
+@Suppress("DEPRECATION")
+@JsName("funDragAndDropModifierNode1")
+fun DragAndDropModifierNode(): DragAndDropModifierNode = DragAndDropNode(onStartTransfer = null)
 
 /**
  * Creates a [Modifier.Node] for receiving transfer data from platform drag and drop sessions. All
@@ -82,12 +136,47 @@
  *   it.
  * @param target allows for receiving events and transfer data from a given drag and drop session.
  */
+@Deprecated(
+    message = "Use DragAndDropTargetModifierNode instead",
+    replaceWith = ReplaceWith("DragAndDropTargetModifierNode")
+)
+@Suppress("DEPRECATION")
+@JsName("funDragAndDropModifierNode2")
 fun DragAndDropModifierNode(
     shouldStartDragAndDrop: (event: DragAndDropEvent) -> Boolean,
     target: DragAndDropTarget
-): DragAndDropModifierNode = DragAndDropNode { startEvent ->
-    if (shouldStartDragAndDrop(startEvent)) target else null
-}
+): DragAndDropModifierNode =
+    DragAndDropNode(
+        onDropTargetValidate = { event -> if (shouldStartDragAndDrop(event)) target else null }
+    )
+
+/**
+ * Creates a [DragAndDropSourceModifierNode] for starting platform drag and drop sessions with the
+ * intention of transferring data.
+ *
+ * @param onStartTransfer the callback function that is invoked when drag and drop session starts.
+ *   It takes an [Offset] parameter representing the start position of the drag.
+ */
+fun DragAndDropSourceModifierNode(
+    onStartTransfer: DragAndDropStartTransferScope.(Offset) -> Unit,
+): DragAndDropSourceModifierNode = DragAndDropNode(onStartTransfer = onStartTransfer)
+
+/**
+ * Creates a [DragAndDropTargetModifierNode] for receiving transfer data from platform drag and drop
+ * sessions.
+ *
+ * @param shouldStartDragAndDrop allows for inspecting the start [DragAndDropEvent] for a given
+ *   session to decide whether or not the provided [DragAndDropTarget] would like to receive from
+ *   it.
+ * @param target allows for receiving events and transfer data from a given drag and drop session.
+ */
+fun DragAndDropTargetModifierNode(
+    shouldStartDragAndDrop: (event: DragAndDropEvent) -> Boolean,
+    target: DragAndDropTarget
+): DragAndDropTargetModifierNode =
+    DragAndDropNode(
+        onDropTargetValidate = { event -> if (shouldStartDragAndDrop(event)) target else null }
+    )
 
 /**
  * Core implementation of drag and drop. This [Modifier.Node] implements tree traversal for drag and
@@ -102,23 +191,47 @@
  *
  * This optimizes traversal for the common case of move events where the event remains within a
  * single node, or moves to a sibling of the node.
+ *
+ * This intended to be used directly only by [DragAndDropManager].
  */
+@Suppress("DEPRECATION")
 internal class DragAndDropNode(
-    private val onDragAndDropStart: (event: DragAndDropEvent) -> DragAndDropTarget?
-) : Modifier.Node(), TraversableNode, DragAndDropModifierNode {
-    companion object {
+    private var onStartTransfer: (DragAndDropStartTransferScope.(Offset) -> Unit)? = null,
+    private val onDropTargetValidate: ((DragAndDropEvent) -> DragAndDropTarget?)? = null,
+) :
+    Modifier.Node(),
+    TraversableNode,
+    DragAndDropModifierNode,
+    DragAndDropSourceModifierNode,
+    DragAndDropTargetModifierNode,
+    DragAndDropTarget {
+    private companion object {
         private object DragAndDropTraversableKey
     }
 
     override val traverseKey: Any = DragAndDropTraversableKey
 
-    /** Child currently receiving drag gestures for dropping into * */
-    private var lastChildDragAndDropModifierNode: DragAndDropModifierNode? = null
+    private val dragAndDropManager: DragAndDropManager
+        get() = requireOwner().dragAndDropManager
 
-    /** This as a drop target if eligible for processing * */
+    /** Child currently receiving drag gestures for dropping into */
+    private var lastChildDragAndDropModifierNode: DragAndDropNode? = null
+
+    /** This as a drop target if eligible for processing */
     private var thisDragAndDropTarget: DragAndDropTarget? = null
 
+    /**
+     * Indicates whether there is a child that is eligible to receive a drop gesture immediately.
+     * This is true if the last move happened over a child that is interested in receiving a drop.
+     */
+    @get:JvmName("hasEligibleDropTarget")
+    val hasEligibleDropTarget: Boolean
+        get() = lastChildDragAndDropModifierNode != null || thisDragAndDropTarget != null
+
+    internal var size: IntSize = IntSize.Zero
+
     // start Node
+
     override fun onDetach() {
         // Clean up
         thisDragAndDropTarget = null
@@ -127,22 +240,91 @@
 
     // end Node
 
+    // start LayoutAwareModifierNode
+
+    override fun onRemeasured(size: IntSize) {
+        this.size = size
+    }
+
+    // end LayoutAwareModifierNode
+
+    // start DragAndDropSourceModifierNode
+
+    override val isRequestDragAndDropTransferRequired: Boolean
+        get() = dragAndDropManager.isRequestDragAndDropTransferRequired
+
+    override fun requestDragAndDropTransfer(offset: Offset) {
+        checkPrecondition(onStartTransfer != null)
+        dragAndDropManager.requestDragAndDropTransfer(this, offset)
+    }
+
+    // end DragAndDropSourceModifierNode
+
+    /**
+     * Initiates a drag-and-drop operation for transferring data.
+     *
+     * @param offset the offset value representing position of the input pointer.
+     * @param isTransferStarted a lambda function that returns true if the drag-and-drop transfer
+     *   has started, or false otherwise.
+     */
+    fun DragAndDropStartTransferScope.startDragAndDropTransfer(
+        offset: Offset,
+        isTransferStarted: () -> Boolean
+    ) {
+        val nodeCoordinates = requireLayoutNode().coordinates
+        traverseSelfAndDescendants { currentNode ->
+            // TODO: b/303904810 unattached nodes should not be found from an attached
+            //  root drag and drop node
+            if (!currentNode.isAttached) {
+                return@traverseSelfAndDescendants SkipSubtreeAndContinueTraversal
+            }
+
+            val onStartTransfer =
+                currentNode.onStartTransfer ?: return@traverseSelfAndDescendants ContinueTraversal
+
+            if (offset != Offset.Unspecified) {
+                val currentCoordinates = currentNode.requireLayoutNode().coordinates
+                val localPosition = currentCoordinates.localPositionOf(nodeCoordinates, offset)
+                if (!currentNode.size.toSize().toRect().contains(localPosition)) {
+                    return@traverseSelfAndDescendants ContinueTraversal
+                }
+
+                onStartTransfer.invoke(this, localPosition)
+            } else {
+                onStartTransfer.invoke(this, Offset.Unspecified)
+            }
+
+            if (isTransferStarted()) {
+                CancelTraversal
+            } else {
+                ContinueTraversal
+            }
+        }
+    }
+
     // start DragAndDropModifierNode
 
+    @Deprecated("Use DragAndDropSourceModifierNode.requestDragAndDropTransfer instead")
     override fun drag(
         transferData: DragAndDropTransferData,
         decorationSize: Size,
-        drawDragDecoration: DrawScope.() -> Unit,
+        drawDragDecoration: DrawScope.() -> Unit
     ) {
-        requireOwner()
-            .dragAndDropManager
-            .drag(
-                transferData = transferData,
-                decorationSize = decorationSize,
-                drawDragDecoration = drawDragDecoration
-            )
+        checkPrecondition(onStartTransfer == null)
+        onStartTransfer = {
+            startDragAndDropTransfer(transferData, decorationSize, drawDragDecoration)
+        }
+        dragAndDropManager.requestDragAndDropTransfer(this, Offset.Unspecified)
+        onStartTransfer = null
     }
 
+    /**
+     * The entry point to register interest in a drag and drop session for receiving data.
+     *
+     * @return true to indicate interest in the contents of a drag and drop session, false indicates
+     *   no interest. If false is returned, this [Modifier] will not receive any [DragAndDropTarget]
+     *   events.
+     */
     override fun acceptDragAndDropTransfer(startEvent: DragAndDropEvent): Boolean {
         var handled = false
         traverseSelfAndDescendants { currentNode ->
@@ -158,11 +340,11 @@
             }
 
             // Start receiving events
-            currentNode.thisDragAndDropTarget = currentNode.onDragAndDropStart(startEvent)
+            currentNode.thisDragAndDropTarget = currentNode.onDropTargetValidate?.invoke(startEvent)
 
             val accepted = currentNode.thisDragAndDropTarget != null
             if (accepted) {
-                requireOwner().dragAndDropManager.registerNodeInterest(currentNode)
+                dragAndDropManager.registerTargetInterest(currentNode)
             }
             handled = handled || accepted
             ContinueTraversal
@@ -189,8 +371,8 @@
     }
 
     override fun onMoved(event: DragAndDropEvent) {
-        val currentChildNode: DragAndDropModifierNode? = lastChildDragAndDropModifierNode
-        val newChildNode: DragAndDropModifierNode? =
+        val currentChildNode: DragAndDropNode? = lastChildDragAndDropModifierNode
+        val newChildNode: DragAndDropNode? =
             when {
                 // Moved within child.
                 currentChildNode?.contains(event.positionInRoot) == true -> currentChildNode
@@ -198,7 +380,7 @@
                 else ->
                     firstDescendantOrNull { child ->
                         // Only dispatch to children who previously accepted the onStart gesture
-                        requireOwner().dragAndDropManager.isInterestedNode(child) &&
+                        dragAndDropManager.isInterestedTarget(child) &&
                             child.contains(event.positionInRoot)
                     }
             }
@@ -270,17 +452,19 @@
 }
 
 /** Hit test for a [DragAndDropNode]. */
-private fun DragAndDropModifierNode.contains(position: Offset): Boolean {
+private fun DragAndDropNode.contains(positionInRoot: Offset): Boolean {
     if (!node.isAttached) return false
     val currentCoordinates = requireLayoutNode().coordinates
     if (!currentCoordinates.isAttached) return false
 
-    val (width, height) = currentCoordinates.size
     val (x1, y1) = currentCoordinates.positionInRoot()
-    val x2 = x1 + width
-    val y2 = y1 + height
 
-    return position.x in x1..x2 && position.y in y1..y2
+    // Use measured size instead of size from currentCoordinates because it might be different
+    //  (eg if padding is applied)
+    val x2 = x1 + size.width
+    val y2 = y1 + size.height
+
+    return positionInRoot.x in x1..x2 && positionInRoot.y in y1..y2
 }
 
 private fun <T : TraversableNode> T.traverseSelfAndDescendants(
diff --git a/constraintlayout/constraintlayout/build.gradle b/constraintlayout/constraintlayout/build.gradle
index 9b2ddcd..1844e81 100644
--- a/constraintlayout/constraintlayout/build.gradle
+++ b/constraintlayout/constraintlayout/build.gradle
@@ -33,7 +33,7 @@
     implementation("androidx.appcompat:appcompat:1.2.0")
     implementation("androidx.core:core:1.3.2")
     implementation(project(":constraintlayout:constraintlayout-core"))
-    implementation("androidx.profileinstaller:profileinstaller:1.3.1")
+    implementation("androidx.profileinstaller:profileinstaller:1.4.0")
 
     testImplementation(libs.junit)
 
diff --git a/core/core-splashscreen/build.gradle b/core/core-splashscreen/build.gradle
index c2c50ee..3ffa382 100644
--- a/core/core-splashscreen/build.gradle
+++ b/core/core-splashscreen/build.gradle
@@ -47,6 +47,7 @@
     androidTestImplementation(libs.truth)
     androidTestImplementation(project(":appcompat:appcompat"))
     androidTestImplementation(project(":test:screenshot:screenshot"))
+    androidTestImplementation(project(":internal-testutils-runtime"))
 }
 
 androidx {
diff --git a/core/core-splashscreen/src/androidTest/java/androidx/core/splashscreen/test/SplashscreenParametrizedTest.kt b/core/core-splashscreen/src/androidTest/java/androidx/core/splashscreen/test/SplashscreenParametrizedTest.kt
index 96aa766..5bcdaec 100644
--- a/core/core-splashscreen/src/androidTest/java/androidx/core/splashscreen/test/SplashscreenParametrizedTest.kt
+++ b/core/core-splashscreen/src/androidTest/java/androidx/core/splashscreen/test/SplashscreenParametrizedTest.kt
@@ -19,7 +19,6 @@
 import android.content.Intent
 import android.graphics.Bitmap
 import android.os.Bundle
-import android.os.SystemClock
 import android.util.Base64
 import android.util.Log
 import android.view.View
@@ -31,6 +30,7 @@
 import androidx.test.platform.app.InstrumentationRegistry
 import androidx.test.screenshot.matchers.MSSIMMatcher
 import androidx.test.uiautomator.UiDevice
+import androidx.testutils.PollingCheck
 import java.io.ByteArrayOutputStream
 import java.io.File
 import java.io.FileInputStream
@@ -161,7 +161,8 @@
             // During the transition from the splash screen of system starting window to the
             // activity, there may be a moment that `PhoneWindowManager`'s
             // `mTopFullscreenOpaqueWindowState` would be `null`, which might lead to the flicker of
-            // status bar (b/64291272, ag/2664318)
+            // status bar (b/64291272,
+            // https://android.googlesource.com/platform/frameworks/base/+/c0c9324fcb03c85ef7bed2d997c441119823d31c%5E%21/)
             val topFullscreenWinState = "mTopFullscreenOpaqueWindowState"
 
             // We should take the screenshot when `mTopFullscreenOpaqueWindowState` is window of the
@@ -185,15 +186,8 @@
                     dumpedWindowPolicy.contains(topFullscreenWinStateBelongsToActivity)
             }
 
-            val timeout = 2000L
-            val interval = 100L
-            val start = SystemClock.uptimeMillis()
-            var topFullscreenWinStateReady = isTopFullscreenWinStateReady()
-            while (!topFullscreenWinStateReady && SystemClock.uptimeMillis() - start < timeout) {
-                SystemClock.sleep(interval)
-                topFullscreenWinStateReady = isTopFullscreenWinStateReady()
-            }
-            if (!topFullscreenWinStateReady)
+            PollingCheck.waitFor(2000, isTopFullscreenWinStateReady)
+            if (!isTopFullscreenWinStateReady())
                 fail("$topFullscreenWinState is not ready, cannot take screenshot")
 
             splashScreenViewScreenShot =
diff --git a/core/core-telecom/integration-tests/testapp/src/main/java/androidx/core/telecom/test/CallListAdapter.kt b/core/core-telecom/integration-tests/testapp/src/main/java/androidx/core/telecom/test/CallListAdapter.kt
index f4ec9ac..44f5287 100644
--- a/core/core-telecom/integration-tests/testapp/src/main/java/androidx/core/telecom/test/CallListAdapter.kt
+++ b/core/core-telecom/integration-tests/testapp/src/main/java/androidx/core/telecom/test/CallListAdapter.kt
@@ -47,6 +47,7 @@
         val callIdTextView: TextView = itemView.findViewById(R.id.callIdTextView)
         val currentState: TextView = itemView.findViewById(R.id.callStateTextView)
         val currentEndpoint: TextView = itemView.findViewById(R.id.endpointStateTextView)
+        val participants: TextView = itemView.findViewById(R.id.participantsTextView)
 
         // Call State Buttons
         val activeButton: Button = itemView.findViewById(R.id.activeButton)
@@ -57,6 +58,10 @@
         val earpieceButton: Button = itemView.findViewById(R.id.selectEndpointButton)
         val speakerButton: Button = itemView.findViewById(R.id.speakerButton)
         val bluetoothButton: Button = itemView.findViewById(R.id.bluetoothButton)
+
+        // Participant Buttons
+        val addParticipantButton: Button = itemView.findViewById(R.id.addParticipantButton)
+        val removeParticipantButton: Button = itemView.findViewById(R.id.removeParticipantButton)
     }
 
     override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
@@ -180,6 +185,25 @@
                     }
                 }
             }
+
+            holder.addParticipantButton.setOnClickListener {
+                CoroutineScope(Dispatchers.Main).launch {
+                    ItemsViewModel.callObject.mParticipantControl?.onParticipantAdded?.invoke()
+                }
+            }
+
+            holder.removeParticipantButton.setOnClickListener {
+                CoroutineScope(Dispatchers.Main).launch {
+                    ItemsViewModel.callObject.mParticipantControl?.onParticipantRemoved?.invoke()
+                }
+            }
+        }
+    }
+
+    fun updateParticipants(callId: String, participants: List<ParticipantState>) {
+        CoroutineScope(Dispatchers.Main).launch {
+            val holder = mCallIdToViewHolder[callId]
+            holder?.participants?.text = "participants=[${printParticipants(participants)}]"
         }
     }
 
@@ -197,6 +221,32 @@
         }
     }
 
+    private fun printParticipants(participants: List<ParticipantState>): String {
+        if (participants.isEmpty()) return "<NONE>"
+        val builder = StringBuilder()
+        val iterator = participants.iterator()
+        while (iterator.hasNext()) {
+            val participant = iterator.next()
+            builder.append("<")
+            if (participant.isActive) {
+                builder.append(" * ")
+            }
+            builder.append(participant.name)
+            if (participant.isSelf) {
+                builder.append("(me)")
+            }
+            if (participant.isHandRaised) {
+                builder.append(" ")
+                builder.append("(RH)")
+            }
+            builder.append(">")
+            if (iterator.hasNext()) {
+                builder.append(", ")
+            }
+        }
+        return builder.toString()
+    }
+
     private fun endAudioRecording() {
         try {
             // Stop audio recording
diff --git a/core/core-telecom/integration-tests/testapp/src/main/java/androidx/core/telecom/test/CallingMainActivity.kt b/core/core-telecom/integration-tests/testapp/src/main/java/androidx/core/telecom/test/CallingMainActivity.kt
index 77025be..79752752 100644
--- a/core/core-telecom/integration-tests/testapp/src/main/java/androidx/core/telecom/test/CallingMainActivity.kt
+++ b/core/core-telecom/integration-tests/testapp/src/main/java/androidx/core/telecom/test/CallingMainActivity.kt
@@ -27,6 +27,8 @@
 import androidx.core.telecom.CallAttributesCompat
 import androidx.core.telecom.CallEndpointCompat
 import androidx.core.telecom.CallsManager
+import androidx.core.telecom.extensions.RaiseHandState
+import androidx.core.telecom.util.ExperimentalAppActions
 import androidx.core.view.WindowCompat
 import androidx.recyclerview.widget.LinearLayoutManager
 import androidx.recyclerview.widget.RecyclerView
@@ -34,6 +36,9 @@
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.cancel
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
 import kotlinx.coroutines.launch
 
 @RequiresApi(34)
@@ -65,6 +70,22 @@
         mCallsManager = CallsManager(this)
         mCallCount = 0
 
+        val raiseHandCheckBox = findViewById<CheckBox>(R.id.RaiseHandCheckbox)
+        val kickParticipantCheckBox = findViewById<CheckBox>(R.id.KickPartCheckbox)
+        val participantCheckBox = findViewById<CheckBox>(R.id.ParticipantsCheckbox)
+
+        participantCheckBox.setOnCheckedChangeListener { _, isChecked ->
+            if (!isChecked) {
+                raiseHandCheckBox.isEnabled = false
+                raiseHandCheckBox.isChecked = false
+                kickParticipantCheckBox.isEnabled = false
+                kickParticipantCheckBox.isChecked = false
+            } else {
+                raiseHandCheckBox.isEnabled = true
+                kickParticipantCheckBox.isEnabled = true
+            }
+        }
+
         val registerPhoneAccountButton = findViewById<Button>(R.id.registerButton)
         registerPhoneAccountButton.setOnClickListener { mScope.launch { registerPhoneAccount() } }
 
@@ -75,12 +96,26 @@
 
         val addOutgoingCallButton = findViewById<Button>(R.id.addOutgoingCall)
         addOutgoingCallButton.setOnClickListener {
-            mScope.launch { addCallWithAttributes(Utilities.OUTGOING_CALL_ATTRIBUTES) }
+            mScope.launch {
+                addCallWithAttributes(
+                    Utilities.OUTGOING_CALL_ATTRIBUTES,
+                    participantCheckBox.isChecked,
+                    raiseHandCheckBox.isChecked,
+                    kickParticipantCheckBox.isChecked
+                )
+            }
         }
 
         val addIncomingCallButton = findViewById<Button>(R.id.addIncomingCall)
         addIncomingCallButton.setOnClickListener {
-            mScope.launch { addCallWithAttributes(Utilities.INCOMING_CALL_ATTRIBUTES) }
+            mScope.launch {
+                addCallWithAttributes(
+                    Utilities.INCOMING_CALL_ATTRIBUTES,
+                    participantCheckBox.isChecked,
+                    raiseHandCheckBox.isChecked,
+                    kickParticipantCheckBox.isChecked
+                )
+            }
         }
 
         // setup the adapters which hold the endpoint and call rows
@@ -124,7 +159,12 @@
         mCallsManager?.registerAppWithTelecom(capabilities)
     }
 
-    private suspend fun addCallWithAttributes(attributes: CallAttributesCompat) {
+    private suspend fun addCallWithAttributes(
+        attributes: CallAttributesCompat,
+        isParticipantsEnabled: Boolean,
+        isRaiseHandEnabled: Boolean,
+        isKickParticipantEnabled: Boolean
+    ) {
         Log.i(TAG, "addCallWithAttributes: attributes=$attributes")
         val callObject = VoipCall()
 
@@ -132,36 +172,17 @@
             val handler = CoroutineExceptionHandler { _, exception ->
                 Log.i(TAG, "CoroutineExceptionHandler: handling e=$exception")
             }
-
             CoroutineScope(Dispatchers.Default).launch(handler) {
                 try {
-                    attributes.preferredStartingCallEndpoint =
-                        mPreCallEndpointAdapter.mSelectedCallEndpoint
-                    mCallsManager!!.addCall(
-                        attributes,
-                        callObject.mOnAnswerLambda,
-                        callObject.mOnDisconnectLambda,
-                        callObject.mOnSetActiveLambda,
-                        callObject.mOnSetInActiveLambda,
-                    ) {
-                        mPreCallEndpointAdapter.mSelectedCallEndpoint = null
-                        // inject client control interface into the VoIP call object
-                        callObject.setCallId(getCallId().toString())
-                        callObject.setCallControl(this)
-
-                        // Collect updates
-                        launch {
-                            currentCallEndpoint.collect { callObject.onCallEndpointChanged(it) }
-                        }
-
-                        launch {
-                            availableEndpoints.collect {
-                                callObject.onAvailableCallEndpointsChanged(it)
-                            }
-                        }
-
-                        launch { isMuted.collect { callObject.onMuteStateChanged(it) } }
-                        addCallRow(callObject)
+                    if (isParticipantsEnabled) {
+                        addCallWithExtensions(
+                            attributes,
+                            callObject,
+                            isRaiseHandEnabled,
+                            isKickParticipantEnabled
+                        )
+                    } else {
+                        addCall(attributes, callObject)
                     }
                 } catch (e: Exception) {
                     logException(e, "addCallWithAttributes: catch inner")
@@ -174,6 +195,108 @@
         }
     }
 
+    private suspend fun addCall(attributes: CallAttributesCompat, callObject: VoipCall) {
+        mCallsManager!!.addCall(
+            attributes,
+            callObject.mOnAnswerLambda,
+            callObject.mOnDisconnectLambda,
+            callObject.mOnSetActiveLambda,
+            callObject.mOnSetInActiveLambda,
+        ) {
+            mPreCallEndpointAdapter.mSelectedCallEndpoint = null
+            // inject client control interface into the VoIP call object
+            callObject.setCallId(getCallId().toString())
+            callObject.setCallControl(this)
+
+            // Collect updates
+            launch { currentCallEndpoint.collect { callObject.onCallEndpointChanged(it) } }
+
+            launch { availableEndpoints.collect { callObject.onAvailableCallEndpointsChanged(it) } }
+
+            launch { isMuted.collect { callObject.onMuteStateChanged(it) } }
+            addCallRow(callObject)
+        }
+    }
+
+    @OptIn(ExperimentalAppActions::class)
+    private suspend fun addCallWithExtensions(
+        attributes: CallAttributesCompat,
+        callObject: VoipCall,
+        isRaiseHandEnabled: Boolean = false,
+        isKickParticipantEnabled: Boolean = false
+    ) {
+        mCallsManager!!.addCallWithExtensions(
+            attributes,
+            callObject.mOnAnswerLambda,
+            callObject.mOnDisconnectLambda,
+            callObject.mOnSetActiveLambda,
+            callObject.mOnSetInActiveLambda,
+        ) {
+            val participants = ParticipantsExtensionManager()
+            val participantExtension =
+                addParticipantExtension(
+                    initialParticipants =
+                        participants.participants.value.map { it.toParticipant() }.toSet()
+                )
+            var raiseHandState: RaiseHandState? = null
+            if (isRaiseHandEnabled) {
+                raiseHandState =
+                    participantExtension.addRaiseHandSupport {
+                        participants.onRaisedHandStateChanged(it)
+                    }
+            }
+            if (isKickParticipantEnabled) {
+                participantExtension.addKickParticipantSupport {
+                    participants.onKickParticipant(it)
+                }
+            }
+            onCall {
+                mPreCallEndpointAdapter.mSelectedCallEndpoint = null
+                // inject client control interface into the VoIP call object
+                callObject.setCallId(getCallId().toString())
+                callObject.setCallControl(this)
+                callObject.setParticipantControl(
+                    ParticipantControl(
+                        onParticipantAdded = participants::addParticipant,
+                        onParticipantRemoved = participants::removeParticipant
+                    )
+                )
+                addCallRow(callObject)
+
+                // Collect updates
+                participants.participants
+                    .onEach {
+                        participantExtension.updateParticipants(
+                            it.map { p -> p.toParticipant() }.toSet()
+                        )
+                        participantExtension.updateActiveParticipant(
+                            it.firstOrNull { p -> p.isActive }?.toParticipant()
+                        )
+                        raiseHandState?.updateRaisedHands(
+                            it.filter { p -> p.isHandRaised }.map { p -> p.toParticipant() }
+                        )
+                        callObject.onParticipantsChanged(it)
+                    }
+                    .launchIn(this)
+
+                launch {
+                    while (true) {
+                        delay(1000)
+                        participants.changeParticipantStates()
+                    }
+                }
+
+                launch { currentCallEndpoint.collect { callObject.onCallEndpointChanged(it) } }
+
+                launch {
+                    availableEndpoints.collect { callObject.onAvailableCallEndpointsChanged(it) }
+                }
+
+                launch { isMuted.collect { callObject.onMuteStateChanged(it) } }
+            }
+        }
+    }
+
     private fun fetchPreCallEndpoints(cancelFlowButton: Button) {
         val endpointsFlow = mCallsManager!!.getAvailableStartingCallEndpoints()
         CoroutineScope(Dispatchers.Default).launch {
diff --git a/core/core-telecom/integration-tests/testapp/src/main/java/androidx/core/telecom/test/ParticipantState.kt b/core/core-telecom/integration-tests/testapp/src/main/java/androidx/core/telecom/test/ParticipantState.kt
new file mode 100644
index 0000000..ddfccb2
--- /dev/null
+++ b/core/core-telecom/integration-tests/testapp/src/main/java/androidx/core/telecom/test/ParticipantState.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.core.telecom.test
+
+import androidx.core.telecom.extensions.Participant
+import androidx.core.telecom.util.ExperimentalAppActions
+
+/** The state of one participant in a call */
+data class ParticipantState(
+    val id: String,
+    val name: String,
+    val isActive: Boolean,
+    val isHandRaised: Boolean,
+    val isSelf: Boolean
+)
+
+/** Control callback handler for adding/removing new participants in the Call via UI */
+data class ParticipantControl(
+    val onParticipantAdded: () -> Unit,
+    val onParticipantRemoved: () -> Unit
+)
+
+@OptIn(ExperimentalAppActions::class)
+fun ParticipantState.toParticipant(): Participant {
+    return Participant(id, name)
+}
diff --git a/core/core-telecom/integration-tests/testapp/src/main/java/androidx/core/telecom/test/ParticipantsExtensionManager.kt b/core/core-telecom/integration-tests/testapp/src/main/java/androidx/core/telecom/test/ParticipantsExtensionManager.kt
new file mode 100644
index 0000000..0f16fb8
--- /dev/null
+++ b/core/core-telecom/integration-tests/testapp/src/main/java/androidx/core/telecom/test/ParticipantsExtensionManager.kt
@@ -0,0 +1,140 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.core.telecom.test
+
+import androidx.core.telecom.extensions.Participant
+import androidx.core.telecom.util.ExperimentalAppActions
+import java.util.concurrent.atomic.AtomicInteger
+import kotlin.random.Random
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+
+/** Manages the extension in a Call */
+@OptIn(ExperimentalAppActions::class)
+class ParticipantsExtensionManager {
+    companion object {
+        // Represents "self" in participants window, which allows raise hand state modification and
+        // no kicking
+        internal val SELF_PARTICIPANT =
+            ParticipantState(
+                "0",
+                "Participant 0",
+                isHandRaised = false,
+                isActive = false,
+                isSelf = true
+            )
+    }
+
+    private val nextId = AtomicInteger(1)
+    private val mParticipants: MutableStateFlow<List<ParticipantState>> =
+        MutableStateFlow(listOf(SELF_PARTICIPANT))
+    /** The current state of participants for the given call */
+    val participants = mParticipants.asStateFlow()
+
+    /** Adds a new Participant to the call. */
+    fun addParticipant() {
+        val id = nextId.getAndAdd(1)
+        mParticipants.update {
+            ArrayList(it).apply {
+                add(
+                    ParticipantState(
+                        id = "$id",
+                        name = "Participant $id",
+                        isHandRaised = false,
+                        isActive = false,
+                        isSelf = false
+                    )
+                )
+            }
+        }
+    }
+
+    /** Removes the last participant in the List */
+    fun removeParticipant() {
+        mParticipants.update { participants ->
+            if (participants.isEmpty()) return
+            if (participants.last().isSelf) return
+            ArrayList(participants).apply { remove(last()) }
+        }
+    }
+
+    /** randomly change all Participant raise hand/active states one time */
+    fun changeParticipantStates() {
+        mParticipants.update { participants ->
+            // Randomly choose a participant to make active & get hand raised
+            val nextActive = Random.nextInt(0, participants.size + 1) - 1
+            var raisedHandParticipant: ParticipantState? = null
+            if (participants.size > 1) {
+                val nextRaisedHand = Random.nextInt(0, participants.size)
+                if (nextRaisedHand > 0) {
+                    // self controls their own raised hand
+                    raisedHandParticipant = participants.getOrNull(nextRaisedHand)
+                }
+            }
+            val activeParticipant = participants.getOrNull(nextActive)
+
+            participants.map { p ->
+                ParticipantState(
+                    id = p.id,
+                    name = p.name,
+                    isActive = activeParticipant?.id == p.id,
+                    isHandRaised =
+                        if (SELF_PARTICIPANT.id != p.id) {
+                            raisedHandParticipant?.id == p.id
+                        } else {
+                            p.isHandRaised
+                        },
+                    isSelf = p.isSelf
+                )
+            }
+        }
+    }
+
+    /** Change the raised hand state of the participant representing this user */
+    fun onRaisedHandStateChanged(isHandRaised: Boolean) {
+        mParticipants.update { state ->
+            val newState = ArrayList<ParticipantState>()
+            for (p in state) {
+                if (p.id == SELF_PARTICIPANT.id) {
+                    newState.add(
+                        ParticipantState(
+                            id = p.id,
+                            name = p.name,
+                            isActive = p.isActive,
+                            isHandRaised = isHandRaised,
+                            isSelf = p.isSelf
+                        )
+                    )
+                } else {
+                    newState.add(p)
+                }
+            }
+            newState
+        }
+    }
+
+    /** Kick a participant as long as it is not this user */
+    fun onKickParticipant(participant: Participant) {
+        mParticipants.update { state ->
+            if (participant.id == SELF_PARTICIPANT.id) return
+            val candidate = state.firstOrNull { it.id == participant.id }
+            if (candidate == null) return
+            ArrayList(state).apply { remove(candidate) }
+        }
+    }
+}
diff --git a/core/core-telecom/integration-tests/testapp/src/main/java/androidx/core/telecom/test/VoipCall.kt b/core/core-telecom/integration-tests/testapp/src/main/java/androidx/core/telecom/test/VoipCall.kt
index 1b18a37..912610e 100644
--- a/core/core-telecom/integration-tests/testapp/src/main/java/androidx/core/telecom/test/VoipCall.kt
+++ b/core/core-telecom/integration-tests/testapp/src/main/java/androidx/core/telecom/test/VoipCall.kt
@@ -28,6 +28,7 @@
 
     var mAdapter: CallListAdapter? = null
     var mCallControl: CallControlScope? = null
+    var mParticipantControl: ParticipantControl? = null
     var mCurrentEndpoint: CallEndpointCompat? = null
     var mAvailableEndpoints: List<CallEndpointCompat>? = ArrayList()
     var mIsMuted = false
@@ -57,6 +58,10 @@
         mCallControl = callControl
     }
 
+    fun setParticipantControl(participantControl: ParticipantControl) {
+        mParticipantControl = participantControl
+    }
+
     fun setCallAdapter(adapter: CallListAdapter?) {
         mAdapter = adapter
     }
@@ -65,6 +70,10 @@
         mTelecomCallId = callId
     }
 
+    fun onParticipantsChanged(participants: List<ParticipantState>) {
+        mAdapter?.updateParticipants(mTelecomCallId, participants)
+    }
+
     fun onCallEndpointChanged(endpoint: CallEndpointCompat) {
         Log.i(TAG, "onCallEndpointChanged: endpoint=$endpoint")
         mCurrentEndpoint = endpoint
diff --git a/core/core-telecom/integration-tests/testapp/src/main/res/layout/activity_main.xml b/core/core-telecom/integration-tests/testapp/src/main/res/layout/activity_main.xml
index 68efdc9..f7dd3d3 100644
--- a/core/core-telecom/integration-tests/testapp/src/main/res/layout/activity_main.xml
+++ b/core/core-telecom/integration-tests/testapp/src/main/res/layout/activity_main.xml
@@ -53,10 +53,37 @@
         <CheckBox
             android:id="@+id/streamingCheckBox"
             android:layout_width="match_parent"
-            android:layout_height="61dp"
+            android:layout_height="wrap_content"
             android:padding="16dp"
             android:text="CAPABILITY_SUPPORTS_CALL_STREAMING" />
 
+        <LinearLayout
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:orientation="horizontal">
+
+            <CheckBox
+                android:id="@+id/ParticipantsCheckbox"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:checked="true"
+                android:padding="16dp"
+                android:text="Participants" />
+
+            <CheckBox
+                android:id="@+id/RaiseHandCheckbox"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:padding="16dp"
+                android:text="Raise Hand" />
+            <CheckBox
+                android:id="@+id/KickPartCheckbox"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:padding="16dp"
+                android:text="Kick" />
+        </LinearLayout>
+
         <Button
             android:id="@+id/registerButton"
             android:layout_width="wrap_content"
diff --git a/core/core-telecom/integration-tests/testapp/src/main/res/layout/call_row.xml b/core/core-telecom/integration-tests/testapp/src/main/res/layout/call_row.xml
index ae53266..b12fe73 100644
--- a/core/core-telecom/integration-tests/testapp/src/main/res/layout/call_row.xml
+++ b/core/core-telecom/integration-tests/testapp/src/main/res/layout/call_row.xml
@@ -61,6 +61,18 @@
                 android:text="currentEndpoint=[null]" />
 
         </LinearLayout>
+        <LinearLayout
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:orientation="horizontal">
+
+            <TextView
+                android:id="@+id/participantsTextView"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:text="participants=[null]" />
+
+        </LinearLayout>
 
         <LinearLayout
             android:layout_width="match_parent"
@@ -118,5 +130,25 @@
                 android:text="bluetooth" />
         </LinearLayout>
 
+        <LinearLayout
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:orientation="horizontal">
+
+            <Button
+                android:id="@+id/addParticipantButton"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:layout_weight="1"
+                android:text= "+ Participant" />
+
+            <Button
+                android:id="@+id/removeParticipantButton"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:layout_weight="1"
+                android:text="- Participant" />
+        </LinearLayout>
+
     </LinearLayout>
 </LinearLayout>
\ No newline at end of file
diff --git a/core/core-telecom/integration-tests/testicsapp/build.gradle b/core/core-telecom/integration-tests/testicsapp/build.gradle
new file mode 100644
index 0000000..b8b9fdc
--- /dev/null
+++ b/core/core-telecom/integration-tests/testicsapp/build.gradle
@@ -0,0 +1,64 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+plugins {
+    id("AndroidXPlugin")
+    id("com.android.application")
+    id("AndroidXComposePlugin")
+    id("org.jetbrains.kotlin.android")
+}
+
+android {
+    namespace 'androidx.core.telecom.test'
+
+    defaultConfig {
+        applicationId "androidx.core.telecom.icstest"
+        minSdk 29 // Move down to 23 if we support CallingApp:checkDialerRole for < Q
+    }
+
+    buildTypes {
+        release {
+            minifyEnabled true
+            shrinkResources true
+            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt')
+        }
+    }
+    buildFeatures {
+        viewBinding true
+    }
+}
+
+dependencies {
+    implementation(libs.kotlinStdlib)
+    //@Serialize
+    implementation(libs.kotlinSerializationCore)
+    // Test package
+    implementation project(":core:core-telecom")
+    // Compose
+    implementation("androidx.activity:activity-compose:1.9.1")
+    // Themes and Dynamic coloring
+    implementation("androidx.compose.material3:material3:1.2.1")
+    // Icons
+    implementation("androidx.compose.material:material-icons-core:1.6.8")
+    // @Preview
+    implementation("androidx.compose.ui:ui-tooling-preview:1.6.8")
+    debugImplementation("androidx.compose.ui:ui-tooling:1.6.8")
+    // Navigation
+    implementation("androidx.navigation:navigation-compose:2.7.7")
+    // collectAsStateWithLifecycle
+    implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.4")
+}
+
diff --git a/core/core-telecom/integration-tests/testicsapp/src/main/AndroidManifest.xml b/core/core-telecom/integration-tests/testicsapp/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..578dcca
--- /dev/null
+++ b/core/core-telecom/integration-tests/testicsapp/src/main/AndroidManifest.xml
@@ -0,0 +1,66 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  Copyright 2024 The Android Open Source Project
+
+  Licensed under the Apache License, Version 2.0 (the "License");
+  you may not use this file except in compliance with the License.
+  You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+  -->
+
+<manifest xmlns:tools="http://schemas.android.com/tools"
+    xmlns:android="http://schemas.android.com/apk/res/android">
+
+    <uses-permission android:name="android.permission.READ_PHONE_STATE"/>
+    <uses-permission android:name="android.permission.READ_CONTACTS"/>
+
+    <application
+        android:icon="@drawable/ic_launcher"
+        android:label="@string/app_name"
+        android:theme="@style/Theme.IcsTest">
+
+        <activity
+            android:name=".ui.CallingActivity"
+            android:exported="true"
+            android:label="@string/main_activity_name">
+            <intent-filter>
+                <action android:name="android.intent.action.MAIN" />
+                <category android:name="android.intent.category.LAUNCHER" />
+            </intent-filter>
+        </activity>
+
+        <service android:name=".services.InCallServiceImpl"
+            android:permission="android.permission.BIND_INCALL_SERVICE"
+            android:exported="true"
+            tools:ignore="MissingServiceExportedEqualsTrue">
+            <meta-data android:name="android.telecom.IN_CALL_SERVICE_UI" android:value="true" />
+            <meta-data android:name="android.telecom.INCLUDE_SELF_MANAGED_CALLS" android:value="true" />
+            <intent-filter>
+                <action android:name="android.telecom.InCallService"/>
+            </intent-filter>
+        </service>
+
+        <activity android:name=".DialerActivity"
+            android:label="DialerActivity"
+            android:exported="true">
+            <intent-filter>
+                <action android:name="android.intent.action.DIAL" />
+                <category android:name="android.intent.category.DEFAULT" />
+            </intent-filter>
+            <intent-filter>
+                <action android:name="android.intent.action.DIAL" />
+                <category android:name="android.intent.category.DEFAULT" />
+                <data android:scheme="tel" />
+            </intent-filter>
+        </activity>
+
+    </application>
+
+</manifest>
\ No newline at end of file
diff --git a/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/Compatibility.kt b/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/Compatibility.kt
new file mode 100644
index 0000000..a623e2a
--- /dev/null
+++ b/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/Compatibility.kt
@@ -0,0 +1,168 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.core.telecom.test
+
+import android.bluetooth.BluetoothDevice
+import android.net.Uri
+import android.os.Build
+import android.os.OutcomeReceiver
+import android.telecom.Call
+import android.telecom.Call.Details
+import android.telecom.CallEndpoint
+import android.telecom.CallEndpointException
+import android.telecom.InCallService
+import androidx.annotation.RequiresApi
+import java.util.concurrent.Executor
+
+/** Ensure compatibility for APIs back to API level 29 */
+object Compatibility {
+    @JvmStatic
+    fun getContactDisplayName(details: Details): String? {
+        return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+            Api30Impl.getContactDisplayName(details)
+        } else {
+            details.callerDisplayName
+        }
+    }
+
+    @JvmStatic
+    fun getContactPhotoUri(details: Details): Uri? {
+        return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+            Api34Impl.getContactDisplayUri(details)
+        } else {
+            null
+        }
+    }
+
+    @Suppress("DEPRECATION")
+    @JvmStatic
+    fun getCallState(call: Call): Int {
+        return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+            Api31Impl.getCallState(call)
+        } else {
+            call.state
+        }
+    }
+
+    @JvmStatic
+    fun getBluetoothDeviceAlias(device: BluetoothDevice): Result<String?> {
+        return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+            Api30Impl.getBluetoothDeviceAlias(device)
+        } else {
+            Result.success(null)
+        }
+    }
+
+    @JvmStatic
+    fun getEndpointIdentifier(endpoint: CallEndpoint): String? {
+        return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+            Api34Impl.getEndpointIdentifier(endpoint)
+        } else {
+            null
+        }
+    }
+
+    @JvmStatic
+    fun getEndpointName(endpoint: CallEndpoint): String? {
+        return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+            Api34Impl.getEndpointName(endpoint).toString()
+        } else {
+            null
+        }
+    }
+
+    @JvmStatic
+    fun getEndpointType(endpoint: CallEndpoint): Int? {
+        return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+            Api34Impl.getEndpointType(endpoint)
+        } else {
+            null
+        }
+    }
+
+    @JvmStatic
+    fun requestCallEndpointChange(
+        service: InCallService,
+        endpoint: CallEndpoint,
+        executor: Executor,
+        callback: OutcomeReceiver<Void, CallEndpointException>
+    ) {
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+            Api34Impl.requestCallEndpointChange(service, endpoint, executor, callback)
+        }
+    }
+}
+
+@RequiresApi(Build.VERSION_CODES.R)
+object Api30Impl {
+    @JvmStatic
+    fun getContactDisplayName(details: Details): String? {
+        return details.contactDisplayName
+    }
+
+    @JvmStatic
+    fun getBluetoothDeviceAlias(device: BluetoothDevice): Result<String?> {
+        return try {
+            Result.success(device.alias)
+        } catch (e: SecurityException) {
+            Result.failure(e)
+        }
+    }
+}
+
+/** Ensure compatibility for [Call] APIs for API level 31+ */
+@RequiresApi(Build.VERSION_CODES.S)
+object Api31Impl {
+    @JvmStatic
+    fun getCallState(call: Call): Int {
+        return call.details.state
+    }
+}
+
+@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
+object Api34Impl {
+
+    @JvmStatic
+    fun getContactDisplayUri(details: Details): Uri? {
+        return details.contactPhotoUri
+    }
+
+    @JvmStatic
+    fun getEndpointIdentifier(endpoint: CallEndpoint): String {
+        return endpoint.identifier.toString()
+    }
+
+    @JvmStatic
+    fun getEndpointName(endpoint: CallEndpoint): CharSequence {
+        return endpoint.endpointName
+    }
+
+    @JvmStatic
+    fun getEndpointType(endpoint: CallEndpoint): Int {
+        return endpoint.endpointType
+    }
+
+    @JvmStatic
+    fun requestCallEndpointChange(
+        service: InCallService,
+        endpoint: CallEndpoint,
+        executor: Executor,
+        callback: OutcomeReceiver<Void, CallEndpointException>
+    ) {
+        service.requestCallEndpointChange(endpoint, executor, callback)
+    }
+}
diff --git a/compose/foundation/foundation-layout/src/jvmMain/kotlin/androidx/compose/foundation/layout/RowColumnMeasurePolicy.jvm.kt b/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/DialerActivity.kt
similarity index 65%
rename from compose/foundation/foundation-layout/src/jvmMain/kotlin/androidx/compose/foundation/layout/RowColumnMeasurePolicy.jvm.kt
rename to core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/DialerActivity.kt
index c273ad3..faab36a 100644
--- a/compose/foundation/foundation-layout/src/jvmMain/kotlin/androidx/compose/foundation/layout/RowColumnMeasurePolicy.jvm.kt
+++ b/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/DialerActivity.kt
@@ -14,12 +14,13 @@
  * limitations under the License.
  */
 
-package androidx.compose.foundation.layout
+package androidx.core.telecom.test
 
-@Suppress("NOTHING_TO_INLINE")
-internal actual inline fun initCause(
-    exception: IllegalArgumentException,
-    cause: Exception
-): Throwable {
-    return exception.initCause(cause)
-}
+import androidx.activity.ComponentActivity
+
+/**
+ * Not used yet - mainly here to fulfill the role requirements for this test application.
+ *
+ * This activity will become useful if dialing numbers becomes a requirement for this application.
+ */
+class DialerActivity : ComponentActivity()
diff --git a/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/services/CallAudioRouteResolver.kt b/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/services/CallAudioRouteResolver.kt
new file mode 100644
index 0000000..7459b7b
--- /dev/null
+++ b/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/services/CallAudioRouteResolver.kt
@@ -0,0 +1,340 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR 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.services
+
+import android.bluetooth.BluetoothDevice
+import android.os.Build
+import android.os.OutcomeReceiver
+import android.telecom.CallAudioState
+import android.telecom.CallEndpoint
+import android.telecom.CallEndpointException
+import androidx.annotation.RequiresApi
+import androidx.core.telecom.test.Compatibility
+import java.util.UUID
+import java.util.concurrent.Executor
+import kotlin.coroutines.resume
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.async
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.filterNotNull
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.suspendCancellableCoroutine
+
+/**
+ * Tracks the current state of the available and current audio route on the device while in call,
+ * taking into account the device's android API version.
+ *
+ * @param coroutineScope The scope attached to the lifecycle of the Service
+ * @param callData The stream of calls that are active on this device
+ * @param onChangeAudioRoute The callback used when user has requested to change the audio route on
+ *   the device for devices running an API version < UDC
+ * @param onRequestBluetoothAudio The callback used when the user has requested to change the audio
+ *   route for devices running on API version < UDC
+ * @param onRequestEndpointChange The callback used when the user has requested to change the
+ *   endpoint for devices running API version UDC+
+ */
+class CallAudioRouteResolver(
+    private val coroutineScope: CoroutineScope,
+    callData: StateFlow<List<CallData>>,
+    private val onChangeAudioRoute: (Int) -> Unit,
+    private val onRequestBluetoothAudio: (BluetoothDevice) -> Unit,
+    private val onRequestEndpointChange:
+        (CallEndpoint, Executor, OutcomeReceiver<Void, CallEndpointException>) -> Unit
+) {
+    private val mIsCallAudioStateDeprecated =
+        Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE
+
+    // Maps the CallAudioEndpoint to the associated BluetoothDevice (if applicable) for bkwds
+    // compatibility with devices running on API version < UDC
+    data class EndpointEntry(val endpoint: CallAudioEndpoint, val device: BluetoothDevice? = null)
+
+    private val mCurrentEndpoint: MutableStateFlow<CallAudioEndpoint?> = MutableStateFlow(null)
+    private val mAvailableEndpoints: MutableStateFlow<List<CallAudioEndpoint>> =
+        MutableStateFlow(emptyList())
+    val currentEndpoint: StateFlow<CallAudioEndpoint?> = mCurrentEndpoint.asStateFlow()
+    val availableEndpoints = mAvailableEndpoints.asStateFlow()
+
+    private val mCallAudioState: MutableStateFlow<CallAudioState?> = MutableStateFlow(null)
+    private val mEndpoints: MutableStateFlow<List<EndpointEntry>> = MutableStateFlow(emptyList())
+    private val mCurrentCallEndpoint: MutableStateFlow<CallEndpoint?> = MutableStateFlow(null)
+    private val mAvailableCallEndpoints: MutableStateFlow<List<CallEndpoint>> =
+        MutableStateFlow(emptyList())
+
+    init {
+        if (!mIsCallAudioStateDeprecated) {
+            // bkwds compat functionality
+            mCallAudioState
+                .filterNotNull()
+                .combine(callData) { state, data ->
+                    if (data.isNotEmpty()) {
+                        mCurrentEndpoint.value = getCurrentEndpoint(state)
+                        mEndpoints.value = createEndpointEntries(state)
+                        mAvailableEndpoints.value = mEndpoints.value.map { it.endpoint }
+                    } else {
+                        mCurrentEndpoint.value = null
+                        mEndpoints.value = emptyList()
+                        mAvailableEndpoints.value = emptyList()
+                    }
+                }
+                .launchIn(coroutineScope)
+        } else {
+            // UDC+ functionality
+            mAvailableCallEndpoints
+                .combine(callData) { endpoints, data ->
+                    val availableEndpoints =
+                        if (data.isNotEmpty()) {
+                            endpoints.mapNotNull(::createCallAudioEndpoint)
+                        } else {
+                            emptyList()
+                        }
+                    mAvailableEndpoints.value = availableEndpoints
+                    availableEndpoints
+                }
+                .combine(mCurrentCallEndpoint) { available, current ->
+                    if (available.isEmpty()) {
+                        mCurrentEndpoint.value = null
+                    }
+                    val audioEndpoint = current?.let { createCallAudioEndpoint(it) }
+                    mCurrentEndpoint.value = available.firstOrNull { it.id == audioEndpoint?.id }
+                }
+                .launchIn(coroutineScope)
+        }
+    }
+
+    /** The audio state reported from the ICS has changed. */
+    fun onCallAudioStateChanged(audioState: CallAudioState?) {
+        if (mIsCallAudioStateDeprecated) return
+        mCallAudioState.value = audioState
+    }
+
+    /** The call endpoint reported from the ICS has changed. */
+    fun onCallEndpointChanged(callEndpoint: CallEndpoint) {
+        if (!mIsCallAudioStateDeprecated) return
+        mCurrentCallEndpoint.value = callEndpoint
+    }
+
+    /** The available endpoints reported from the ICS have changed. */
+    fun onAvailableCallEndpointsChanged(availableEndpoints: MutableList<CallEndpoint>) {
+        if (!mIsCallAudioStateDeprecated) return
+        mAvailableCallEndpoints.value = availableEndpoints
+    }
+
+    /**
+     * Request to change the audio route using the provided [CallAudioEndpoint.id].
+     *
+     * @return true if the operation succeeded, false if it did not because the endpoint doesn't
+     *   exist.
+     */
+    suspend fun onChangeAudioRoute(id: String): Boolean {
+        if (mIsCallAudioStateDeprecated) {
+            val endpoint =
+                mAvailableCallEndpoints.value.firstOrNull { it.identifier.toString() == id }
+            if (endpoint == null) return false
+            return coroutineScope.async { onRequestEndpointChange(endpoint) }.await()
+        } else {
+            val endpoint = mEndpoints.value.firstOrNull { it.endpoint.id == id }
+            if (endpoint == null) return false
+            if (endpoint.endpoint.audioRoute != AudioRoute.BLUETOOTH) {
+                onChangeAudioRoute(getAudioState(endpoint.endpoint.audioRoute))
+                return true
+            } else {
+                if (endpoint.device == null) return false
+                onRequestBluetoothAudio(endpoint.device)
+                return true
+            }
+        }
+    }
+
+    /** Send a request to the InCallService to change the current endpoint. */
+    private suspend fun onRequestEndpointChange(endpoint: CallEndpoint): Boolean =
+        suspendCancellableCoroutine { continuation ->
+            onRequestEndpointChange(
+                endpoint,
+                Runnable::run,
+                @RequiresApi(Build.VERSION_CODES.S)
+                object : OutcomeReceiver<Void, CallEndpointException> {
+                    override fun onResult(result: Void?) {
+                        continuation.resume(true)
+                    }
+
+                    override fun onError(error: CallEndpointException) {
+                        continuation.resume(false)
+                    }
+                }
+            )
+        }
+
+    /** Maps from the Telecom [CallAudioState] to the app's [CallAudioEndpoint] */
+    private fun getCurrentEndpoint(callAudioState: CallAudioState): CallAudioEndpoint {
+        if (CallAudioState.ROUTE_BLUETOOTH != callAudioState.route) {
+            return CallAudioEndpoint(
+                id = getAudioEndpointId(callAudioState.route),
+                audioRoute = getAudioEndpointRoute(callAudioState.route)
+            )
+        }
+        val device: BluetoothDevice? = callAudioState.activeBluetoothDevice
+        if (device?.address != null) {
+            return CallAudioEndpoint(
+                id = device.address,
+                audioRoute = AudioRoute.BLUETOOTH,
+                frameworkName = getName(device)
+            )
+        }
+        val exactMatch = mEndpoints.value.firstOrNull { it.device == device }
+        if (exactMatch != null) return exactMatch.endpoint
+        return CallAudioEndpoint(
+            id = "",
+            audioRoute = AudioRoute.BLUETOOTH,
+            frameworkName = device?.let { getName(it) }
+        )
+    }
+
+    /** Create the [CallAudioEndpoint] from the telecom [CallEndpoint] for API UDC+ */
+    private fun createCallAudioEndpoint(endpoint: CallEndpoint): CallAudioEndpoint? {
+        val id = Compatibility.getEndpointIdentifier(endpoint) ?: return null
+        val type = Compatibility.getEndpointType(endpoint) ?: return null
+        val name = Compatibility.getEndpointName(endpoint) ?: return null
+        return CallAudioEndpoint(id, getAudioRouteFromEndpointType(type), name)
+    }
+
+    /** Reconstruct the available audio routes from telecom state and construct [EndpointEntry]s */
+    private fun createEndpointEntries(callAudioState: CallAudioState): List<EndpointEntry> {
+        return buildList {
+            if (CallAudioState.ROUTE_EARPIECE and callAudioState.supportedRouteMask > 0) {
+                add(
+                    EndpointEntry(
+                        CallAudioEndpoint(
+                            id = getAudioEndpointId(CallAudioState.ROUTE_EARPIECE),
+                            audioRoute = AudioRoute.EARPIECE
+                        )
+                    )
+                )
+            }
+            if (CallAudioState.ROUTE_SPEAKER and callAudioState.supportedRouteMask > 0) {
+                add(
+                    EndpointEntry(
+                        CallAudioEndpoint(
+                            id = getAudioEndpointId(CallAudioState.ROUTE_SPEAKER),
+                            audioRoute = AudioRoute.SPEAKER
+                        )
+                    )
+                )
+            }
+            if (CallAudioState.ROUTE_WIRED_HEADSET and callAudioState.supportedRouteMask > 0) {
+                add(
+                    EndpointEntry(
+                        CallAudioEndpoint(
+                            id = getAudioEndpointId(CallAudioState.ROUTE_WIRED_HEADSET),
+                            audioRoute = AudioRoute.HEADSET
+                        )
+                    )
+                )
+            }
+            if (CallAudioState.ROUTE_STREAMING and callAudioState.supportedRouteMask > 0) {
+                add(
+                    EndpointEntry(
+                        CallAudioEndpoint(
+                            id = getAudioEndpointId(CallAudioState.ROUTE_STREAMING),
+                            audioRoute = AudioRoute.STREAMING
+                        )
+                    )
+                )
+            }
+            // For Bluetooth, cache the BluetoothDevices associated with the route so we can choose
+            // them later
+            if (CallAudioState.ROUTE_BLUETOOTH and callAudioState.supportedRouteMask > 0) {
+                addAll(
+                    callAudioState.supportedBluetoothDevices.map { device ->
+                        EndpointEntry(
+                            CallAudioEndpoint(
+                                id = device.address?.toString() ?: UUID.randomUUID().toString(),
+                                audioRoute = AudioRoute.BLUETOOTH,
+                                frameworkName = getName(device)
+                            ),
+                            device
+                        )
+                    }
+                )
+            }
+        }
+    }
+
+    private fun getName(device: BluetoothDevice): String? {
+        var name = Compatibility.getBluetoothDeviceAlias(device)
+        if (name.isFailure) {
+            name = getBluetoothDeviceName(device)
+        }
+        return name.getOrDefault(null)
+    }
+
+    private fun getBluetoothDeviceName(device: BluetoothDevice): Result<String> {
+        return try {
+            Result.success(device.name ?: "")
+        } catch (e: SecurityException) {
+            Result.failure(e)
+        }
+    }
+
+    private fun getAudioEndpointId(audioState: Int): String {
+        return when (audioState) {
+            CallAudioState.ROUTE_EARPIECE -> "Earpiece"
+            CallAudioState.ROUTE_SPEAKER -> "Speaker"
+            CallAudioState.ROUTE_WIRED_HEADSET -> "Headset"
+            CallAudioState.ROUTE_BLUETOOTH -> "Bluetooth"
+            CallAudioState.ROUTE_STREAMING -> "Streaming"
+            else -> "Unknown"
+        }
+    }
+
+    private fun getAudioRouteFromEndpointType(endpointType: Int): AudioRoute {
+        return when (endpointType) {
+            CallEndpoint.TYPE_EARPIECE -> AudioRoute.EARPIECE
+            CallEndpoint.TYPE_SPEAKER -> AudioRoute.SPEAKER
+            CallEndpoint.TYPE_WIRED_HEADSET -> AudioRoute.HEADSET
+            CallEndpoint.TYPE_BLUETOOTH -> AudioRoute.BLUETOOTH
+            CallEndpoint.TYPE_STREAMING -> AudioRoute.STREAMING
+            else -> {
+                AudioRoute.UNKNOWN
+            }
+        }
+    }
+
+    private fun getAudioEndpointRoute(audioState: Int): AudioRoute {
+        return when (audioState) {
+            CallAudioState.ROUTE_EARPIECE -> AudioRoute.EARPIECE
+            CallAudioState.ROUTE_SPEAKER -> AudioRoute.SPEAKER
+            CallAudioState.ROUTE_WIRED_HEADSET -> AudioRoute.HEADSET
+            CallAudioState.ROUTE_BLUETOOTH -> AudioRoute.BLUETOOTH
+            CallAudioState.ROUTE_STREAMING -> AudioRoute.STREAMING
+            else -> AudioRoute.UNKNOWN
+        }
+    }
+
+    private fun getAudioState(audioRoute: AudioRoute): Int {
+        return when (audioRoute) {
+            AudioRoute.EARPIECE -> CallAudioState.ROUTE_EARPIECE
+            AudioRoute.SPEAKER -> CallAudioState.ROUTE_SPEAKER
+            AudioRoute.HEADSET -> CallAudioState.ROUTE_WIRED_HEADSET
+            AudioRoute.BLUETOOTH -> CallAudioState.ROUTE_BLUETOOTH
+            AudioRoute.STREAMING -> CallAudioState.ROUTE_STREAMING
+            else -> CallAudioState.ROUTE_EARPIECE
+        }
+    }
+}
diff --git a/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/services/CallData.kt b/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/services/CallData.kt
new file mode 100644
index 0000000..77ac06b
--- /dev/null
+++ b/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/services/CallData.kt
@@ -0,0 +1,103 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.core.telecom.test.services
+
+import android.net.Uri
+import android.telecom.PhoneAccountHandle
+import androidx.core.telecom.extensions.KickParticipantAction
+import androidx.core.telecom.extensions.Participant
+import androidx.core.telecom.extensions.RaiseHandAction
+import androidx.core.telecom.test.ui.calling.CallStateTransition
+import androidx.core.telecom.util.ExperimentalAppActions
+
+enum class CallState {
+    INCOMING,
+    DIALING,
+    ACTIVE,
+    HELD,
+    DISCONNECTING,
+    DISCONNECTED,
+    UNKNOWN
+}
+
+enum class Direction {
+    INCOMING,
+    OUTGOING
+}
+
+enum class AudioRoute {
+    UNKNOWN,
+    EARPIECE,
+    SPEAKER,
+    BLUETOOTH,
+    HEADSET,
+    STREAMING
+}
+
+enum class CallType {
+    AUDIO,
+    VIDEO
+}
+
+enum class Capability {
+    SUPPORTS_HOLD
+}
+
+/** Base relevant call data */
+data class BaseCallData(
+    val id: Int,
+    val phoneAccountHandle: PhoneAccountHandle,
+    val name: String,
+    val contactName: String?,
+    val contactUri: Uri?,
+    val number: Uri,
+    val state: CallState,
+    val direction: Direction,
+    val callType: CallType,
+    val capabilities: List<Capability>,
+    val onStateChanged: (transition: CallStateTransition) -> Unit
+)
+
+/** Represents a call endpoint from the application's perspective */
+data class CallAudioEndpoint(
+    val id: String,
+    val audioRoute: AudioRoute,
+    val frameworkName: String? = null
+)
+
+/** data related to the extensions to the call */
+@OptIn(ExperimentalAppActions::class)
+data class ParticipantExtensionData(
+    val isSupported: Boolean,
+    val activeParticipant: Participant?,
+    val selfParticipant: Participant?,
+    val participants: Set<Participant>,
+    val raiseHandData: RaiseHandData? = null,
+    val kickParticipantData: KickParticipantData? = null
+)
+
+@OptIn(ExperimentalAppActions::class)
+data class RaiseHandData(val raisedHands: List<Participant>, val raiseHandAction: RaiseHandAction)
+
+@OptIn(ExperimentalAppActions::class)
+data class KickParticipantData(val kickParticipantAction: KickParticipantAction)
+
+/** Combined call data including extensions. */
+data class CallData(
+    val callData: BaseCallData,
+    val participantExtensionData: ParticipantExtensionData?
+)
diff --git a/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/services/CallDataAggregator.kt b/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/services/CallDataAggregator.kt
new file mode 100644
index 0000000..5d7318f
--- /dev/null
+++ b/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/services/CallDataAggregator.kt
@@ -0,0 +1,65 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.core.telecom.test.services
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onCompletion
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.flow.update
+
+/**
+ * Watches for [CallData] flow changes for each active call.
+ *
+ * Each call should call [watch] so this aggregator can track and listen to changes in its
+ * [CallData]. When there is a change in the [CallData] state for any call, regenerate all of the
+ * data in [callDataState]
+ */
+class CallDataAggregator {
+    private val mCallDataProducers: MutableStateFlow<List<StateFlow<CallData>>> =
+        MutableStateFlow(emptyList())
+    private val mCallDataState: MutableStateFlow<List<CallData>> = MutableStateFlow(emptyList())
+    /** Contains the current state of all active calls */
+    val callDataState: StateFlow<List<CallData>> = mCallDataState.asStateFlow()
+
+    /**
+     * Watch the [CallData] flow for changes related to a new call until the [scope] completes when
+     * the call ends.
+     */
+    suspend fun watch(scope: CoroutineScope, dataFlow: Flow<CallData>) {
+        val dataStateFlow = dataFlow.stateIn(scope)
+        mCallDataProducers.update { oldList -> ArrayList(oldList).apply { add(dataStateFlow) } }
+        dataStateFlow
+            .onEach { onCallDataUpdated() }
+            .onCompletion {
+                mCallDataProducers.update { oldList ->
+                    ArrayList(oldList).apply { remove(dataStateFlow) }
+                }
+                onCallDataUpdated()
+            }
+            .launchIn(scope)
+    }
+
+    private fun onCallDataUpdated() {
+        mCallDataState.value = mCallDataProducers.value.map { it.value }
+    }
+}
diff --git a/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/services/CallDataEmitters.kt b/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/services/CallDataEmitters.kt
new file mode 100644
index 0000000..cc65745
--- /dev/null
+++ b/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/services/CallDataEmitters.kt
@@ -0,0 +1,281 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR 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.services
+
+import android.telecom.Call
+import android.telecom.TelecomManager
+import android.telecom.VideoProfile
+import android.util.Log
+import androidx.core.telecom.CallControlResult
+import androidx.core.telecom.CallException.Companion.ERROR_CALL_IS_NOT_BEING_TRACKED
+import androidx.core.telecom.extensions.KickParticipantAction
+import androidx.core.telecom.extensions.Participant
+import androidx.core.telecom.extensions.RaiseHandAction
+import androidx.core.telecom.test.Compatibility
+import androidx.core.telecom.test.ui.calling.CallStateTransition
+import androidx.core.telecom.util.ExperimentalAppActions
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.channels.trySendBlocking
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.callbackFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.flow.map
+
+/** Track the kick participant support for this application */
+@OptIn(ExperimentalAppActions::class)
+class KickParticipantDataEmitter {
+    companion object {
+        private val unsupportedAction =
+            object : KickParticipantAction {
+                override var isSupported: Boolean = false
+
+                override suspend fun requestKickParticipant(
+                    participant: Participant
+                ): CallControlResult {
+                    return CallControlResult.Error(ERROR_CALL_IS_NOT_BEING_TRACKED)
+                }
+            }
+        /** Implementation used when kicking participants is unsupported */
+        val UNSUPPORTED = KickParticipantDataEmitter().collect(unsupportedAction)
+    }
+
+    /** Collect updates to [KickParticipantData] related to the call */
+    fun collect(action: KickParticipantAction): Flow<KickParticipantData> {
+        return flowOf(createKickParticipantData(action))
+    }
+
+    private fun createKickParticipantData(action: KickParticipantAction): KickParticipantData {
+        return KickParticipantData(action)
+    }
+}
+
+/** Track the raised hands state of participants in the call */
+@OptIn(ExperimentalAppActions::class)
+class RaiseHandDataEmitter {
+    companion object {
+        private val unsupportedAction =
+            object : RaiseHandAction {
+                override var isSupported: Boolean = false
+
+                override suspend fun requestRaisedHandStateChange(
+                    isRaised: Boolean
+                ): CallControlResult {
+                    return CallControlResult.Error(ERROR_CALL_IS_NOT_BEING_TRACKED)
+                }
+            }
+        /** The implementation used when not supported */
+        val UNSUPPORTED = RaiseHandDataEmitter().collect(unsupportedAction)
+    }
+
+    private val raisedHands: MutableStateFlow<List<Participant>> = MutableStateFlow(emptyList())
+
+    /** The raised hands state of the participants has changed */
+    fun onRaisedHandsChanged(newRaisedHands: List<Participant>) {
+        raisedHands.value = newRaisedHands
+    }
+
+    /** Collect updates to the [RaiseHandData] related to this call */
+    fun collect(action: RaiseHandAction): Flow<RaiseHandData> {
+        return raisedHands.map { raisedHands -> createRaiseHandData(action, raisedHands) }
+    }
+
+    private fun createRaiseHandData(
+        action: RaiseHandAction,
+        raisedHands: List<Participant>
+    ): RaiseHandData {
+        return RaiseHandData(raisedHands, action)
+    }
+}
+
+/**
+ * Track and update listeners when the [ParticipantExtensionData] related to a call changes,
+ * including the optional raise hand and kick participant extensions.
+ */
+@OptIn(ExperimentalAppActions::class)
+class ParticipantExtensionDataEmitter {
+    private val activeParticipant: MutableStateFlow<Participant?> = MutableStateFlow(null)
+    private val participants: MutableStateFlow<Set<Participant>> = MutableStateFlow(emptySet())
+
+    /** The participants in the call have changed */
+    fun onParticipantsChanged(newParticipants: Set<Participant>) {
+        participants.value = newParticipants
+    }
+
+    /** The active participant in the call has changed */
+    fun onActiveParticipantChanged(participant: Participant?) {
+        activeParticipant.value = participant
+    }
+
+    /**
+     * Collect updates to the [ParticipantExtensionData] related to this call based on the support
+     * state of this extension + actions
+     */
+    fun collect(
+        isSupported: Boolean,
+        raiseHandDataEmitter: Flow<RaiseHandData> = RaiseHandDataEmitter.UNSUPPORTED,
+        kickParticipantDataEmitter: Flow<KickParticipantData> =
+            KickParticipantDataEmitter.UNSUPPORTED
+    ): Flow<ParticipantExtensionData> {
+        return participants
+            .combine(activeParticipant) { newParticipants, newActiveParticipant ->
+                createExtensionData(isSupported, newActiveParticipant, newParticipants)
+            }
+            .combine(raiseHandDataEmitter) { data, rhData ->
+                ParticipantExtensionData(
+                    isSupported = data.isSupported,
+                    activeParticipant = data.activeParticipant,
+                    selfParticipant = data.selfParticipant,
+                    participants = data.participants,
+                    raiseHandData = rhData,
+                    kickParticipantData = data.kickParticipantData
+                )
+            }
+            .combine(kickParticipantDataEmitter) { data, kpData ->
+                ParticipantExtensionData(
+                    isSupported = data.isSupported,
+                    activeParticipant = data.activeParticipant,
+                    selfParticipant = data.selfParticipant,
+                    participants = data.participants,
+                    raiseHandData = data.raiseHandData,
+                    kickParticipantData = kpData
+                )
+            }
+    }
+
+    private fun createExtensionData(
+        isSupported: Boolean,
+        activeParticipant: Participant? = null,
+        participants: Set<Participant> = emptySet()
+    ): ParticipantExtensionData {
+        // For now, the first element is considered ourself
+        val self = participants.firstOrNull()
+        return ParticipantExtensionData(isSupported, activeParticipant, self, participants)
+    }
+}
+
+/**
+ * Track a [Call] and begin to stream [BaseCallData] using [collect] whenever the call data changes.
+ */
+class CallDataEmitter(val trackedCall: IcsCall) {
+    private companion object {
+        const val LOG_TAG = "CallDataProducer"
+    }
+
+    /** Collect on changes to the [BaseCallData] related to the [trackedCall] */
+    fun collect(): Flow<BaseCallData> {
+        return createCallDataFlow()
+    }
+
+    private fun createCallDataFlow(): Flow<BaseCallData> = callbackFlow {
+        val callback =
+            object : Call.Callback() {
+                override fun onStateChanged(call: Call?, state: Int) {
+                    if (call != trackedCall.call) return
+                    val callData = createCallData(trackedCall)
+                    Log.v(LOG_TAG, "onStateChanged: call ${trackedCall.id}: $callData")
+                    trySendBlocking(callData)
+                }
+
+                override fun onDetailsChanged(call: Call?, details: Call.Details?) {
+                    if (call != trackedCall.call) return
+                    val callData = createCallData(trackedCall)
+                    Log.v(LOG_TAG, "onDetailsChanged: call ${trackedCall.id}: $callData")
+                    trySendBlocking(callData)
+                }
+
+                override fun onCallDestroyed(call: Call?) {
+                    if (call != trackedCall.call) return
+                    Log.v(LOG_TAG, "call ${trackedCall.id}: destroyed")
+                    channel.close()
+                }
+            }
+        if (trackedCall.call.details != null) {
+            val callData = createCallData(trackedCall)
+            Log.v(LOG_TAG, "call ${trackedCall.id}: $callData")
+            trySendBlocking(callData)
+        }
+        trackedCall.call.registerCallback(callback)
+        awaitClose { trackedCall.call.unregisterCallback(callback) }
+    }
+
+    private fun createCallData(icsCall: IcsCall): BaseCallData {
+        return BaseCallData(
+            id = icsCall.id,
+            phoneAccountHandle = icsCall.call.details.accountHandle,
+            name =
+                when (icsCall.call.details.callerDisplayNamePresentation) {
+                    TelecomManager.PRESENTATION_ALLOWED ->
+                        icsCall.call.details.callerDisplayName ?: ""
+                    TelecomManager.PRESENTATION_RESTRICTED -> "Restricted"
+                    TelecomManager.PRESENTATION_UNKNOWN -> "Unknown"
+                    else -> icsCall.call.details.callerDisplayName ?: ""
+                },
+            contactName = Compatibility.getContactDisplayName(icsCall.call.details),
+            contactUri = Compatibility.getContactPhotoUri(icsCall.call.details),
+            number = icsCall.call.details.handle,
+            state = getState(Compatibility.getCallState(icsCall.call)),
+            direction =
+                when (icsCall.call.details.callDirection) {
+                    Call.Details.DIRECTION_INCOMING -> Direction.INCOMING
+                    else -> Direction.OUTGOING
+                },
+            callType =
+                when (VideoProfile.isVideo(icsCall.call.details.videoState)) {
+                    true -> CallType.VIDEO
+                    false -> CallType.AUDIO
+                },
+            capabilities = getCapabilities(icsCall.call.details.callCapabilities),
+            onStateChanged = ::onChangeCallState
+        )
+    }
+
+    private fun onChangeCallState(transition: CallStateTransition) {
+        when (transition) {
+            CallStateTransition.HOLD -> trackedCall.call.hold()
+            CallStateTransition.UNHOLD -> trackedCall.call.unhold()
+            CallStateTransition.ANSWER -> trackedCall.call.answer(VideoProfile.STATE_AUDIO_ONLY)
+            CallStateTransition.DISCONNECT -> trackedCall.call.disconnect()
+            CallStateTransition.NONE -> {}
+        }
+    }
+
+    private fun getState(telecomState: Int): CallState {
+        return when (telecomState) {
+            Call.STATE_RINGING -> CallState.INCOMING
+            Call.STATE_DIALING -> CallState.DIALING
+            Call.STATE_ACTIVE -> CallState.ACTIVE
+            Call.STATE_HOLDING -> CallState.HELD
+            Call.STATE_DISCONNECTING -> CallState.DISCONNECTING
+            Call.STATE_DISCONNECTED -> CallState.DISCONNECTED
+            else -> CallState.UNKNOWN
+        }
+    }
+
+    private fun getCapabilities(capabilities: Int): List<Capability> {
+        val capabilitiesList = ArrayList<Capability>()
+        if (canHold(capabilities)) {
+            capabilitiesList.add(Capability.SUPPORTS_HOLD)
+        }
+        return capabilitiesList
+    }
+
+    private fun canHold(capabilities: Int): Boolean {
+        return (Call.Details.CAPABILITY_HOLD and capabilities) > 0
+    }
+}
diff --git a/compose/foundation/foundation-layout/src/commonStubsMain/kotlin/androidx/compose/foundation/layout/RowColumnMeasurePolicy.commonStubs.kt b/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/services/IcsCall.kt
similarity index 73%
rename from compose/foundation/foundation-layout/src/commonStubsMain/kotlin/androidx/compose/foundation/layout/RowColumnMeasurePolicy.commonStubs.kt
rename to core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/services/IcsCall.kt
index 263e22e..915dc7d 100644
--- a/compose/foundation/foundation-layout/src/commonStubsMain/kotlin/androidx/compose/foundation/layout/RowColumnMeasurePolicy.commonStubs.kt
+++ b/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/services/IcsCall.kt
@@ -14,10 +14,9 @@
  * limitations under the License.
  */
 
-package androidx.compose.foundation.layout
+package androidx.core.telecom.test.services
 
-@Suppress("NOTHING_TO_INLINE")
-internal actual inline fun initCause(
-    exception: IllegalArgumentException,
-    cause: Exception
-): Throwable = implementedInJetBrainsFork()
+import android.telecom.Call
+
+/** Contains the [Call] and an app specific ID that relates to this call. */
+class IcsCall(val id: Int, val call: Call)
diff --git a/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/services/InCallServiceImpl.kt b/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/services/InCallServiceImpl.kt
new file mode 100644
index 0000000..430b8b8
--- /dev/null
+++ b/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/services/InCallServiceImpl.kt
@@ -0,0 +1,167 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.core.telecom.test.services
+
+import android.content.Intent
+import android.os.Binder
+import android.os.IBinder
+import android.telecom.Call
+import android.telecom.CallAudioState
+import android.telecom.CallEndpoint
+import android.util.Log
+import androidx.core.telecom.InCallServiceCompat
+import androidx.core.telecom.test.Compatibility
+import androidx.core.telecom.util.ExperimentalAppActions
+import androidx.lifecycle.lifecycleScope
+import java.util.concurrent.atomic.AtomicInteger
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.launch
+
+/**
+ * Implements the InCallService for this application as well as a local ICS binder for activities to
+ * bind to this service locally and receive state changes.
+ */
+class InCallServiceImpl : LocalIcsBinder, InCallServiceCompat() {
+    private companion object {
+        const val LOG_TAG = "InCallServiceImpl"
+    }
+
+    private val localBinder =
+        object : LocalIcsBinder.Connector, Binder() {
+            override fun getService(): LocalIcsBinder {
+                return this@InCallServiceImpl
+            }
+        }
+
+    private val currId = AtomicInteger(1)
+    private val mCallDataAggregator = CallDataAggregator()
+    override val callData: StateFlow<List<CallData>> = mCallDataAggregator.callDataState
+    private val mMuteStateResolver = MuteStateResolver()
+
+    @Suppress("DEPRECATION")
+    private val mCallAudioRouteResolver =
+        CallAudioRouteResolver(
+            lifecycleScope,
+            callData,
+            ::setAudioRoute,
+            ::requestBluetoothAudio,
+            onRequestEndpointChange = { ep, e, or ->
+                Compatibility.requestCallEndpointChange(this@InCallServiceImpl, ep, e, or)
+            }
+        )
+    override val isMuted: StateFlow<Boolean> = mMuteStateResolver.muteState
+    override val currentAudioEndpoint: StateFlow<CallAudioEndpoint?> =
+        mCallAudioRouteResolver.currentEndpoint
+    override val availableAudioEndpoints: StateFlow<List<CallAudioEndpoint>> =
+        mCallAudioRouteResolver.availableEndpoints
+
+    override fun onBind(intent: Intent?): IBinder? {
+        if (intent == null) {
+            Log.w(LOG_TAG, "onBind: null intent, returning")
+            return null
+        }
+        if (SERVICE_INTERFACE == intent.action) {
+            Log.d(LOG_TAG, "onBind: Received telecom interface.")
+            return super.onBind(intent)
+        }
+        Log.d(LOG_TAG, "onBind: Received bind request from ${intent.`package`}")
+        return localBinder
+    }
+
+    override fun onUnbind(intent: Intent?): Boolean {
+        Log.d(LOG_TAG, "onUnbind: Received unbind request from $intent")
+        // work around a stupid bug where InCallService assumes that the unbind request can only
+        // come from telecom
+        if (intent?.action != null) {
+            return super.onUnbind(intent)
+        }
+        return false
+    }
+
+    override fun onChangeMuteState(isMuted: Boolean) {
+        setMuted(isMuted)
+    }
+
+    override suspend fun onChangeAudioRoute(id: String) {
+        mCallAudioRouteResolver.onChangeAudioRoute(id)
+    }
+
+    @OptIn(ExperimentalAppActions::class)
+    override fun onCallAdded(call: Call?) {
+        if (call == null) return
+        var callJob: Job? = null
+        callJob =
+            lifecycleScope.launch {
+                connectExtensions(call) {
+                    val participantsEmitter = ParticipantExtensionDataEmitter()
+                    val participantExtension =
+                        addParticipantExtension(
+                            onActiveParticipantChanged =
+                                participantsEmitter::onActiveParticipantChanged,
+                            onParticipantsUpdated = participantsEmitter::onParticipantsChanged
+                        )
+
+                    val kickParticipantDataEmitter = KickParticipantDataEmitter()
+                    val kickParticipantAction = participantExtension.addKickParticipantAction()
+
+                    val raiseHandDataEmitter = RaiseHandDataEmitter()
+                    val raiseHandAction =
+                        participantExtension.addRaiseHandAction(
+                            raiseHandDataEmitter::onRaisedHandsChanged
+                        )
+                    onConnected {
+                        val callDataEmitter = CallDataEmitter(IcsCall(currId.getAndAdd(1), call))
+                        val participantData =
+                            participantsEmitter.collect(
+                                participantExtension.isSupported,
+                                raiseHandDataEmitter.collect(raiseHandAction),
+                                kickParticipantDataEmitter.collect(kickParticipantAction)
+                            )
+                        val fullData =
+                            callDataEmitter.collect().combine(participantData) { callData, partData
+                                ->
+                                CallData(callData, partData)
+                            }
+                        mCallDataAggregator.watch(this@launch, fullData)
+                    }
+                }
+                callJob?.cancel("Call Disconnected")
+                Log.d(LOG_TAG, "onCallAdded: connectedExtensions complete")
+            }
+    }
+
+    @Deprecated("Deprecated in API 34")
+    override fun onCallAudioStateChanged(audioState: CallAudioState?) {
+        mMuteStateResolver.onCallAudioStateChanged(audioState)
+        mCallAudioRouteResolver.onCallAudioStateChanged(audioState)
+    }
+
+    override fun onMuteStateChanged(isMuted: Boolean) {
+        mMuteStateResolver.onMuteStateChanged(isMuted)
+    }
+
+    override fun onCallEndpointChanged(callEndpoint: CallEndpoint) {
+        mCallAudioRouteResolver.onCallEndpointChanged(callEndpoint)
+    }
+
+    override fun onAvailableCallEndpointsChanged(availableEndpoints: MutableList<CallEndpoint>) {
+        mCallAudioRouteResolver.onAvailableCallEndpointsChanged(availableEndpoints)
+    }
+}
diff --git a/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/services/LocalIcsBinder.kt b/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/services/LocalIcsBinder.kt
new file mode 100644
index 0000000..89b66d7
--- /dev/null
+++ b/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/services/LocalIcsBinder.kt
@@ -0,0 +1,42 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.core.telecom.test.services
+
+import kotlinx.coroutines.flow.StateFlow
+
+/** Local interface used to define the local connection between a component and this Service. */
+interface LocalIcsBinder {
+    /** Connector used during Service binding to capture the instance of this class */
+    interface Connector {
+        fun getService(): LocalIcsBinder
+    }
+
+    /** the state of active calls on this device */
+    val callData: StateFlow<List<CallData>>
+    /** The state of global mute on this device */
+    val isMuted: StateFlow<Boolean>
+    /** The current audio route that the active call is using */
+    val currentAudioEndpoint: StateFlow<CallAudioEndpoint?>
+    /** The available audio routes for the active call */
+    val availableAudioEndpoints: StateFlow<List<CallAudioEndpoint>>
+
+    /** Request to change the mute state of the device */
+    fun onChangeMuteState(isMuted: Boolean)
+
+    /** Request to change the current audio route on the device */
+    suspend fun onChangeAudioRoute(id: String)
+}
diff --git a/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/services/MuteStateResolver.kt b/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/services/MuteStateResolver.kt
new file mode 100644
index 0000000..b08e7141
--- /dev/null
+++ b/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/services/MuteStateResolver.kt
@@ -0,0 +1,42 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.core.telecom.test.services
+
+import android.os.Build
+import android.telecom.CallAudioState
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+
+/** Tracks the current global mute state of the device */
+class MuteStateResolver {
+    private val isCallAudioStateDeprecated =
+        Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE
+    private val mMuteState = MutableStateFlow(false)
+    val muteState = mMuteState.asStateFlow()
+
+    /** The audio state of the device has changed for devices using API version < UDC */
+    fun onCallAudioStateChanged(audioState: CallAudioState?) {
+        if (audioState == null || isCallAudioStateDeprecated) return
+        mMuteState.value = audioState.isMuted
+    }
+
+    /** The audio state of the device has changed for devices using API version UDC+ */
+    fun onMuteStateChanged(isMuted: Boolean) {
+        if (!isCallAudioStateDeprecated) return
+        mMuteState.value = isMuted
+    }
+}
diff --git a/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/services/RemoteCallProvider.kt b/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/services/RemoteCallProvider.kt
new file mode 100644
index 0000000..2b59c02
--- /dev/null
+++ b/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/services/RemoteCallProvider.kt
@@ -0,0 +1,154 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.core.telecom.test.services
+
+import android.content.ComponentName
+import android.content.Context
+import android.content.Context.BIND_AUTO_CREATE
+import android.content.Intent
+import android.content.ServiceConnection
+import android.os.IBinder
+import android.util.Log
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.emptyFlow
+import kotlinx.coroutines.flow.flatMapConcat
+import kotlinx.coroutines.flow.getAndUpdate
+
+data class LocalServiceConnection(
+    val isConnected: Boolean,
+    val context: Context? = null,
+    val serviceConnection: ServiceConnection? = null,
+    val connection: LocalIcsBinder? = null
+)
+
+/**
+ * Manages the connection for the Provider of "remote" calls, which are calls from the app's
+ * [InCallServiceImpl].
+ */
+@OptIn(ExperimentalCoroutinesApi::class)
+class RemoteCallProvider {
+    private companion object {
+        const val LOG_TAG = "RemoteCallProvider"
+    }
+
+    private val connectedService: MutableStateFlow<LocalServiceConnection> =
+        MutableStateFlow(LocalServiceConnection(false))
+
+    /** Bind to the app's [LocalIcsBinder.Connector] Service implementation */
+    fun connectService(context: Context) {
+        if (connectedService.value.isConnected) return
+        val intent = Intent(context, InCallServiceImpl::class.java)
+        val serviceConnection =
+            object : ServiceConnection {
+                override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
+                    if (service == null) return
+                    val localService = service as LocalIcsBinder.Connector
+                    connectedService.value =
+                        LocalServiceConnection(true, context, this, localService.getService())
+                }
+
+                override fun onServiceDisconnected(name: ComponentName?) {
+                    // Unlikely since the Service is in the same process. Re-evaluate if the service
+                    // is moved to another process.
+                    Log.w(LOG_TAG, "onServiceDisconnected: Unexpected call")
+                }
+            }
+        Log.i(LOG_TAG, "connectToIcs: Binding to ICS locally")
+        context.bindService(intent, serviceConnection, BIND_AUTO_CREATE)
+    }
+
+    /** Disconnect from the app;s [LocalIcsBinder.Connector] Service implementation */
+    fun disconnectService() {
+        val localConnection = connectedService.getAndUpdate { LocalServiceConnection(false) }
+        localConnection.serviceConnection?.let { conn ->
+            Log.i(LOG_TAG, "connectToIcs: Unbinding to ICS locally")
+            localConnection.context?.unbindService(conn)
+        }
+    }
+
+    /**
+     * Stream the [CallData] representing each active Call on the device. The Flow will be empty
+     * until the remote Service connects.
+     */
+    fun streamCallData(): Flow<List<CallData>> {
+        return connectedService.flatMapConcat { conn ->
+            if (!conn.isConnected) {
+                emptyFlow()
+            } else {
+                conn.connection?.callData ?: emptyFlow()
+            }
+        }
+    }
+
+    /**
+     * Stream the global mute state of the device. The Flow will be empty until the remote Service
+     * connects.
+     */
+    fun streamMuteData(): Flow<Boolean> {
+        return connectedService.flatMapConcat { conn ->
+            if (!conn.isConnected) {
+                emptyFlow()
+            } else {
+                conn.connection?.isMuted ?: emptyFlow()
+            }
+        }
+    }
+
+    /**
+     * Stream the [CallAudioEndpoint] representing the current endpoint of the active call. The Flow
+     * will be empty until the remote Service connects.
+     */
+    fun streamCurrentEndpointData(): Flow<CallAudioEndpoint?> {
+        return connectedService.flatMapConcat { conn ->
+            if (!conn.isConnected) {
+                emptyFlow()
+            } else {
+                conn.connection?.currentAudioEndpoint ?: emptyFlow()
+            }
+        }
+    }
+
+    /**
+     * Stream the List of [CallAudioEndpoint]s representing the available endpoints of the active
+     * call. The Flow will be empty until the remote Service connects.
+     */
+    fun streamAvailableEndpointData(): Flow<List<CallAudioEndpoint>> {
+        return connectedService.flatMapConcat { conn ->
+            if (!conn.isConnected) {
+                emptyFlow()
+            } else {
+                conn.connection?.availableAudioEndpoints ?: emptyFlow()
+            }
+        }
+    }
+
+    /** Request to change the global mute state of the device. */
+    fun onChangeMuteState(isMuted: Boolean) {
+        val service = connectedService.value
+        if (!service.isConnected) return
+        service.connection?.onChangeMuteState(isMuted)
+    }
+
+    /** Request to change the current audio route of the active call. */
+    suspend fun onChangeAudioRoute(id: String) {
+        val service = connectedService.value
+        if (!service.isConnected) return
+        service.connection?.onChangeAudioRoute(id)
+    }
+}
diff --git a/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/ui/CallingActivity.kt b/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/ui/CallingActivity.kt
new file mode 100644
index 0000000..addc405
--- /dev/null
+++ b/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/ui/CallingActivity.kt
@@ -0,0 +1,34 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.core.telecom.test.ui
+
+import android.app.role.RoleManager
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.core.telecom.test.ui.theme.AppTheme
+
+/** Main activity for this application, which sets the compose UI content. */
+class CallingActivity : ComponentActivity() {
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        val roleManager = applicationContext.getSystemService(RoleManager::class.java)
+        val isSupported = roleManager.isRoleAvailable(RoleManager.ROLE_DIALER)
+        setContent { AppTheme { CallingApp(isSupported) } }
+    }
+}
diff --git a/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/ui/CallingApp.kt b/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/ui/CallingApp.kt
new file mode 100644
index 0000000..54629e3
--- /dev/null
+++ b/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/ui/CallingApp.kt
@@ -0,0 +1,96 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.core.telecom.test.ui
+
+import android.Manifest
+import android.app.role.RoleManager
+import android.content.pm.PackageManager
+import android.os.Build
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+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.platform.LocalContext
+import androidx.core.telecom.test.ui.calling.OngoingCallsViewModel
+import androidx.lifecycle.viewmodel.compose.viewModel
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.rememberNavController
+
+/** Compose UI for the application, which handles navigation between screens */
+@Composable
+fun CallingApp(isSupported: Boolean) {
+    val navController = rememberNavController()
+    val context = LocalContext.current
+    val roleManager = context.getSystemService(RoleManager::class.java)
+    var isGranted by remember { mutableStateOf(roleManager.isRoleHeld(RoleManager.ROLE_DIALER)) }
+    val roleIntent by remember {
+        mutableStateOf(roleManager.createRequestRoleIntent(RoleManager.ROLE_DIALER))
+    }
+    var isBtPermRequestSuggested by remember {
+        mutableStateOf(
+            // Telecom handles getting the name for UDC+
+            Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE &&
+                context.checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) !=
+                    PackageManager.PERMISSION_GRANTED
+        )
+    }
+    val btPermlauncher =
+        rememberLauncherForActivityResult(contract = ActivityResultContracts.RequestPermission()) {
+            isBtGranted ->
+            isBtPermRequestSuggested = !isBtGranted
+            navController.launchAudioRouteDialog()
+        }
+
+    val ongoingCallsViewModel: OngoingCallsViewModel = viewModel()
+
+    val startRoute =
+        when (isSupported) {
+            true -> {
+                when (isGranted) {
+                    true -> NavRoute.CALLS
+                    false -> NavRoute.ROLE_REQUESTS
+                }
+            }
+            false -> NavRoute.NOT_SUPPORTED
+        }
+    // Following encapsulation guidelines from
+    // https://developer.android.com/guide/navigation/design/encapsulate
+    NavHost(navController, startDestination = startRoute) {
+        notSupportedDestination()
+        roleRequestsDestination(roleIntent) { isGranted = it }
+        callsDestination(
+            ongoingCallsViewModel,
+            onShowAudioRouting = {
+                if (isBtPermRequestSuggested) {
+                    btPermlauncher.launch(Manifest.permission.BLUETOOTH_CONNECT)
+                } else {
+                    navController.launchAudioRouteDialog()
+                }
+            },
+            onMoveToSettings = { navController.moveToSettingsDestination() }
+        )
+        audioRouteDialog(
+            ongoingCallsViewModel,
+            onDismissDialog = { navController.popBackStack() },
+            onChangeAudioRoute = { ongoingCallsViewModel.onChangeAudioRoute(it) }
+        )
+        settingsDestination()
+    }
+}
diff --git a/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/ui/Navigation.kt b/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/ui/Navigation.kt
new file mode 100644
index 0000000..1c06915
--- /dev/null
+++ b/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/ui/Navigation.kt
@@ -0,0 +1,92 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.core.telecom.test.ui
+
+import android.content.Intent
+import androidx.core.telecom.test.ui.calling.AudioRoutePickerDialog
+import androidx.core.telecom.test.ui.calling.CallsScreen
+import androidx.core.telecom.test.ui.calling.OngoingCallsViewModel
+import androidx.navigation.NavController
+import androidx.navigation.NavGraphBuilder
+import androidx.navigation.compose.composable
+import androidx.navigation.compose.dialog
+
+/* Routes defined by the navgraph */
+object NavRoute {
+    const val ROLE_REQUESTS = "RoleRequests"
+    const val CALLS = "Calls"
+    const val NOT_SUPPORTED = "NotSupported"
+    const val AUDIO_ROUTE_PICKER = "AudioRoutePicker"
+    const val SETTINGS = "Settings"
+}
+
+/** The screen used for devices that do not support this application */
+fun NavGraphBuilder.notSupportedDestination() {
+    composable(NavRoute.NOT_SUPPORTED) { UnsupportedDeviceScreen() }
+}
+
+/** The screen used for devices that have not set this application to the default dialer yet. */
+fun NavGraphBuilder.roleRequestsDestination(
+    roleIntent: Intent,
+    onGrantedStateChanged: (Boolean) -> Unit
+) {
+    composable(NavRoute.ROLE_REQUESTS) { RoleRequestScreen(roleIntent, onGrantedStateChanged) }
+}
+
+/** The main calling screen, which manages new and ongoing calls. */
+fun NavGraphBuilder.callsDestination(
+    ongoingCallsViewModel: OngoingCallsViewModel,
+    onShowAudioRouting: () -> Unit,
+    onMoveToSettings: () -> Unit
+) {
+    composable(NavRoute.CALLS) {
+        CallsScreen(
+            ongoingCallsViewModel = ongoingCallsViewModel,
+            onShowAudioRouting = onShowAudioRouting,
+            onMoveToSettings = onMoveToSettings
+        )
+    }
+}
+
+/**
+ * The audio routing dialog, which sits on top of the active screen and allows users to change the
+ * active audio route of the active call.
+ */
+fun NavGraphBuilder.audioRouteDialog(
+    ongoingCallsViewModel: OngoingCallsViewModel,
+    onDismissDialog: () -> Unit,
+    onChangeAudioRoute: suspend (String) -> Unit
+) {
+    dialog(NavRoute.AUDIO_ROUTE_PICKER) {
+        AudioRoutePickerDialog(ongoingCallsViewModel, onDismissDialog, onChangeAudioRoute)
+    }
+}
+
+/** Defines the screen used to control app settings. */
+fun NavGraphBuilder.settingsDestination() {
+    composable(NavRoute.SETTINGS) { SettingsScreen() }
+}
+
+/** Launch the audio routing dialog for the user. */
+fun NavController.launchAudioRouteDialog() {
+    navigate(route = NavRoute.AUDIO_ROUTE_PICKER)
+}
+
+/** Launch the settings screen. */
+fun NavController.moveToSettingsDestination() {
+    navigate(route = NavRoute.SETTINGS)
+}
diff --git a/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/ui/RoleRequestScreen.kt b/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/ui/RoleRequestScreen.kt
new file mode 100644
index 0000000..4a2aa52
--- /dev/null
+++ b/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/ui/RoleRequestScreen.kt
@@ -0,0 +1,102 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.core.telecom.test.ui
+
+import android.app.Activity.RESULT_OK
+import android.content.Intent
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.ElevatedButton
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults.topAppBarColors
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import kotlinx.coroutines.launch
+
+/**
+ * Screen that allows the user to request the dialer role for this application, which grants the
+ * permissions required for this application to run.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun RoleRequestScreen(roleIntent: Intent, onGrantedStateChanged: (Boolean) -> Unit) {
+    val scope = rememberCoroutineScope()
+    val snackbarHostState = remember { SnackbarHostState() }
+    // Handles launching activities for result
+    val launcher =
+        rememberLauncherForActivityResult(
+            contract = ActivityResultContracts.StartActivityForResult()
+        ) {
+            when (it.resultCode) {
+                RESULT_OK -> onGrantedStateChanged(true)
+                else -> {
+                    scope.launch { snackbarHostState.showSnackbar("Role denied: Try again?") }
+                    onGrantedStateChanged(false)
+                }
+            }
+        }
+    Scaffold(
+        topBar = {
+            TopAppBar(
+                colors =
+                    topAppBarColors(
+                        containerColor = MaterialTheme.colorScheme.primaryContainer,
+                        titleContentColor = MaterialTheme.colorScheme.primary
+                    ),
+                title = { Text("Required Permissions Request") }
+            )
+        },
+        snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
+    ) { contentPadding ->
+        Column(
+            horizontalAlignment = Alignment.CenterHorizontally,
+            modifier = Modifier.padding(contentPadding).padding(12.dp).fillMaxHeight()
+        ) {
+            Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.Center) {
+                Text(
+                    text =
+                        "This test app is required to be the default dialer to work." +
+                            "Tap \"Request Permissions\" below and set this app as the default dialer" +
+                            " to continue."
+                )
+            }
+            Column {
+                ElevatedButton(
+                    modifier = Modifier.fillMaxWidth(),
+                    onClick = { launcher.launch(roleIntent) }
+                ) {
+                    Text("Request Permissions")
+                }
+            }
+        }
+    }
+}
diff --git a/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/ui/SettingsScreen.kt b/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/ui/SettingsScreen.kt
new file mode 100644
index 0000000..0ce9066
--- /dev/null
+++ b/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/ui/SettingsScreen.kt
@@ -0,0 +1,48 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.core.telecom.test.ui
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults.topAppBarColors
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+
+/** The screen used to show the application settings. */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SettingsScreen() {
+    Scaffold(
+        topBar = {
+            TopAppBar(
+                colors =
+                    topAppBarColors(
+                        containerColor = MaterialTheme.colorScheme.primaryContainer,
+                        titleContentColor = MaterialTheme.colorScheme.primary
+                    ),
+                title = { Text("Settings") }
+            )
+        }
+    ) { scaffoldPadding ->
+        Column(modifier = Modifier.padding(scaffoldPadding)) { Text("<Nothing yet...>") }
+    }
+}
diff --git a/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/ui/UnsupportedDeviceScreen.kt b/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/ui/UnsupportedDeviceScreen.kt
new file mode 100644
index 0000000..e4119c1
--- /dev/null
+++ b/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/ui/UnsupportedDeviceScreen.kt
@@ -0,0 +1,52 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.core.telecom.test.ui
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+
+/** Screen used to communicate to the user that the device does not support this application. */
+@Composable
+fun UnsupportedDeviceScreen() {
+    Column(
+        verticalArrangement = Arrangement.Center,
+        modifier = Modifier.padding(12.dp).fillMaxSize()
+    ) {
+        Text(
+            modifier = Modifier.fillMaxWidth(),
+            textAlign = TextAlign.Center,
+            style = MaterialTheme.typography.titleLarge,
+            text = "Unsupported Device"
+        )
+        Text(
+            modifier = Modifier.padding(10.dp).fillMaxWidth(),
+            textAlign = TextAlign.Center,
+            text =
+                "Unfortunately, this device doesn't support the dialer role so this app will " +
+                    "not work."
+        )
+    }
+}
diff --git a/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/ui/calling/AudioEndpointUiState.kt b/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/ui/calling/AudioEndpointUiState.kt
new file mode 100644
index 0000000..c13d318
--- /dev/null
+++ b/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/ui/calling/AudioEndpointUiState.kt
@@ -0,0 +1,32 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.core.telecom.test.ui.calling
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import androidx.core.telecom.test.services.AudioRoute
+
+/** Sets up some previews for UI components using the [AudioEndpointUiState]. */
+class UserPreviewEndpointProvider : PreviewParameterProvider<AudioEndpointUiState> {
+    override val values =
+        sequenceOf(
+            AudioEndpointUiState(id = "A", name = "Earpiece", audioRoute = AudioRoute.EARPIECE),
+            AudioEndpointUiState(id = "B", name = "Speaker", audioRoute = AudioRoute.SPEAKER),
+            AudioEndpointUiState(id = "C", name = "Bluetooth", audioRoute = AudioRoute.BLUETOOTH),
+        )
+}
+
+data class AudioEndpointUiState(val id: String, val name: String, val audioRoute: AudioRoute)
diff --git a/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/ui/calling/AudioRouteDialog.kt b/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/ui/calling/AudioRouteDialog.kt
new file mode 100644
index 0000000..947a732
--- /dev/null
+++ b/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/ui/calling/AudioRouteDialog.kt
@@ -0,0 +1,143 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.core.telecom.test.ui.calling
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.ElevatedCard
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedCard
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.tooling.preview.Wallpapers
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+import androidx.core.telecom.test.R
+import androidx.core.telecom.test.services.AudioRoute
+import androidx.core.telecom.test.ui.calling.OngoingCallsViewModel.Companion.UnknownAudioUiState
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import kotlinx.coroutines.launch
+
+/**
+ * The dialog that pops up on the screen when the user tries to change the audio route of the
+ * device.
+ */
+@Composable
+fun AudioRoutePickerDialog(
+    ongoingCallsViewModel: OngoingCallsViewModel,
+    onDismissDialog: () -> Unit,
+    onChangeAudioRoute: suspend (String) -> Unit
+) {
+    val currentAudioRoute: AudioEndpointUiState by
+        ongoingCallsViewModel
+            .streamCurrentEndpointAudioData()
+            .collectAsStateWithLifecycle(UnknownAudioUiState)
+    val availableAudioRoutes: List<AudioEndpointUiState> by
+        ongoingCallsViewModel
+            .streamAvailableEndpointAudioData()
+            .collectAsStateWithLifecycle(emptyList())
+    Dialog(onDismissRequest = onDismissDialog) {
+        Card(
+            modifier = Modifier.fillMaxWidth(),
+            colors =
+                CardDefaults.cardColors(
+                    containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
+                )
+        ) {
+            Column(modifier = Modifier.padding(6.dp)) {
+                Text("Current Audio Route")
+                Spacer(modifier = Modifier.padding(vertical = 3.dp))
+                OutlinedCard { AudioRouteContent(currentAudioRoute) }
+                HorizontalDivider(modifier = Modifier.padding(vertical = 6.dp))
+                Text("Available Audio Routes")
+                val available = availableAudioRoutes.filter { it.id != currentAudioRoute.id }
+                if (available.isEmpty()) {
+                    Text(modifier = Modifier.padding(6.dp), text = "<None Available>")
+                } else {
+                    available.forEach { route ->
+                        ClickableAudioRouteContent(route, onChangeAudioRoute)
+                    }
+                }
+            }
+        }
+    }
+}
+
+@Preview(showBackground = true, wallpaper = Wallpapers.BLUE_DOMINATED_EXAMPLE)
+@Composable
+fun ClickableAudioRouteContent(
+    @PreviewParameter(UserPreviewEndpointProvider::class) audioRoute: AudioEndpointUiState,
+    onChangeAudioRoute: suspend (String) -> Unit = {}
+) {
+    val coroutineScope = rememberCoroutineScope()
+    var isLoading: Boolean by remember { mutableStateOf(false) }
+    ElevatedCard(
+        enabled = !isLoading,
+        onClick = {
+            coroutineScope.launch {
+                isLoading = true
+                onChangeAudioRoute(audioRoute.id)
+                isLoading = false
+            }
+        }
+    ) {
+        AudioRouteContent(audioRoute)
+    }
+}
+
+@Composable
+fun AudioRouteContent(audioRoute: AudioEndpointUiState) {
+    Row(
+        modifier = Modifier.fillMaxWidth().padding(6.dp),
+        verticalAlignment = Alignment.CenterVertically
+    ) {
+        Icon(
+            painter = painterResource(getResourceForAudioRoute(audioRoute.audioRoute)),
+            contentDescription = "audio route details"
+        )
+        Spacer(modifier = Modifier.padding(horizontal = 6.dp))
+        Text(audioRoute.name)
+    }
+}
+
+fun getResourceForAudioRoute(audioRoute: AudioRoute): Int {
+    return when (audioRoute) {
+        AudioRoute.UNKNOWN -> R.drawable.phone_in_talk_24px
+        AudioRoute.EARPIECE -> R.drawable.phone_in_talk_24px
+        AudioRoute.SPEAKER -> R.drawable.speaker_phone_24px
+        AudioRoute.BLUETOOTH -> R.drawable.bluetooth_24px
+        AudioRoute.HEADSET -> R.drawable.headset_mic_24px
+        AudioRoute.STREAMING -> R.drawable.cast_24px
+    }
+}
diff --git a/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/ui/calling/CallUiState.kt b/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/ui/calling/CallUiState.kt
new file mode 100644
index 0000000..23c3da2
--- /dev/null
+++ b/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/ui/calling/CallUiState.kt
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.core.telecom.test.ui.calling
+
+import android.net.Uri
+import androidx.core.telecom.test.services.CallState
+import androidx.core.telecom.test.services.CallType
+import androidx.core.telecom.test.services.Direction
+
+/** Defines valid call state transitions */
+enum class CallStateTransition {
+    ANSWER,
+    HOLD,
+    UNHOLD,
+    NONE,
+    DISCONNECT
+}
+
+/** UI state and callback container for a Call */
+data class CallUiState(
+    val id: Int,
+    val name: String,
+    val photo: Uri?,
+    val number: String,
+    val state: CallState,
+    val validTransition: CallStateTransition,
+    val direction: Direction,
+    val callType: CallType,
+    val onStateChanged: (transition: CallStateTransition) -> Unit,
+    val participantUiState: ParticipantExtensionUiState?
+)
diff --git a/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/ui/calling/CallsScreen.kt b/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/ui/calling/CallsScreen.kt
new file mode 100644
index 0000000..c316c6a
--- /dev/null
+++ b/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/ui/calling/CallsScreen.kt
@@ -0,0 +1,368 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR 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.ui.calling
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.ImageDecoder
+import android.net.Uri
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.animateContentSize
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.IntrinsicSize
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.Face
+import androidx.compose.material.icons.rounded.Settings
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.ElevatedCard
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.OutlinedIconButton
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults.topAppBarColors
+import androidx.compose.material3.VerticalDivider
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.graphics.painter.BitmapPainter
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.tooling.preview.Wallpapers
+import androidx.compose.ui.unit.dp
+import androidx.core.telecom.test.R
+import androidx.core.telecom.test.services.AudioRoute
+import androidx.core.telecom.test.services.CallState
+import androidx.core.telecom.test.services.CallType
+import androidx.core.telecom.test.services.Direction
+import androidx.core.telecom.test.ui.calling.OngoingCallsViewModel.Companion.UnknownAudioUiState
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.compose.LocalLifecycleOwner
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+
+/**
+ * The main screen of the application, which allows the user to view and manage ongoing calls on the
+ * device.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun CallsScreen(
+    lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
+    context: Context = LocalContext.current,
+    ongoingCallsViewModel: OngoingCallsViewModel,
+    onShowAudioRouting: () -> Unit,
+    onMoveToSettings: () -> Unit
+) {
+    DisposableEffect(lifecycleOwner, context) {
+        ongoingCallsViewModel.connectService(context)
+        // When the effect leaves the Composition, teardown
+        onDispose { ongoingCallsViewModel.disconnectService() }
+    }
+    val callDataState: List<CallUiState> by
+        ongoingCallsViewModel
+            .streamCallData(LocalContext.current)
+            .collectAsStateWithLifecycle(emptyList())
+    val isMuted: Boolean by
+        ongoingCallsViewModel.streamMuteData().collectAsStateWithLifecycle(false)
+    val currentAudioRoute: AudioEndpointUiState by
+        ongoingCallsViewModel
+            .streamCurrentEndpointAudioData()
+            .collectAsStateWithLifecycle(UnknownAudioUiState)
+    Scaffold(
+        topBar = {
+            TopAppBar(
+                colors =
+                    topAppBarColors(
+                        containerColor = MaterialTheme.colorScheme.primaryContainer,
+                        titleContentColor = MaterialTheme.colorScheme.primary
+                    ),
+                title = { Text("Ongoing Calls") },
+                actions = {
+                    IconButton(onClick = onMoveToSettings) {
+                        Icon(imageVector = Icons.Rounded.Settings, contentDescription = "Settings ")
+                    }
+                }
+            )
+        }
+    ) { scaffoldPadding ->
+        ServiceConnectedCallContent(
+            modifier = Modifier.padding(scaffoldPadding),
+            callDataState,
+            isMuted,
+            currentAudioRoute,
+            onChangeMuteState = ongoingCallsViewModel::onChangeMuteState,
+            onShowAudioRouteDialog = onShowAudioRouting
+        )
+    }
+}
+
+@Composable
+fun ServiceConnectedCallContent(
+    modifier: Modifier = Modifier,
+    calls: List<CallUiState>,
+    isMuted: Boolean,
+    currentAudioRoute: AudioEndpointUiState,
+    onChangeMuteState: (Boolean) -> Unit,
+    onShowAudioRouteDialog: () -> Unit,
+) {
+    Column(modifier = modifier.fillMaxSize().verticalScroll(rememberScrollState())) {
+        if (calls.isNotEmpty()) {
+            DeviceStatusCard(
+                isMuted,
+                currentAudioRoute,
+                onShowAudioRouteDialog = onShowAudioRouteDialog,
+                onMuteStateChange = onChangeMuteState
+            )
+        }
+        calls.forEach { caller -> CallCard(caller = caller) }
+    }
+}
+
+@Preview(showBackground = true, wallpaper = Wallpapers.BLUE_DOMINATED_EXAMPLE)
+@Composable
+fun CallerCard(
+    modifier: Modifier = Modifier,
+    isExpanded: Boolean = false,
+    name: String = "Abraham Lincoln",
+    number: String = "555-1212",
+    photo: Uri? = null,
+    direction: Direction = Direction.INCOMING,
+    callType: CallType = CallType.AUDIO,
+    callState: CallState = CallState.UNKNOWN
+) {
+    Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) {
+        if (photo == null) {
+            Icon(
+                Icons.Rounded.Face,
+                modifier = Modifier.size(48.dp),
+                contentDescription = "Caller Icon"
+            )
+        } else {
+            val context = LocalContext.current
+            val bitmap: Bitmap by remember {
+                mutableStateOf(
+                    ImageDecoder.decodeBitmap(
+                        ImageDecoder.createSource(context.contentResolver, photo)
+                    )
+                )
+            }
+            Image(
+                modifier = Modifier.size(48.dp).clip(CircleShape),
+                painter = BitmapPainter(bitmap.asImageBitmap()),
+                contentDescription = "Caller Icon"
+            )
+        }
+        Column(modifier = Modifier.padding(6.dp)) {
+            if (name.isNotEmpty()) {
+                Text(text = name)
+            }
+            Text(text = number)
+            if (isExpanded) {
+                Row(modifier = Modifier.height(IntrinsicSize.Min)) {
+                    Text(
+                        text =
+                            when (callType) {
+                                CallType.AUDIO -> "Audio"
+                                CallType.VIDEO -> "Video"
+                            }
+                    )
+                    VerticalDivider(modifier = Modifier.padding(horizontal = 6.dp))
+                    Text(
+                        text =
+                            when (direction) {
+                                Direction.INCOMING -> "Incoming"
+                                Direction.OUTGOING -> "Outgoing"
+                            }
+                    )
+                    VerticalDivider(modifier = Modifier.padding(horizontal = 6.dp))
+                    Text(
+                        text =
+                            when (callState) {
+                                CallState.UNKNOWN -> "Unknown"
+                                CallState.INCOMING -> "Incoming"
+                                CallState.DIALING -> "Dialing"
+                                CallState.ACTIVE -> "Active"
+                                CallState.HELD -> "Held"
+                                CallState.DISCONNECTING -> "Disconnecting"
+                                CallState.DISCONNECTED -> "Disconnected"
+                            }
+                    )
+                }
+            }
+        }
+    }
+}
+
+@Composable
+fun DeviceStatusCard(
+    isMuted: Boolean = false,
+    currentAudioRoute: AudioEndpointUiState,
+    onMuteStateChange: (Boolean) -> Unit,
+    onShowAudioRouteDialog: () -> Unit,
+) {
+    ElevatedCard(
+        colors =
+            CardDefaults.cardColors(
+                containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
+            ),
+        modifier = Modifier.fillMaxWidth().padding(6.dp)
+    ) {
+        Column(
+            modifier = Modifier.fillMaxWidth().padding(6.dp),
+        ) {
+            Text("Device State")
+            HorizontalDivider(modifier = Modifier.padding(vertical = 6.dp))
+            Row(
+                modifier = Modifier.fillMaxWidth(),
+                horizontalArrangement = Arrangement.SpaceEvenly
+            ) {
+                OutlinedIconButton(onClick = { onMuteStateChange(!isMuted) }) {
+                    if (!isMuted) {
+                        Icon(
+                            painter = painterResource(R.drawable.mic),
+                            contentDescription = "device unmuted"
+                        )
+                    } else {
+                        Icon(
+                            painter = painterResource(R.drawable.mic_off_24px),
+                            contentDescription = "device muted"
+                        )
+                    }
+                }
+                OutlinedIconButton(
+                    enabled = currentAudioRoute.audioRoute != AudioRoute.UNKNOWN,
+                    onClick = onShowAudioRouteDialog
+                ) {
+                    Icon(
+                        painter =
+                            painterResource(getResourceForAudioRoute(currentAudioRoute.audioRoute)),
+                        contentDescription = "current audio route"
+                    )
+                }
+            }
+        }
+    }
+}
+
+@Composable
+fun CallCard(caller: CallUiState, defaultExpandedState: Boolean = false) {
+    var isExpanded by remember { mutableStateOf(defaultExpandedState) }
+    val expandedColor =
+        when (isExpanded) {
+            true -> MaterialTheme.colorScheme.surfaceContainerHigh
+            false -> MaterialTheme.colorScheme.surfaceContainerLow
+        }
+    val padding =
+        when (isExpanded) {
+            true -> 6.dp
+            false -> 12.dp
+        }
+    ElevatedCard(
+        colors = CardDefaults.cardColors(containerColor = expandedColor),
+        modifier =
+            Modifier.animateContentSize()
+                .height(IntrinsicSize.Min)
+                .fillMaxWidth()
+                .padding(padding)
+                .clickable { isExpanded = !isExpanded }
+    ) {
+        Column {
+            Column(modifier = Modifier.padding(6.dp)) {
+                CallerCard(
+                    isExpanded = isExpanded,
+                    name = caller.name,
+                    number = caller.number,
+                    photo = caller.photo,
+                    direction = caller.direction,
+                    callType = caller.callType,
+                    callState = caller.state
+                )
+                Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
+                    val isCallTransitionPossible =
+                        caller.validTransition != CallStateTransition.NONE &&
+                            caller.validTransition != CallStateTransition.DISCONNECT
+                    if (isCallTransitionPossible) {
+                        OutlinedButton(
+                            onClick = { caller.onStateChanged(caller.validTransition) },
+                        ) {
+                            val stateTransitionText =
+                                when (caller.validTransition) {
+                                    CallStateTransition.UNHOLD -> "Unhold"
+                                    CallStateTransition.HOLD -> "Hold"
+                                    CallStateTransition.ANSWER -> "Answer"
+                                    CallStateTransition.NONE -> "None"
+                                    CallStateTransition.DISCONNECT -> "Disconnect"
+                                }
+                            Text(text = stateTransitionText)
+                        }
+                    }
+                    Spacer(modifier = Modifier.padding(horizontal = 6.dp))
+                    OutlinedButton(
+                        onClick = { caller.onStateChanged(CallStateTransition.DISCONNECT) },
+                    ) {
+                        val disconnectText =
+                            if (caller.state == CallState.INCOMING) {
+                                "Reject"
+                            } else {
+                                "Hangup"
+                            }
+                        Text(disconnectText)
+                    }
+                }
+            }
+            AnimatedVisibility(isExpanded) {
+                Column {
+                    HorizontalDivider(modifier = Modifier.padding(vertical = 6.dp))
+                    if (caller.participantUiState == null) {
+                        Text(
+                            modifier = Modifier.fillMaxWidth().padding(6.dp),
+                            text = "<No Extensions supported>"
+                        )
+                    } else {
+                        ExtensionsContent(participantUiState = caller.participantUiState)
+                    }
+                }
+            }
+        }
+    }
+}
diff --git a/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/ui/calling/ExtensionsContent.kt b/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/ui/calling/ExtensionsContent.kt
new file mode 100644
index 0000000..fbc12bf
--- /dev/null
+++ b/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/ui/calling/ExtensionsContent.kt
@@ -0,0 +1,214 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR 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.ui.calling
+
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.Face
+import androidx.compose.material3.Button
+import androidx.compose.material3.ElevatedButton
+import androidx.compose.material3.ElevatedCard
+import androidx.compose.material3.Icon
+import androidx.compose.material3.OutlinedCard
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.tooling.preview.Wallpapers
+import androidx.compose.ui.unit.dp
+import androidx.core.telecom.test.R
+import kotlinx.coroutines.launch
+
+@Preview(showBackground = true, wallpaper = Wallpapers.BLUE_DOMINATED_EXAMPLE)
+@Composable
+fun ExtensionsContent(
+    @PreviewParameter(ParticipantExtensionProvider::class)
+    participantUiState: ParticipantExtensionUiState
+) {
+    Column(modifier = Modifier.fillMaxWidth().padding(6.dp)) {
+        Text("Participants")
+        if (participantUiState.participants.isEmpty()) {
+            Text(modifier = Modifier.padding(horizontal = 6.dp), text = "<No Participants>")
+        } else {
+            Column(
+                modifier =
+                    Modifier.height(150.dp)
+                        .fillMaxWidth()
+                        .padding(6.dp)
+                        .verticalScroll(rememberScrollState())
+            ) {
+                participantUiState.participants.forEach {
+                    if (it.isActive) {
+                        ActiveParticipantContent(
+                            participantUiState.isKickParticipantSupported,
+                            participantUiState.isRaiseHandSupported,
+                            onRaiseHandStateChanged = participantUiState.onRaiseHandStateChanged,
+                            it
+                        )
+                    } else {
+                        NonActiveParticipantContent(
+                            participantUiState.isKickParticipantSupported,
+                            participantUiState.isRaiseHandSupported,
+                            onRaiseHandStateChanged = participantUiState.onRaiseHandStateChanged,
+                            it
+                        )
+                    }
+                    Spacer(Modifier.padding(vertical = 6.dp))
+                }
+            }
+        }
+    }
+}
+
+@Composable
+fun NonActiveParticipantContent(
+    isKickSupported: Boolean,
+    isRaiseHandSupported: Boolean,
+    onRaiseHandStateChanged: suspend (Boolean) -> Unit,
+    participant: ParticipantUiState
+) {
+    ElevatedCard {
+        ParticipantContent(
+            isKickSupported,
+            isRaiseHandSupported,
+            onRaiseHandStateChanged,
+            participant
+        )
+    }
+}
+
+@Composable
+fun ActiveParticipantContent(
+    isKickSupported: Boolean,
+    isRaiseHandSupported: Boolean,
+    onRaiseHandStateChanged: suspend (Boolean) -> Unit,
+    participant: ParticipantUiState
+) {
+    OutlinedCard(
+        border = BorderStroke(3.dp, Color.Black),
+    ) {
+        ParticipantContent(
+            isKickSupported,
+            isRaiseHandSupported,
+            onRaiseHandStateChanged,
+            participant
+        )
+    }
+}
+
+@Composable
+fun ParticipantContent(
+    isKickSupported: Boolean,
+    isRaiseHandSupported: Boolean,
+    onRaiseHandStateChanged: suspend (Boolean) -> Unit,
+    participant: ParticipantUiState
+) {
+    Row(
+        modifier = Modifier.fillMaxWidth().padding(6.dp),
+        verticalAlignment = Alignment.CenterVertically
+    ) {
+        val scope = rememberCoroutineScope()
+        Icon(
+            Icons.Rounded.Face,
+            modifier = Modifier.size(48.dp),
+            contentDescription = "Caller Icon"
+        )
+        Spacer(modifier = Modifier.padding(horizontal = 6.dp))
+        Text(participant.name)
+        Spacer(modifier = Modifier.padding(horizontal = 6.dp))
+        if (isRaiseHandSupported) {
+            if (participant.isHandRaised) {
+                Icon(
+                    painter = painterResource(R.drawable.waving_hand_24px),
+                    contentDescription = "hand raised"
+                )
+            }
+        }
+        Spacer(modifier = Modifier.padding(horizontal = 6.dp).weight(1f))
+        if (participant.isSelf && isRaiseHandSupported) {
+            var isRaiseHandEnabled by remember { mutableStateOf(true) }
+            if (participant.isHandRaised) {
+                Button(
+                    enabled = isRaiseHandEnabled,
+                    onClick = {
+                        scope.launch {
+                            isRaiseHandEnabled = false
+                            onRaiseHandStateChanged(false)
+                            isRaiseHandEnabled = true
+                        }
+                    }
+                ) {
+                    Icon(
+                        painter = painterResource(R.drawable.waving_hand_24px),
+                        contentDescription = "lower hand request"
+                    )
+                }
+            } else {
+                ElevatedButton(
+                    enabled = isRaiseHandEnabled,
+                    onClick = {
+                        scope.launch {
+                            isRaiseHandEnabled = false
+                            onRaiseHandStateChanged(true)
+                            isRaiseHandEnabled = true
+                        }
+                    }
+                ) {
+                    Icon(
+                        painter = painterResource(R.drawable.waving_hand_24px),
+                        contentDescription = "raise hand request"
+                    )
+                }
+            }
+        }
+
+        if (!participant.isSelf && isKickSupported) {
+            Spacer(modifier = Modifier.padding(horizontal = 6.dp))
+            var isKickEnabled by remember { mutableStateOf(true) }
+            ElevatedButton(
+                enabled = isKickEnabled,
+                onClick = {
+                    scope.launch {
+                        isKickEnabled = false
+                        participant.onKickParticipant()
+                        isKickEnabled = true
+                    }
+                }
+            ) {
+                Text("Kick")
+            }
+        }
+    }
+}
diff --git a/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/ui/calling/OngoingCallsViewModel.kt b/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/ui/calling/OngoingCallsViewModel.kt
new file mode 100644
index 0000000..32e7c29
--- /dev/null
+++ b/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/ui/calling/OngoingCallsViewModel.kt
@@ -0,0 +1,242 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR 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.ui.calling
+
+import android.content.Context
+import android.net.Uri
+import android.telecom.PhoneAccount
+import android.telecom.PhoneAccountHandle
+import android.telephony.PhoneNumberUtils
+import android.telephony.TelephonyManager
+import androidx.core.content.getSystemService
+import androidx.core.telecom.CallControlResult
+import androidx.core.telecom.CallException
+import androidx.core.telecom.test.services.AudioRoute
+import androidx.core.telecom.test.services.CallAudioEndpoint
+import androidx.core.telecom.test.services.CallData
+import androidx.core.telecom.test.services.CallState
+import androidx.core.telecom.test.services.Capability
+import androidx.core.telecom.test.services.ParticipantExtensionData
+import androidx.core.telecom.test.services.RemoteCallProvider
+import androidx.core.telecom.util.ExperimentalAppActions
+import androidx.lifecycle.ViewModel
+import java.util.Locale
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+
+/**
+ * ViewModel responsible for maintaining the connection to the [RemoteCallProvider] as well as
+ * converting call/extension state to the associated UI specific state.
+ */
+class OngoingCallsViewModel(private val callProvider: RemoteCallProvider = RemoteCallProvider()) :
+    ViewModel() {
+    companion object {
+        val UnknownAudioEndpoint =
+            CallAudioEndpoint(id = "UNKNOWN", audioRoute = AudioRoute.UNKNOWN)
+        val UnknownAudioUiState =
+            AudioEndpointUiState(id = "UNKNOWN", name = "UNKNOWN", audioRoute = AudioRoute.UNKNOWN)
+    }
+
+    /** connect to the remote call provider with the given context */
+    fun connectService(context: Context) {
+        callProvider.connectService(context)
+    }
+
+    /** disconnect from the remote call provider */
+    fun disconnectService() {
+        callProvider.disconnectService()
+    }
+
+    /**
+     * stream the [CallData] from the [RemoteCallProvider] when the service is connected and map
+     * that data to associated [CallUiState].
+     */
+    fun streamCallData(context: Context): Flow<List<CallUiState>> {
+        return callProvider.streamCallData().map { dataState ->
+            dataState.map { mapToUiState(context, it) }
+        }
+    }
+
+    /** Stream the global mute state of the device as long as the service is connected. */
+    fun streamMuteData(): Flow<Boolean> {
+        return callProvider.streamMuteData()
+    }
+
+    /**
+     * Stream the current audio endpoint of the device as long as the service is connected and we
+     * are in call.
+     */
+    fun streamCurrentEndpointAudioData(): Flow<AudioEndpointUiState> {
+        return callProvider
+            .streamCurrentEndpointData()
+            .map { it ?: UnknownAudioEndpoint }
+            .map(::mapToUiAudioState)
+    }
+
+    /**
+     * Stream the available endpoints of the device as long as the service is connected and we are
+     * in call.
+     */
+    fun streamAvailableEndpointAudioData(): Flow<List<AudioEndpointUiState>> {
+        return callProvider
+            .streamAvailableEndpointData()
+            .map { it.map(::mapToUiAudioState) }
+            .map { endpoints -> endpoints.sortedWith(compareBy({ it.audioRoute }, { it.name })) }
+    }
+
+    /**
+     * Change the global mute state of the device
+     *
+     * @param isMuted true if the device should be muted, false otherwise
+     */
+    fun onChangeMuteState(isMuted: Boolean) {
+        callProvider.onChangeMuteState(isMuted)
+    }
+
+    /**
+     * Change the audio route of the active call
+     *
+     * @param id The ID of the endpoint from [AudioEndpointUiState.id]
+     */
+    suspend fun onChangeAudioRoute(id: String) {
+        callProvider.onChangeAudioRoute(id)
+    }
+
+    /** Perform a map operation from [CallData] to [CallUiState] */
+    private fun mapToUiState(context: Context, fullCallData: CallData): CallUiState {
+        return CallUiState(
+            id = fullCallData.callData.id,
+            name = fullCallData.callData.contactName ?: fullCallData.callData.name,
+            photo = fullCallData.callData.contactUri,
+            number =
+                formatPhoneNumber(
+                    context,
+                    fullCallData.callData.phoneAccountHandle,
+                    fullCallData.callData.number
+                ),
+            state = fullCallData.callData.state,
+            validTransition =
+                getValidTransition(fullCallData.callData.state, fullCallData.callData.capabilities),
+            direction = fullCallData.callData.direction,
+            callType = fullCallData.callData.callType,
+            onStateChanged = { fullCallData.callData.onStateChanged(it) },
+            participantUiState = mapToUiParticipantExtension(fullCallData.participantExtensionData)
+        )
+    }
+
+    /** Perform a map ooperation from [ParticipantExtensionData] to [ParticipantExtensionUiState] */
+    @OptIn(ExperimentalAppActions::class)
+    private fun mapToUiParticipantExtension(
+        participantExtensionData: ParticipantExtensionData?
+    ): ParticipantExtensionUiState? {
+        if (participantExtensionData == null || !participantExtensionData.isSupported) return null
+        return ParticipantExtensionUiState(
+            isRaiseHandSupported =
+                participantExtensionData.raiseHandData?.raiseHandAction?.isSupported ?: false,
+            isKickParticipantSupported =
+                participantExtensionData.kickParticipantData?.kickParticipantAction?.isSupported
+                    ?: false,
+            onRaiseHandStateChanged = {
+                participantExtensionData.raiseHandData
+                    ?.raiseHandAction
+                    ?.requestRaisedHandStateChange(it)
+            },
+            participants = mapUiParticipants(participantExtensionData)
+        )
+    }
+
+    /** map [ParticipantExtensionData] to [ParticipantExtensionUiState] */
+    @OptIn(ExperimentalAppActions::class)
+    private fun mapUiParticipants(
+        participantExtensionData: ParticipantExtensionData
+    ): List<ParticipantUiState> {
+        return participantExtensionData.participants.map { p ->
+            ParticipantUiState(
+                name = p.name.toString(),
+                isActive = participantExtensionData.activeParticipant == p,
+                isSelf = participantExtensionData.selfParticipant?.id == p.id,
+                isHandRaised =
+                    participantExtensionData.raiseHandData?.raisedHands?.contains(p) ?: false,
+                onKickParticipant = {
+                    participantExtensionData.kickParticipantData
+                        ?.kickParticipantAction
+                        ?.requestKickParticipant(p)
+                        ?: CallControlResult.Error(CallException.ERROR_CALL_IS_NOT_BEING_TRACKED)
+                }
+            )
+        }
+    }
+
+    /** format the phone number to a user friendly form */
+    private fun formatPhoneNumber(
+        context: Context,
+        phoneAccountHandle: PhoneAccountHandle,
+        number: Uri
+    ): String {
+        val isTel = PhoneAccount.SCHEME_TEL == number.scheme
+        if (!isTel) return number.schemeSpecificPart
+        val tm: TelephonyManager? =
+            context
+                .getSystemService<TelephonyManager>()
+                ?.createForPhoneAccountHandle(phoneAccountHandle)
+        val iso = tm?.networkCountryIso ?: Locale.getDefault().country
+        return PhoneNumberUtils.formatNumber(number.schemeSpecificPart, iso)
+    }
+
+    /** Determine the valid [CallStateTransition] based on [CallState] and call [Capability] */
+    private fun getValidTransition(
+        state: CallState,
+        capabilities: List<Capability>
+    ): CallStateTransition {
+        return when (state) {
+            CallState.INCOMING -> CallStateTransition.ANSWER
+            CallState.DIALING -> CallStateTransition.NONE
+            CallState.ACTIVE -> {
+                if (capabilities.contains(Capability.SUPPORTS_HOLD)) {
+                    CallStateTransition.HOLD
+                } else {
+                    CallStateTransition.NONE
+                }
+            }
+            CallState.HELD -> CallStateTransition.UNHOLD
+            CallState.DISCONNECTING -> CallStateTransition.NONE
+            CallState.DISCONNECTED -> CallStateTransition.NONE
+            CallState.UNKNOWN -> CallStateTransition.NONE
+        }
+    }
+
+    /** Map from [CallAudioEndpoint] to [AudioEndpointUiState] */
+    private fun mapToUiAudioState(endpoint: CallAudioEndpoint): AudioEndpointUiState {
+        return AudioEndpointUiState(
+            id = endpoint.id,
+            name = endpoint.frameworkName ?: getAudioEndpointRouteName(endpoint.audioRoute),
+            audioRoute = endpoint.audioRoute
+        )
+    }
+
+    /** Get the user friendly endpoint route name */
+    private fun getAudioEndpointRouteName(audioState: AudioRoute): String {
+        return when (audioState) {
+            AudioRoute.EARPIECE -> "Earpiece"
+            AudioRoute.SPEAKER -> "Speaker"
+            AudioRoute.HEADSET -> "Headset"
+            AudioRoute.BLUETOOTH -> "Bluetooth"
+            AudioRoute.STREAMING -> "Streaming"
+            AudioRoute.UNKNOWN -> "Unknown"
+        }
+    }
+}
diff --git a/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/ui/calling/ParticipantExtensionUiState.kt b/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/ui/calling/ParticipantExtensionUiState.kt
new file mode 100644
index 0000000..7ca42ae
--- /dev/null
+++ b/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/ui/calling/ParticipantExtensionUiState.kt
@@ -0,0 +1,65 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.core.telecom.test.ui.calling
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import androidx.core.telecom.CallControlResult
+
+/** Provide a preview of [ParticipantExtensionUiState] for helping with UI rendering of state. */
+class ParticipantExtensionProvider : PreviewParameterProvider<ParticipantExtensionUiState> {
+    override val values =
+        sequenceOf(
+            ParticipantExtensionUiState(
+                isRaiseHandSupported = true,
+                isKickParticipantSupported = true,
+                onRaiseHandStateChanged = { CallControlResult.Success() },
+                listOf(
+                    ParticipantUiState(
+                        "Abraham Lincoln",
+                        false,
+                        isHandRaised = false,
+                        isSelf = true,
+                        onKickParticipant = { CallControlResult.Success() }
+                    ),
+                    ParticipantUiState(
+                        "Betty Lapone",
+                        true,
+                        isHandRaised = true,
+                        isSelf = false,
+                        onKickParticipant = { CallControlResult.Success() }
+                    )
+                )
+            )
+        )
+}
+
+/** UI state and actions related to the extensions on a call */
+data class ParticipantExtensionUiState(
+    val isRaiseHandSupported: Boolean,
+    val isKickParticipantSupported: Boolean,
+    val onRaiseHandStateChanged: suspend (Boolean) -> Unit,
+    val participants: List<ParticipantUiState>
+)
+
+/** UI state and actions associated with a participant */
+data class ParticipantUiState(
+    val name: String,
+    val isActive: Boolean,
+    val isSelf: Boolean,
+    val isHandRaised: Boolean,
+    val onKickParticipant: suspend () -> CallControlResult,
+)
diff --git a/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/ui/theme/Theme.kt b/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/ui/theme/Theme.kt
new file mode 100644
index 0000000..8c233af
--- /dev/null
+++ b/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/ui/theme/Theme.kt
@@ -0,0 +1,62 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.core.telecom.test.ui.theme
+
+import android.app.Activity
+import android.os.Build
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.dynamicDarkColorScheme
+import androidx.compose.material3.dynamicLightColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.SideEffect
+import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalView
+import androidx.core.view.WindowCompat
+
+/**
+ * VERY basic theme for this device, which just pulls default colors from the system UI in light and
+ * dark mode.
+ */
+@Composable
+fun AppTheme(useDarkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) {
+    val context = LocalContext.current
+    val colors =
+        when {
+            (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) -> {
+                if (useDarkTheme) dynamicDarkColorScheme(context)
+                else dynamicLightColorScheme(context)
+            }
+            useDarkTheme -> darkColorScheme()
+            else -> lightColorScheme()
+        }
+    // Add primary status bar color from chosen color scheme.
+    val view = LocalView.current
+    if (!view.isInEditMode) {
+        SideEffect {
+            val window = (view.context as Activity).window
+            window.statusBarColor = colors.primary.toArgb()
+            WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars =
+                useDarkTheme
+        }
+    }
+
+    MaterialTheme(colorScheme = colors, content = content)
+}
diff --git a/core/core-telecom/integration-tests/testicsapp/src/main/res/drawable/android.xml b/core/core-telecom/integration-tests/testicsapp/src/main/res/drawable/android.xml
new file mode 100644
index 0000000..dfa932e
--- /dev/null
+++ b/core/core-telecom/integration-tests/testicsapp/src/main/res/drawable/android.xml
@@ -0,0 +1,35 @@
+<?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.
+  -->
+
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="160dp"
+    android:height="160dp"
+    android:viewportHeight="432"
+    android:viewportWidth="432">
+
+    <!-- Safe zone = 66dp => 432 * (66 / 108) = 432 * 0.61 -->
+    <group
+        android:translateX="84"
+        android:translateY="84"
+        android:scaleX="0.61"
+        android:scaleY="0.61">
+
+        <path
+            android:fillColor="#3ddc84"
+            android:pathData="m322.02,167.89c12.141,-21.437 25.117,-42.497 36.765,-64.158 2.2993,-7.7566 -9.5332,-12.802 -13.555,-5.7796 -12.206,21.045 -24.375,42.112 -36.567,63.166 -57.901,-26.337 -127.00,-26.337 -184.90,0.0 -12.685,-21.446 -24.606,-43.441 -37.743,-64.562 -5.6074,-5.8390 -15.861,1.9202 -11.747,8.8889 12.030,20.823 24.092,41.629 36.134,62.446C47.866,200.90 5.0987,267.15 0.0,337.5c144.00,0.0 288.00,0.0 432.0,0.0C426.74,267.06 384.46,201.32 322.02,167.89ZM116.66,276.03c-13.076,0.58968 -22.531,-15.277 -15.773,-26.469 5.7191,-11.755 24.196,-12.482 30.824,-1.2128 7.8705,11.451 -1.1102,28.027 -15.051,27.682zM315.55,276.03c-13.076,0.58968 -22.531,-15.277 -15.773,-26.469 5.7191,-11.755 24.196,-12.482 30.824,-1.2128 7.8705,11.451 -1.1097,28.027 -15.051,27.682z"
+            android:strokeWidth="2" />
+    </group>
+</vector>
\ No newline at end of file
diff --git a/core/core-telecom/integration-tests/testicsapp/src/main/res/drawable/bluetooth_24px.xml b/core/core-telecom/integration-tests/testicsapp/src/main/res/drawable/bluetooth_24px.xml
new file mode 100644
index 0000000..c4a98b4
--- /dev/null
+++ b/core/core-telecom/integration-tests/testicsapp/src/main/res/drawable/bluetooth_24px.xml
@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:viewportWidth="960"
+    android:viewportHeight="960">
+  <path
+      android:fillColor="@android:color/white"
+      android:pathData="M440,880L440,576L256,760L200,704L424,480L200,256L256,200L440,384L440,80L480,80L708,308L536,480L708,652L480,880L440,880ZM520,384L596,308L520,234L520,384ZM520,726L596,652L520,576L520,726Z"/>
+</vector>
diff --git a/core/core-telecom/integration-tests/testicsapp/src/main/res/drawable/call_24px.xml b/core/core-telecom/integration-tests/testicsapp/src/main/res/drawable/call_24px.xml
new file mode 100644
index 0000000..09fff5d
--- /dev/null
+++ b/core/core-telecom/integration-tests/testicsapp/src/main/res/drawable/call_24px.xml
@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:viewportWidth="960"
+    android:viewportHeight="960">
+  <path
+      android:fillColor="@android:color/white"
+      android:pathData="M798,840Q673,840 551,785.5Q429,731 329,631Q229,531 174.5,409Q120,287 120,162Q120,144 132,132Q144,120 162,120L324,120Q338,120 349,129.5Q360,139 362,152L388,292Q390,308 387,319Q384,330 376,338L279,436Q299,473 326.5,507.5Q354,542 387,574Q418,605 452,631.5Q486,658 524,680L618,586Q627,577 641.5,572.5Q656,568 670,570L808,598Q822,602 831,612.5Q840,623 840,636L840,798Q840,816 828,828Q816,840 798,840ZM241,360L307,294Q307,294 307,294Q307,294 307,294L290,200Q290,200 290,200Q290,200 290,200L201,200Q201,200 201,200Q201,200 201,200Q206,241 215,281Q224,321 241,360ZM599,718Q638,735 678.5,745Q719,755 760,758Q760,758 760,758Q760,758 760,758L760,670Q760,670 760,670Q760,670 760,670L666,651Q666,651 666,651Q666,651 666,651L599,718ZM241,360Q241,360 241,360Q241,360 241,360Q241,360 241,360Q241,360 241,360L241,360Q241,360 241,360Q241,360 241,360L241,360Q241,360 241,360Q241,360 241,360ZM599,718L599,718Q599,718 599,718Q599,718 599,718L599,718Q599,718 599,718Q599,718 599,718L599,718Q599,718 599,718Q599,718 599,718Q599,718 599,718Q599,718 599,718Z"/>
+</vector>
diff --git a/core/core-telecom/integration-tests/testicsapp/src/main/res/drawable/cast_24px.xml b/core/core-telecom/integration-tests/testicsapp/src/main/res/drawable/cast_24px.xml
new file mode 100644
index 0000000..7e99841
--- /dev/null
+++ b/core/core-telecom/integration-tests/testicsapp/src/main/res/drawable/cast_24px.xml
@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:viewportWidth="960"
+    android:viewportHeight="960">
+  <path
+      android:fillColor="@android:color/white"
+      android:pathData="M480,480Q480,480 480,480Q480,480 480,480L480,480Q480,480 480,480Q480,480 480,480L480,480Q480,480 480,480Q480,480 480,480L480,480Q480,480 480,480Q480,480 480,480ZM800,800L600,800Q600,780 598.5,760Q597,740 594,720L800,720Q800,720 800,720Q800,720 800,720L800,240Q800,240 800,240Q800,240 800,240L160,240Q160,240 160,240Q160,240 160,240L160,286Q140,283 120,281.5Q100,280 80,280L80,240Q80,207 103.5,183.5Q127,160 160,160L800,160Q833,160 856.5,183.5Q880,207 880,240L880,720Q880,753 856.5,776.5Q833,800 800,800ZM80,800L80,680Q130,680 165,715Q200,750 200,800L80,800ZM280,800Q280,717 221.5,658.5Q163,600 80,600L80,520Q197,520 278.5,601.5Q360,683 360,800L280,800ZM440,800Q440,725 411.5,659.5Q383,594 334.5,545.5Q286,497 220.5,468.5Q155,440 80,440L80,360Q171,360 251,394.5Q331,429 391,489Q451,549 485.5,629Q520,709 520,800L440,800Z"/>
+</vector>
diff --git a/core/core-telecom/integration-tests/testicsapp/src/main/res/drawable/dialpad_24px.xml b/core/core-telecom/integration-tests/testicsapp/src/main/res/drawable/dialpad_24px.xml
new file mode 100644
index 0000000..ce1f8b1
--- /dev/null
+++ b/core/core-telecom/integration-tests/testicsapp/src/main/res/drawable/dialpad_24px.xml
@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:viewportWidth="960"
+    android:viewportHeight="960">
+  <path
+      android:fillColor="@android:color/white"
+      android:pathData="M480,920Q447,920 423.5,896.5Q400,873 400,840Q400,807 423.5,783.5Q447,760 480,760Q513,760 536.5,783.5Q560,807 560,840Q560,873 536.5,896.5Q513,920 480,920ZM240,200Q207,200 183.5,176.5Q160,153 160,120Q160,87 183.5,63.5Q207,40 240,40Q273,40 296.5,63.5Q320,87 320,120Q320,153 296.5,176.5Q273,200 240,200ZM240,440Q207,440 183.5,416.5Q160,393 160,360Q160,327 183.5,303.5Q207,280 240,280Q273,280 296.5,303.5Q320,327 320,360Q320,393 296.5,416.5Q273,440 240,440ZM240,680Q207,680 183.5,656.5Q160,633 160,600Q160,567 183.5,543.5Q207,520 240,520Q273,520 296.5,543.5Q320,567 320,600Q320,633 296.5,656.5Q273,680 240,680ZM720,200Q687,200 663.5,176.5Q640,153 640,120Q640,87 663.5,63.5Q687,40 720,40Q753,40 776.5,63.5Q800,87 800,120Q800,153 776.5,176.5Q753,200 720,200ZM480,680Q447,680 423.5,656.5Q400,633 400,600Q400,567 423.5,543.5Q447,520 480,520Q513,520 536.5,543.5Q560,567 560,600Q560,633 536.5,656.5Q513,680 480,680ZM720,680Q687,680 663.5,656.5Q640,633 640,600Q640,567 663.5,543.5Q687,520 720,520Q753,520 776.5,543.5Q800,567 800,600Q800,633 776.5,656.5Q753,680 720,680ZM720,440Q687,440 663.5,416.5Q640,393 640,360Q640,327 663.5,303.5Q687,280 720,280Q753,280 776.5,303.5Q800,327 800,360Q800,393 776.5,416.5Q753,440 720,440ZM480,440Q447,440 423.5,416.5Q400,393 400,360Q400,327 423.5,303.5Q447,280 480,280Q513,280 536.5,303.5Q560,327 560,360Q560,393 536.5,416.5Q513,440 480,440ZM480,200Q447,200 423.5,176.5Q400,153 400,120Q400,87 423.5,63.5Q447,40 480,40Q513,40 536.5,63.5Q560,87 560,120Q560,153 536.5,176.5Q513,200 480,200Z"/>
+</vector>
diff --git a/core/core-telecom/integration-tests/testicsapp/src/main/res/drawable/headset_mic_24px.xml b/core/core-telecom/integration-tests/testicsapp/src/main/res/drawable/headset_mic_24px.xml
new file mode 100644
index 0000000..ac3d6e5
--- /dev/null
+++ b/core/core-telecom/integration-tests/testicsapp/src/main/res/drawable/headset_mic_24px.xml
@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:viewportWidth="960"
+    android:viewportHeight="960">
+  <path
+      android:fillColor="@android:color/white"
+      android:pathData="M480,920L480,840L760,840Q760,840 760,840Q760,840 760,840L760,800L600,800L600,480L760,480L760,440Q760,324 678,242Q596,160 480,160Q364,160 282,242Q200,324 200,440L200,480L360,480L360,800L200,800Q167,800 143.5,776.5Q120,753 120,720L120,440Q120,366 148.5,300.5Q177,235 226,186Q275,137 340.5,108.5Q406,80 480,80Q554,80 619.5,108.5Q685,137 734,186Q783,235 811.5,300.5Q840,366 840,440L840,840Q840,873 816.5,896.5Q793,920 760,920L480,920ZM200,720L280,720L280,560L200,560L200,720Q200,720 200,720Q200,720 200,720ZM680,720L760,720L760,560L680,560L680,720ZM200,560Q200,560 200,560Q200,560 200,560L200,560L280,560L280,560L200,560ZM680,560L680,560L760,560L760,560L680,560Z"/>
+</vector>
diff --git a/core/core-telecom/integration-tests/testicsapp/src/main/res/drawable/ic_launcher.xml b/core/core-telecom/integration-tests/testicsapp/src/main/res/drawable/ic_launcher.xml
new file mode 100644
index 0000000..481bbd7
--- /dev/null
+++ b/core/core-telecom/integration-tests/testicsapp/src/main/res/drawable/ic_launcher.xml
@@ -0,0 +1,19 @@
+<?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.
+  -->
+
+<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
+    <item android:drawable="@drawable/android" />
+</layer-list>
\ No newline at end of file
diff --git a/core/core-telecom/integration-tests/testicsapp/src/main/res/drawable/mic.xml b/core/core-telecom/integration-tests/testicsapp/src/main/res/drawable/mic.xml
new file mode 100644
index 0000000..b239700
--- /dev/null
+++ b/core/core-telecom/integration-tests/testicsapp/src/main/res/drawable/mic.xml
@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:viewportWidth="960"
+    android:viewportHeight="960">
+  <path
+      android:pathData="M480,560q-50,0 -85,-35t-35,-85v-240q0,-50 35,-85t85,-35q50,0 85,35t35,85v240q0,50 -35,85t-85,35ZM480,320ZM440,840v-123q-104,-14 -172,-93t-68,-184h80q0,83 58.5,141.5T480,640q83,0 141.5,-58.5T680,440h80q0,105 -68,184t-172,93v123h-80ZM480,480q17,0 28.5,-11.5T520,440v-240q0,-17 -11.5,-28.5T480,160q-17,0 -28.5,11.5T440,200v240q0,17 11.5,28.5T480,480Z"
+      android:fillColor="@android:color/white"/>
+</vector>
diff --git a/core/core-telecom/integration-tests/testicsapp/src/main/res/drawable/mic_off_24px.xml b/core/core-telecom/integration-tests/testicsapp/src/main/res/drawable/mic_off_24px.xml
new file mode 100644
index 0000000..716a40c
--- /dev/null
+++ b/core/core-telecom/integration-tests/testicsapp/src/main/res/drawable/mic_off_24px.xml
@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:viewportWidth="960"
+    android:viewportHeight="960">
+  <path
+      android:fillColor="@android:color/white"
+      android:pathData="M710,598L652,540Q666,517 673,492Q680,467 680,440L760,440Q760,484 747,523.5Q734,563 710,598ZM480,366Q480,366 480,366Q480,366 480,366L480,366L480,366L480,366Q480,366 480,366Q480,366 480,366ZM592,478L520,406L520,200Q520,183 508.5,171.5Q497,160 480,160Q463,160 451.5,171.5Q440,183 440,200L440,326L360,246L360,200Q360,150 395,115Q430,80 480,80Q530,80 565,115Q600,150 600,200L600,440Q600,451 597.5,460Q595,469 592,478ZM440,840L440,717Q336,703 268,624Q200,545 200,440L280,440Q280,523 337.5,581.5Q395,640 480,640Q514,640 544.5,629.5Q575,619 600,600L657,657Q628,680 593.5,696Q559,712 520,717L520,840L440,840ZM792,904L56,168L112,112L848,848L792,904Z"/>
+</vector>
diff --git a/core/core-telecom/integration-tests/testicsapp/src/main/res/drawable/phone_in_talk_24px.xml b/core/core-telecom/integration-tests/testicsapp/src/main/res/drawable/phone_in_talk_24px.xml
new file mode 100644
index 0000000..d35ae53
--- /dev/null
+++ b/core/core-telecom/integration-tests/testicsapp/src/main/res/drawable/phone_in_talk_24px.xml
@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:viewportWidth="960"
+    android:viewportHeight="960">
+  <path
+      android:fillColor="@android:color/white"
+      android:pathData="M760,480Q760,363 678.5,281.5Q597,200 480,200L480,120Q555,120 620.5,148.5Q686,177 734.5,225.5Q783,274 811.5,339.5Q840,405 840,480L760,480ZM600,480Q600,430 565,395Q530,360 480,360L480,280Q563,280 621.5,338.5Q680,397 680,480L600,480ZM798,840Q673,840 551,785.5Q429,731 329,631Q229,531 174.5,409Q120,287 120,162Q120,144 132,132Q144,120 162,120L324,120Q338,120 349,129.5Q360,139 362,152L388,292Q390,308 387,319Q384,330 376,338L279,436Q299,473 326.5,507.5Q354,542 387,574Q418,605 452,631.5Q486,658 524,680L618,586Q627,577 641.5,572.5Q656,568 670,570L808,598Q822,602 831,612.5Q840,623 840,636L840,798Q840,816 828,828Q816,840 798,840ZM241,360L307,294Q307,294 307,294Q307,294 307,294L290,200Q290,200 290,200Q290,200 290,200L201,200Q201,200 201,200Q201,200 201,200Q206,241 215,281Q224,321 241,360ZM599,718Q638,735 678.5,745Q719,755 760,758Q760,758 760,758Q760,758 760,758L760,670Q760,670 760,670Q760,670 760,670L666,651Q666,651 666,651Q666,651 666,651L599,718ZM241,360Q241,360 241,360Q241,360 241,360Q241,360 241,360Q241,360 241,360L241,360Q241,360 241,360Q241,360 241,360L241,360Q241,360 241,360Q241,360 241,360ZM599,718L599,718Q599,718 599,718Q599,718 599,718L599,718Q599,718 599,718Q599,718 599,718L599,718Q599,718 599,718Q599,718 599,718Q599,718 599,718Q599,718 599,718Z"/>
+</vector>
diff --git a/core/core-telecom/integration-tests/testicsapp/src/main/res/drawable/speaker_phone_24px.xml b/core/core-telecom/integration-tests/testicsapp/src/main/res/drawable/speaker_phone_24px.xml
new file mode 100644
index 0000000..6e40548
--- /dev/null
+++ b/core/core-telecom/integration-tests/testicsapp/src/main/res/drawable/speaker_phone_24px.xml
@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:viewportWidth="960"
+    android:viewportHeight="960">
+  <path
+      android:fillColor="@android:color/white"
+      android:pathData="M338,340L280,282Q321,243 372.5,221.5Q424,200 480,200Q536,200 587.5,221.5Q639,243 680,282L622,340Q593,311 557,295.5Q521,280 480,280Q439,280 403,295.5Q367,311 338,340ZM226,224L170,168Q233,106 312.5,73Q392,40 480,40Q568,40 647.5,73Q727,106 790,168L734,224Q683,174 617,147Q551,120 480,120Q409,120 343,147Q277,174 226,224ZM400,880Q367,880 343.5,856.5Q320,833 320,800L320,480Q320,447 343.5,423.5Q367,400 400,400L560,400Q593,400 616.5,423.5Q640,447 640,480L640,800Q640,833 616.5,856.5Q593,880 560,880L400,880ZM560,800Q560,800 560,800Q560,800 560,800L560,480Q560,480 560,480Q560,480 560,480L400,480Q400,480 400,480Q400,480 400,480L400,800Q400,800 400,800Q400,800 400,800L560,800ZM560,800L400,800Q400,800 400,800Q400,800 400,800L400,800Q400,800 400,800Q400,800 400,800L560,800Q560,800 560,800Q560,800 560,800L560,800Q560,800 560,800Q560,800 560,800Z"/>
+</vector>
diff --git a/core/core-telecom/integration-tests/testicsapp/src/main/res/drawable/waving_hand_24px.xml b/core/core-telecom/integration-tests/testicsapp/src/main/res/drawable/waving_hand_24px.xml
new file mode 100644
index 0000000..a5589ac
--- /dev/null
+++ b/core/core-telecom/integration-tests/testicsapp/src/main/res/drawable/waving_hand_24px.xml
@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:viewportWidth="960"
+    android:viewportHeight="960">
+  <path
+      android:fillColor="@android:color/white"
+      android:pathData="M430,460L713,177Q725,165 741,165Q757,165 769,177Q781,189 781,205Q781,221 769,233L487,516L430,460ZM529,559L783,304Q795,292 811.5,292Q828,292 840,304Q852,316 852,332.5Q852,349 840,361L586,615L529,559ZM211,749Q120,658 120,530Q120,402 211,311L331,191L390,250Q397,257 402,264.5Q407,272 412,280L560,131Q572,119 588.5,119Q605,119 617,131Q629,143 629,159.5Q629,176 617,188L444,361L444,361L359,445L378,464Q424,510 422,574Q420,638 373,685L373,685L316,629L316,629Q339,606 341.5,574.5Q344,543 321,520L274,474Q262,462 262,445.5Q262,429 274,417L331,361Q343,349 343,332.5Q343,316 331,304L331,304L267,368Q199,436 199,530.5Q199,625 267,693Q335,761 430,761Q525,761 593,693L832,453Q844,441 860.5,441Q877,441 889,453Q901,465 901,481.5Q901,498 889,510L649,749Q558,840 430,840Q302,840 211,749ZM430,530Q430,530 430,530Q430,530 430,530L430,530L430,530L430,530Q430,530 430,530Q430,530 430,530L430,530L430,530L430,530Q430,530 430,530Q430,530 430,530L430,530Q430,530 430,530Q430,530 430,530ZM680,921L680,840Q746,840 793,793Q840,746 840,680L921,680Q921,780 850.5,850.5Q780,921 680,921ZM39,280Q39,180 109.5,109.5Q180,39 280,39L280,120Q214,120 167,167Q120,214 120,280L39,280Z"/>
+</vector>
diff --git a/core/core-telecom/integration-tests/testicsapp/src/main/res/values/strings.xml b/core/core-telecom/integration-tests/testicsapp/src/main/res/values/strings.xml
new file mode 100644
index 0000000..53186db
--- /dev/null
+++ b/core/core-telecom/integration-tests/testicsapp/src/main/res/values/strings.xml
@@ -0,0 +1,20 @@
+<!--
+  Copyright 2024 The Android Open Source Project
+
+  Licensed under the Apache License, Version 2.0 (the "License");
+  you may not use this file except in compliance with the License.
+  You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+  -->
+
+<resources>
+    <string name="app_name">Telecom Test ICS</string>
+    <string name="main_activity_name">Jetpack ICS</string>
+</resources>
\ No newline at end of file
diff --git a/core/core-telecom/integration-tests/testicsapp/src/main/res/values/themes.xml b/core/core-telecom/integration-tests/testicsapp/src/main/res/values/themes.xml
new file mode 100644
index 0000000..eb98ba9
--- /dev/null
+++ b/core/core-telecom/integration-tests/testicsapp/src/main/res/values/themes.xml
@@ -0,0 +1,19 @@
+<!--
+  Copyright 2024 The Android Open Source Project
+
+  Licensed under the Apache License, Version 2.0 (the "License");
+  you may not use this file except in compliance with the License.
+  You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+  -->
+
+<resources xmlns:tools="http://schemas.android.com/tools">
+    <style name="Theme.IcsTest" parent="@android:style/Theme.Material.Light.NoActionBar"/>
+</resources>
\ No newline at end of file
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 d48aa50..630a3e9 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
@@ -252,6 +252,10 @@
                     val participants = CachedParticipants(this)
                     onConnected {
                         hasConnected = true
+                        assertTrue(
+                            "Participants are not supported",
+                            participants.extension.isSupported
+                        )
                         // Wait for initial state
                         participants.waitForParticipants(emptySet())
                         participants.waitForActiveParticipant(null)
diff --git a/core/core-telecom/src/main/java/androidx/core/telecom/extensions/CallExtensionScopeImpl.kt b/core/core-telecom/src/main/java/androidx/core/telecom/extensions/CallExtensionScopeImpl.kt
index 9374cdd..39674ef 100644
--- a/core/core-telecom/src/main/java/androidx/core/telecom/extensions/CallExtensionScopeImpl.kt
+++ b/core/core-telecom/src/main/java/androidx/core/telecom/extensions/CallExtensionScopeImpl.kt
@@ -16,9 +16,7 @@
 
 package androidx.core.telecom.extensions
 
-import android.Manifest
 import android.content.Context
-import android.content.pm.PackageManager
 import android.os.Build
 import android.os.Bundle
 import android.os.Handler
@@ -28,24 +26,26 @@
 import android.telecom.Call.Callback
 import android.telecom.InCallService
 import android.telecom.PhoneAccount
+import android.telecom.PhoneAccountHandle
 import android.telecom.TelecomManager
 import android.util.Log
 import androidx.annotation.IntDef
 import androidx.annotation.RequiresApi
 import androidx.annotation.VisibleForTesting
-import androidx.core.content.ContextCompat
 import androidx.core.telecom.CallsManager
 import androidx.core.telecom.internal.CapabilityExchangeListenerRemote
 import androidx.core.telecom.internal.utils.Utils
 import androidx.core.telecom.util.ExperimentalAppActions
 import java.util.Collections
 import kotlin.coroutines.resume
-import kotlin.coroutines.suspendCoroutine
 import kotlin.math.min
 import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.async
 import kotlinx.coroutines.cancel
 import kotlinx.coroutines.channels.awaitClose
 import kotlinx.coroutines.channels.trySendBlocking
+import kotlinx.coroutines.coroutineScope
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.callbackFlow
 import kotlinx.coroutines.flow.first
@@ -228,30 +228,19 @@
         if (Utils.hasPlatformV2Apis()) {
             // Android CallsManager V+ check
             if (details.hasProperty(CallsManager.PROPERTY_IS_TRANSACTIONAL)) {
+                Log.d(TAG, "resolveCallExtensionsType: PROPERTY_IS_TRANSACTIONAL present")
                 return CAPABILITY_EXCHANGE
             }
             // Android CallsManager U check
-            // Verify read phone numbers permission to see if phone account supports transactional
-            // ops.
-            if (
-                ContextCompat.checkSelfPermission(
-                    applicationContext,
-                    Manifest.permission.READ_PHONE_NUMBERS
-                ) == PackageManager.PERMISSION_GRANTED
-            ) {
-                val telecomManager =
-                    applicationContext.getSystemService(Context.TELECOM_SERVICE) as TelecomManager
-                val phoneAccount = telecomManager.getPhoneAccount(details.accountHandle)
-                if (
-                    phoneAccount?.hasCapabilities(
-                        PhoneAccount.CAPABILITY_SUPPORTS_TRANSACTIONAL_OPERATIONS
-                    ) == true
-                ) {
-                    return CAPABILITY_EXCHANGE
-                }
-            } else {
-                Log.i(TAG, "Unable to resolve call extension type due to lack of permission.")
+            val acct = getPhoneAccountIfAllowed(details.accountHandle)
+            if (acct == null) {
+                Log.d(TAG, "resolveCallExtensionsType: Unable to resolve PA")
                 type = UNKNOWN
+            } else if (
+                acct.hasCapabilities(PhoneAccount.CAPABILITY_SUPPORTS_TRANSACTIONAL_OPERATIONS)
+            ) {
+                Log.d(TAG, "resolveCallExtensionsType: PA supports transactional API")
+                return CAPABILITY_EXCHANGE
             }
         }
         // The extras may come in after the call is first signalled to InCallService - wait for the
@@ -274,21 +263,55 @@
         if (callExtras.containsKey(CallsManager.EXTRA_VOIP_BACKWARDS_COMPATIBILITY_SUPPORTED)) {
             return CAPABILITY_EXCHANGE
         }
-        Log.i(TAG, "Unable to resolve call extension type. Returning $type.")
+        Log.i(
+            TAG,
+            "resolveCallExtensionsType: Unable to resolve call extension type. " +
+                "Returning $type."
+        )
         return type
     }
 
+    private suspend fun getPhoneAccountIfAllowed(handle: PhoneAccountHandle): PhoneAccount? =
+        coroutineScope {
+            val telecomManager =
+                applicationContext.getSystemService(Context.TELECOM_SERVICE) as TelecomManager
+            async(Dispatchers.IO) {
+                    try {
+                        telecomManager.getPhoneAccount(handle)
+                    } catch (e: SecurityException) {
+                        Log.i(
+                            TAG,
+                            "getPhoneAccountIfAllowed: Unable to resolve call extension " +
+                                "type due to lack of permission."
+                        )
+                        null
+                    }
+                }
+                .await()
+        }
+
     /** Perform the operation to connect the extensions to the call. */
     internal suspend fun connectExtensionSession() {
         val type = resolveCallExtensionsType()
         Log.d(TAG, "connectExtensionsSession: type=$type")
-        // When we support EXTRAs, extensions should wrap this detail into a generic interface
-        val extensions = performExchangeWithRemote()
+        var extensions: CapabilityExchangeResult? = null
         try {
             when (type) {
-                CAPABILITY_EXCHANGE -> initializeExtensions(extensions)
-                else -> Log.w(TAG, "connectExtensions: unexpected type: $type")
+                CAPABILITY_EXCHANGE,
+                UNKNOWN -> {
+                    // When we support EXTRAs, extensions should wrap this detail into a generic
+                    // interface
+                    extensions = performExchangeWithRemote()
+                }
+                else -> {
+                    Log.w(
+                        TAG,
+                        "connectExtensions: unexpected type: $type. Proceeding with " +
+                            "no extension support"
+                    )
+                }
             }
+            initializeExtensions(extensions)
             invokeDelegate()
             waitForDestroy()
         } finally {
@@ -305,11 +328,11 @@
      * does not support extensions at all.
      */
     private suspend fun performExchangeWithRemote(): CapabilityExchangeResult? {
-        Log.d(TAG, "requestExtensions: requesting extensions from remote")
+        Log.d(TAG, "performExchangeWithRemote: requesting extensions from remote")
         val extensions =
             withTimeoutOrNull(CAPABILITY_EXCHANGE_TIMEOUT_MS) { registerWithRemoteService() }
         if (extensions == null) {
-            Log.w(TAG, "startCapabilityExchange: never received response")
+            Log.w(TAG, "performExchangeWithRemote: never received response")
         }
         return extensions
     }
@@ -356,7 +379,7 @@
      * @return the remote capabilities and Binder interface used to communicate with the remote
      */
     private suspend fun registerWithRemoteService(): CapabilityExchangeResult? =
-        suspendCoroutine { continuation ->
+        suspendCancellableCoroutine { continuation ->
             val binder =
                 object : ICapabilityExchange.Stub() {
                     override fun beginExchange(
diff --git a/core/core-telecom/src/main/java/androidx/core/telecom/extensions/ParticipantExtensionRemoteImpl.kt b/core/core-telecom/src/main/java/androidx/core/telecom/extensions/ParticipantExtensionRemoteImpl.kt
index 012e7bc..e264736 100644
--- a/core/core-telecom/src/main/java/androidx/core/telecom/extensions/ParticipantExtensionRemoteImpl.kt
+++ b/core/core-telecom/src/main/java/androidx/core/telecom/extensions/ParticipantExtensionRemoteImpl.kt
@@ -83,6 +83,7 @@
     // Maps a Capability to a receiver that allows the action to register itself with a listener
     // and then return a Receiver that gets called when Cap exchange completes.
     private val actionInitializers = HashMap<Int, ActionExchangeResult>()
+
     // Manages callbacks that are applicable to sub-actions of the Participants
     private val callbacks = ParticipantStateCallbackRepository()
 
@@ -152,6 +153,7 @@
             initializeNotSupportedActions()
             return
         }
+        isSupported = true
         Log.d(TAG, "onNegotiated: setup updates")
         initializeParticipantUpdates()
         initializeActionsLocally(negotiatedCapability)
diff --git a/credentials/credentials/api/current.txt b/credentials/credentials/api/current.txt
index b16b365..0e2e133 100644
--- a/credentials/credentials/api/current.txt
+++ b/credentials/credentials/api/current.txt
@@ -17,9 +17,9 @@
   }
 
   public abstract class CreateCredentialRequest {
-    method @Discouraged(message="It is recommended to construct a CreateCredentialRequest by directly instantiating a CreateCredentialRequest subclass") @RequiresApi(34) public static final androidx.credentials.CreateCredentialRequest createFrom(android.credentials.CreateCredentialRequest request);
-    method @Discouraged(message="It is recommended to construct a CreateCredentialRequest by directly instantiating a CreateCredentialRequest subclass") @RequiresApi(23) public static final androidx.credentials.CreateCredentialRequest createFrom(String type, android.os.Bundle credentialData, android.os.Bundle candidateQueryData, boolean requireSystemProvider);
-    method @Discouraged(message="It is recommended to construct a CreateCredentialRequest by directly instantiating a CreateCredentialRequest subclass") @RequiresApi(23) public static final androidx.credentials.CreateCredentialRequest createFrom(String type, android.os.Bundle credentialData, android.os.Bundle candidateQueryData, boolean requireSystemProvider, optional String? origin);
+    method @RequiresApi(34) public static final androidx.credentials.CreateCredentialRequest createFrom(android.credentials.CreateCredentialRequest request);
+    method @RequiresApi(23) public static final androidx.credentials.CreateCredentialRequest createFrom(String type, android.os.Bundle credentialData, android.os.Bundle candidateQueryData, boolean requireSystemProvider);
+    method @RequiresApi(23) public static final androidx.credentials.CreateCredentialRequest createFrom(String type, android.os.Bundle credentialData, android.os.Bundle candidateQueryData, boolean requireSystemProvider, optional String? origin);
     method public final android.os.Bundle getCandidateQueryData();
     method public final android.os.Bundle getCredentialData();
     method public final androidx.credentials.CreateCredentialRequest.DisplayInfo getDisplayInfo();
@@ -40,16 +40,16 @@
   }
 
   public static final class CreateCredentialRequest.Companion {
-    method @Discouraged(message="It is recommended to construct a CreateCredentialRequest by directly instantiating a CreateCredentialRequest subclass") @RequiresApi(34) public androidx.credentials.CreateCredentialRequest createFrom(android.credentials.CreateCredentialRequest request);
-    method @Discouraged(message="It is recommended to construct a CreateCredentialRequest by directly instantiating a CreateCredentialRequest subclass") @RequiresApi(23) public androidx.credentials.CreateCredentialRequest createFrom(String type, android.os.Bundle credentialData, android.os.Bundle candidateQueryData, boolean requireSystemProvider);
-    method @Discouraged(message="It is recommended to construct a CreateCredentialRequest by directly instantiating a CreateCredentialRequest subclass") @RequiresApi(23) public androidx.credentials.CreateCredentialRequest createFrom(String type, android.os.Bundle credentialData, android.os.Bundle candidateQueryData, boolean requireSystemProvider, optional String? origin);
+    method @RequiresApi(34) public androidx.credentials.CreateCredentialRequest createFrom(android.credentials.CreateCredentialRequest request);
+    method @RequiresApi(23) public androidx.credentials.CreateCredentialRequest createFrom(String type, android.os.Bundle credentialData, android.os.Bundle candidateQueryData, boolean requireSystemProvider);
+    method @RequiresApi(23) public androidx.credentials.CreateCredentialRequest createFrom(String type, android.os.Bundle credentialData, android.os.Bundle candidateQueryData, boolean requireSystemProvider, optional String? origin);
   }
 
   public static final class CreateCredentialRequest.DisplayInfo {
     ctor public CreateCredentialRequest.DisplayInfo(CharSequence userId);
     ctor public CreateCredentialRequest.DisplayInfo(CharSequence userId, optional CharSequence? userDisplayName);
     ctor public CreateCredentialRequest.DisplayInfo(CharSequence userId, CharSequence? userDisplayName, String? preferDefaultProvider);
-    method @Discouraged(message="It is recommended to construct a DisplayInfo by directly using the DisplayInfo constructor") @RequiresApi(23) public static androidx.credentials.CreateCredentialRequest.DisplayInfo createFrom(android.os.Bundle from);
+    method @RequiresApi(23) public static androidx.credentials.CreateCredentialRequest.DisplayInfo createFrom(android.os.Bundle from);
     method public CharSequence? getUserDisplayName();
     method public CharSequence getUserId();
     property public final CharSequence? userDisplayName;
@@ -58,7 +58,7 @@
   }
 
   public static final class CreateCredentialRequest.DisplayInfo.Companion {
-    method @Discouraged(message="It is recommended to construct a DisplayInfo by directly using the DisplayInfo constructor") @RequiresApi(23) public androidx.credentials.CreateCredentialRequest.DisplayInfo createFrom(android.os.Bundle from);
+    method @RequiresApi(23) public androidx.credentials.CreateCredentialRequest.DisplayInfo createFrom(android.os.Bundle from);
   }
 
   public abstract class CreateCredentialResponse {
@@ -139,8 +139,8 @@
   }
 
   public abstract class Credential {
-    method @Discouraged(message="It is recommended to construct a Credential by directly instantiating a Credential subclass") @RequiresApi(34) public static final androidx.credentials.Credential createFrom(android.credentials.Credential credential);
-    method @Discouraged(message="It is recommended to construct a Credential by directly instantiating a Credential subclass") public static final androidx.credentials.Credential createFrom(String type, android.os.Bundle data);
+    method @RequiresApi(34) public static final androidx.credentials.Credential createFrom(android.credentials.Credential credential);
+    method public static final androidx.credentials.Credential createFrom(String type, android.os.Bundle data);
     method public final android.os.Bundle getData();
     method public final String getType();
     property public final android.os.Bundle data;
@@ -149,8 +149,8 @@
   }
 
   public static final class Credential.Companion {
-    method @Discouraged(message="It is recommended to construct a Credential by directly instantiating a Credential subclass") @RequiresApi(34) public androidx.credentials.Credential createFrom(android.credentials.Credential credential);
-    method @Discouraged(message="It is recommended to construct a Credential by directly instantiating a Credential subclass") public androidx.credentials.Credential createFrom(String type, android.os.Bundle data);
+    method @RequiresApi(34) public androidx.credentials.Credential createFrom(android.credentials.Credential credential);
+    method public androidx.credentials.Credential createFrom(String type, android.os.Bundle data);
   }
 
   public interface CredentialManager {
@@ -184,8 +184,8 @@
   }
 
   public abstract class CredentialOption {
-    method @Discouraged(message="It is recommended to construct a CredentialOption by directly instantiating a CredentialOption subclass") @RequiresApi(34) public static final androidx.credentials.CredentialOption createFrom(android.credentials.CredentialOption option);
-    method @Discouraged(message="It is recommended to construct a CredentialOption by directly instantiating a CredentialOption subclass") public static final androidx.credentials.CredentialOption createFrom(String type, android.os.Bundle requestData, android.os.Bundle candidateQueryData, boolean requireSystemProvider, java.util.Set<android.content.ComponentName> allowedProviders);
+    method @RequiresApi(34) public static final androidx.credentials.CredentialOption createFrom(android.credentials.CredentialOption option);
+    method public static final androidx.credentials.CredentialOption createFrom(String type, android.os.Bundle requestData, android.os.Bundle candidateQueryData, boolean requireSystemProvider, java.util.Set<android.content.ComponentName> allowedProviders);
     method public final java.util.Set<android.content.ComponentName> getAllowedProviders();
     method public final android.os.Bundle getCandidateQueryData();
     method public final android.os.Bundle getRequestData();
@@ -208,8 +208,8 @@
   }
 
   public static final class CredentialOption.Companion {
-    method @Discouraged(message="It is recommended to construct a CredentialOption by directly instantiating a CredentialOption subclass") @RequiresApi(34) public androidx.credentials.CredentialOption createFrom(android.credentials.CredentialOption option);
-    method @Discouraged(message="It is recommended to construct a CredentialOption by directly instantiating a CredentialOption subclass") public androidx.credentials.CredentialOption createFrom(String type, android.os.Bundle requestData, android.os.Bundle candidateQueryData, boolean requireSystemProvider, java.util.Set<android.content.ComponentName> allowedProviders);
+    method @RequiresApi(34) public androidx.credentials.CredentialOption createFrom(android.credentials.CredentialOption option);
+    method public androidx.credentials.CredentialOption createFrom(String type, android.os.Bundle requestData, android.os.Bundle candidateQueryData, boolean requireSystemProvider, java.util.Set<android.content.ComponentName> allowedProviders);
   }
 
   public interface CredentialProvider {
@@ -245,13 +245,13 @@
     ctor public GetCredentialRequest(java.util.List<? extends androidx.credentials.CredentialOption> credentialOptions, optional String? origin, optional boolean preferIdentityDocUi);
     ctor public GetCredentialRequest(java.util.List<? extends androidx.credentials.CredentialOption> credentialOptions, optional String? origin, optional boolean preferIdentityDocUi, optional android.content.ComponentName? preferUiBrandingComponentName);
     ctor public GetCredentialRequest(java.util.List<? extends androidx.credentials.CredentialOption> credentialOptions, optional String? origin, optional boolean preferIdentityDocUi, optional android.content.ComponentName? preferUiBrandingComponentName, optional boolean preferImmediatelyAvailableCredentials);
-    method @Discouraged(message="It is recommended to construct a GetCredentialRequest by directly instantiating a GetCredentialRequest") @RequiresApi(34) public static androidx.credentials.GetCredentialRequest createFrom(android.credentials.GetCredentialRequest request);
-    method @Discouraged(message="It is recommended to construct a GetCredentialRequest by directly instantiating a GetCredentialRequest") public static androidx.credentials.GetCredentialRequest createFrom(java.util.List<? extends androidx.credentials.CredentialOption> credentialOptions, String? origin, android.os.Bundle metadata);
+    method @RequiresApi(34) public static androidx.credentials.GetCredentialRequest createFrom(android.credentials.GetCredentialRequest request);
+    method public static androidx.credentials.GetCredentialRequest createFrom(java.util.List<? extends androidx.credentials.CredentialOption> credentialOptions, String? origin, android.os.Bundle metadata);
     method public java.util.List<androidx.credentials.CredentialOption> getCredentialOptions();
     method public String? getOrigin();
     method public boolean getPreferIdentityDocUi();
     method public android.content.ComponentName? getPreferUiBrandingComponentName();
-    method @Discouraged(message="It should only be used by OEM services and library groups") public static android.os.Bundle getRequestMetadataBundle(androidx.credentials.GetCredentialRequest request);
+    method public static android.os.Bundle getRequestMetadataBundle(androidx.credentials.GetCredentialRequest request);
     method public boolean preferImmediatelyAvailableCredentials();
     property public final java.util.List<androidx.credentials.CredentialOption> credentialOptions;
     property public final String? origin;
@@ -273,9 +273,9 @@
   }
 
   public static final class GetCredentialRequest.Companion {
-    method @Discouraged(message="It is recommended to construct a GetCredentialRequest by directly instantiating a GetCredentialRequest") @RequiresApi(34) public androidx.credentials.GetCredentialRequest createFrom(android.credentials.GetCredentialRequest request);
-    method @Discouraged(message="It is recommended to construct a GetCredentialRequest by directly instantiating a GetCredentialRequest") public androidx.credentials.GetCredentialRequest createFrom(java.util.List<? extends androidx.credentials.CredentialOption> credentialOptions, String? origin, android.os.Bundle metadata);
-    method @Discouraged(message="It should only be used by OEM services and library groups") public android.os.Bundle getRequestMetadataBundle(androidx.credentials.GetCredentialRequest request);
+    method @RequiresApi(34) public androidx.credentials.GetCredentialRequest createFrom(android.credentials.GetCredentialRequest request);
+    method public androidx.credentials.GetCredentialRequest createFrom(java.util.List<? extends androidx.credentials.CredentialOption> credentialOptions, String? origin, android.os.Bundle metadata);
+    method public android.os.Bundle getRequestMetadataBundle(androidx.credentials.GetCredentialRequest request);
   }
 
   public final class GetCredentialResponse {
diff --git a/credentials/credentials/api/restricted_current.txt b/credentials/credentials/api/restricted_current.txt
index b16b365..0e2e133 100644
--- a/credentials/credentials/api/restricted_current.txt
+++ b/credentials/credentials/api/restricted_current.txt
@@ -17,9 +17,9 @@
   }
 
   public abstract class CreateCredentialRequest {
-    method @Discouraged(message="It is recommended to construct a CreateCredentialRequest by directly instantiating a CreateCredentialRequest subclass") @RequiresApi(34) public static final androidx.credentials.CreateCredentialRequest createFrom(android.credentials.CreateCredentialRequest request);
-    method @Discouraged(message="It is recommended to construct a CreateCredentialRequest by directly instantiating a CreateCredentialRequest subclass") @RequiresApi(23) public static final androidx.credentials.CreateCredentialRequest createFrom(String type, android.os.Bundle credentialData, android.os.Bundle candidateQueryData, boolean requireSystemProvider);
-    method @Discouraged(message="It is recommended to construct a CreateCredentialRequest by directly instantiating a CreateCredentialRequest subclass") @RequiresApi(23) public static final androidx.credentials.CreateCredentialRequest createFrom(String type, android.os.Bundle credentialData, android.os.Bundle candidateQueryData, boolean requireSystemProvider, optional String? origin);
+    method @RequiresApi(34) public static final androidx.credentials.CreateCredentialRequest createFrom(android.credentials.CreateCredentialRequest request);
+    method @RequiresApi(23) public static final androidx.credentials.CreateCredentialRequest createFrom(String type, android.os.Bundle credentialData, android.os.Bundle candidateQueryData, boolean requireSystemProvider);
+    method @RequiresApi(23) public static final androidx.credentials.CreateCredentialRequest createFrom(String type, android.os.Bundle credentialData, android.os.Bundle candidateQueryData, boolean requireSystemProvider, optional String? origin);
     method public final android.os.Bundle getCandidateQueryData();
     method public final android.os.Bundle getCredentialData();
     method public final androidx.credentials.CreateCredentialRequest.DisplayInfo getDisplayInfo();
@@ -40,16 +40,16 @@
   }
 
   public static final class CreateCredentialRequest.Companion {
-    method @Discouraged(message="It is recommended to construct a CreateCredentialRequest by directly instantiating a CreateCredentialRequest subclass") @RequiresApi(34) public androidx.credentials.CreateCredentialRequest createFrom(android.credentials.CreateCredentialRequest request);
-    method @Discouraged(message="It is recommended to construct a CreateCredentialRequest by directly instantiating a CreateCredentialRequest subclass") @RequiresApi(23) public androidx.credentials.CreateCredentialRequest createFrom(String type, android.os.Bundle credentialData, android.os.Bundle candidateQueryData, boolean requireSystemProvider);
-    method @Discouraged(message="It is recommended to construct a CreateCredentialRequest by directly instantiating a CreateCredentialRequest subclass") @RequiresApi(23) public androidx.credentials.CreateCredentialRequest createFrom(String type, android.os.Bundle credentialData, android.os.Bundle candidateQueryData, boolean requireSystemProvider, optional String? origin);
+    method @RequiresApi(34) public androidx.credentials.CreateCredentialRequest createFrom(android.credentials.CreateCredentialRequest request);
+    method @RequiresApi(23) public androidx.credentials.CreateCredentialRequest createFrom(String type, android.os.Bundle credentialData, android.os.Bundle candidateQueryData, boolean requireSystemProvider);
+    method @RequiresApi(23) public androidx.credentials.CreateCredentialRequest createFrom(String type, android.os.Bundle credentialData, android.os.Bundle candidateQueryData, boolean requireSystemProvider, optional String? origin);
   }
 
   public static final class CreateCredentialRequest.DisplayInfo {
     ctor public CreateCredentialRequest.DisplayInfo(CharSequence userId);
     ctor public CreateCredentialRequest.DisplayInfo(CharSequence userId, optional CharSequence? userDisplayName);
     ctor public CreateCredentialRequest.DisplayInfo(CharSequence userId, CharSequence? userDisplayName, String? preferDefaultProvider);
-    method @Discouraged(message="It is recommended to construct a DisplayInfo by directly using the DisplayInfo constructor") @RequiresApi(23) public static androidx.credentials.CreateCredentialRequest.DisplayInfo createFrom(android.os.Bundle from);
+    method @RequiresApi(23) public static androidx.credentials.CreateCredentialRequest.DisplayInfo createFrom(android.os.Bundle from);
     method public CharSequence? getUserDisplayName();
     method public CharSequence getUserId();
     property public final CharSequence? userDisplayName;
@@ -58,7 +58,7 @@
   }
 
   public static final class CreateCredentialRequest.DisplayInfo.Companion {
-    method @Discouraged(message="It is recommended to construct a DisplayInfo by directly using the DisplayInfo constructor") @RequiresApi(23) public androidx.credentials.CreateCredentialRequest.DisplayInfo createFrom(android.os.Bundle from);
+    method @RequiresApi(23) public androidx.credentials.CreateCredentialRequest.DisplayInfo createFrom(android.os.Bundle from);
   }
 
   public abstract class CreateCredentialResponse {
@@ -139,8 +139,8 @@
   }
 
   public abstract class Credential {
-    method @Discouraged(message="It is recommended to construct a Credential by directly instantiating a Credential subclass") @RequiresApi(34) public static final androidx.credentials.Credential createFrom(android.credentials.Credential credential);
-    method @Discouraged(message="It is recommended to construct a Credential by directly instantiating a Credential subclass") public static final androidx.credentials.Credential createFrom(String type, android.os.Bundle data);
+    method @RequiresApi(34) public static final androidx.credentials.Credential createFrom(android.credentials.Credential credential);
+    method public static final androidx.credentials.Credential createFrom(String type, android.os.Bundle data);
     method public final android.os.Bundle getData();
     method public final String getType();
     property public final android.os.Bundle data;
@@ -149,8 +149,8 @@
   }
 
   public static final class Credential.Companion {
-    method @Discouraged(message="It is recommended to construct a Credential by directly instantiating a Credential subclass") @RequiresApi(34) public androidx.credentials.Credential createFrom(android.credentials.Credential credential);
-    method @Discouraged(message="It is recommended to construct a Credential by directly instantiating a Credential subclass") public androidx.credentials.Credential createFrom(String type, android.os.Bundle data);
+    method @RequiresApi(34) public androidx.credentials.Credential createFrom(android.credentials.Credential credential);
+    method public androidx.credentials.Credential createFrom(String type, android.os.Bundle data);
   }
 
   public interface CredentialManager {
@@ -184,8 +184,8 @@
   }
 
   public abstract class CredentialOption {
-    method @Discouraged(message="It is recommended to construct a CredentialOption by directly instantiating a CredentialOption subclass") @RequiresApi(34) public static final androidx.credentials.CredentialOption createFrom(android.credentials.CredentialOption option);
-    method @Discouraged(message="It is recommended to construct a CredentialOption by directly instantiating a CredentialOption subclass") public static final androidx.credentials.CredentialOption createFrom(String type, android.os.Bundle requestData, android.os.Bundle candidateQueryData, boolean requireSystemProvider, java.util.Set<android.content.ComponentName> allowedProviders);
+    method @RequiresApi(34) public static final androidx.credentials.CredentialOption createFrom(android.credentials.CredentialOption option);
+    method public static final androidx.credentials.CredentialOption createFrom(String type, android.os.Bundle requestData, android.os.Bundle candidateQueryData, boolean requireSystemProvider, java.util.Set<android.content.ComponentName> allowedProviders);
     method public final java.util.Set<android.content.ComponentName> getAllowedProviders();
     method public final android.os.Bundle getCandidateQueryData();
     method public final android.os.Bundle getRequestData();
@@ -208,8 +208,8 @@
   }
 
   public static final class CredentialOption.Companion {
-    method @Discouraged(message="It is recommended to construct a CredentialOption by directly instantiating a CredentialOption subclass") @RequiresApi(34) public androidx.credentials.CredentialOption createFrom(android.credentials.CredentialOption option);
-    method @Discouraged(message="It is recommended to construct a CredentialOption by directly instantiating a CredentialOption subclass") public androidx.credentials.CredentialOption createFrom(String type, android.os.Bundle requestData, android.os.Bundle candidateQueryData, boolean requireSystemProvider, java.util.Set<android.content.ComponentName> allowedProviders);
+    method @RequiresApi(34) public androidx.credentials.CredentialOption createFrom(android.credentials.CredentialOption option);
+    method public androidx.credentials.CredentialOption createFrom(String type, android.os.Bundle requestData, android.os.Bundle candidateQueryData, boolean requireSystemProvider, java.util.Set<android.content.ComponentName> allowedProviders);
   }
 
   public interface CredentialProvider {
@@ -245,13 +245,13 @@
     ctor public GetCredentialRequest(java.util.List<? extends androidx.credentials.CredentialOption> credentialOptions, optional String? origin, optional boolean preferIdentityDocUi);
     ctor public GetCredentialRequest(java.util.List<? extends androidx.credentials.CredentialOption> credentialOptions, optional String? origin, optional boolean preferIdentityDocUi, optional android.content.ComponentName? preferUiBrandingComponentName);
     ctor public GetCredentialRequest(java.util.List<? extends androidx.credentials.CredentialOption> credentialOptions, optional String? origin, optional boolean preferIdentityDocUi, optional android.content.ComponentName? preferUiBrandingComponentName, optional boolean preferImmediatelyAvailableCredentials);
-    method @Discouraged(message="It is recommended to construct a GetCredentialRequest by directly instantiating a GetCredentialRequest") @RequiresApi(34) public static androidx.credentials.GetCredentialRequest createFrom(android.credentials.GetCredentialRequest request);
-    method @Discouraged(message="It is recommended to construct a GetCredentialRequest by directly instantiating a GetCredentialRequest") public static androidx.credentials.GetCredentialRequest createFrom(java.util.List<? extends androidx.credentials.CredentialOption> credentialOptions, String? origin, android.os.Bundle metadata);
+    method @RequiresApi(34) public static androidx.credentials.GetCredentialRequest createFrom(android.credentials.GetCredentialRequest request);
+    method public static androidx.credentials.GetCredentialRequest createFrom(java.util.List<? extends androidx.credentials.CredentialOption> credentialOptions, String? origin, android.os.Bundle metadata);
     method public java.util.List<androidx.credentials.CredentialOption> getCredentialOptions();
     method public String? getOrigin();
     method public boolean getPreferIdentityDocUi();
     method public android.content.ComponentName? getPreferUiBrandingComponentName();
-    method @Discouraged(message="It should only be used by OEM services and library groups") public static android.os.Bundle getRequestMetadataBundle(androidx.credentials.GetCredentialRequest request);
+    method public static android.os.Bundle getRequestMetadataBundle(androidx.credentials.GetCredentialRequest request);
     method public boolean preferImmediatelyAvailableCredentials();
     property public final java.util.List<androidx.credentials.CredentialOption> credentialOptions;
     property public final String? origin;
@@ -273,9 +273,9 @@
   }
 
   public static final class GetCredentialRequest.Companion {
-    method @Discouraged(message="It is recommended to construct a GetCredentialRequest by directly instantiating a GetCredentialRequest") @RequiresApi(34) public androidx.credentials.GetCredentialRequest createFrom(android.credentials.GetCredentialRequest request);
-    method @Discouraged(message="It is recommended to construct a GetCredentialRequest by directly instantiating a GetCredentialRequest") public androidx.credentials.GetCredentialRequest createFrom(java.util.List<? extends androidx.credentials.CredentialOption> credentialOptions, String? origin, android.os.Bundle metadata);
-    method @Discouraged(message="It should only be used by OEM services and library groups") public android.os.Bundle getRequestMetadataBundle(androidx.credentials.GetCredentialRequest request);
+    method @RequiresApi(34) public androidx.credentials.GetCredentialRequest createFrom(android.credentials.GetCredentialRequest request);
+    method public androidx.credentials.GetCredentialRequest createFrom(java.util.List<? extends androidx.credentials.CredentialOption> credentialOptions, String? origin, android.os.Bundle metadata);
+    method public android.os.Bundle getRequestMetadataBundle(androidx.credentials.GetCredentialRequest request);
   }
 
   public final class GetCredentialResponse {
diff --git a/credentials/credentials/src/main/java/androidx/credentials/CreateCredentialRequest.kt b/credentials/credentials/src/main/java/androidx/credentials/CreateCredentialRequest.kt
index fa9b7ee..a1fa61d 100644
--- a/credentials/credentials/src/main/java/androidx/credentials/CreateCredentialRequest.kt
+++ b/credentials/credentials/src/main/java/androidx/credentials/CreateCredentialRequest.kt
@@ -19,7 +19,6 @@
 import android.graphics.drawable.Icon
 import android.os.Bundle
 import android.text.TextUtils
-import androidx.annotation.Discouraged
 import androidx.annotation.RequiresApi
 import androidx.annotation.RestrictTo
 import androidx.credentials.PublicKeyCredential.Companion.BUNDLE_KEY_SUBTYPE
@@ -177,14 +176,15 @@
             /**
              * Returns a RequestDisplayInfo from a [CreateCredentialRequest.credentialData] Bundle.
              *
+             * It is recommended to construct a DisplayInfo by direct constructor calls, instead of
+             * using this API. This API should only be used by a small subset of system apps that
+             * reconstruct an existing object for user interactions such as collecting consents.
+             *
              * @param from the raw display data in the Bundle format, retrieved from
              *   [CreateCredentialRequest.credentialData]
              */
             @JvmStatic
             @RequiresApi(23) // Icon dependency
-            @Discouraged(
-                "It is recommended to construct a DisplayInfo by directly using the DisplayInfo constructor"
-            )
             fun createFrom(from: Bundle): DisplayInfo {
                 return try {
                     val displayInfoBundle = from.getBundle(BUNDLE_KEY_REQUEST_DISPLAY_INFO)!!
@@ -215,13 +215,15 @@
         /**
          * Parses the [request] into an instance of [CreateCredentialRequest].
          *
+         * It is recommended to construct a CreateCredentialRequest by directly instantiating a
+         * CreateCredentialRequest subclass, instead of using this API. This API should only be used
+         * by a small subset of system apps that reconstruct an existing object for user
+         * interactions such as collecting consents.
+         *
          * @param request the framework CreateCredentialRequest object
          */
         @JvmStatic
         @RequiresApi(34)
-        @Discouraged(
-            "It is recommended to construct a CreateCredentialRequest by directly instantiating a CreateCredentialRequest subclass"
-        )
         fun createFrom(
             request: android.credentials.CreateCredentialRequest
         ): CreateCredentialRequest {
@@ -238,6 +240,11 @@
          * Attempts to parse the raw data into one of [CreatePasswordRequest],
          * [CreatePublicKeyCredentialRequest], and [CreateCustomCredentialRequest].
          *
+         * It is recommended to construct a CreateCredentialRequest by directly instantiating a
+         * CreateCredentialRequest subclass, instead of using this API. This API should only be used
+         * by a small subset of system apps that reconstruct an existing object for user
+         * interactions such as collecting consents.
+         *
          * @param type matches [CreateCredentialRequest.type]
          * @param credentialData matches [CreateCredentialRequest.credentialData], the request data
          *   in the [Bundle] format; this should be constructed and retrieved from the a given
@@ -254,9 +261,6 @@
         @JvmStatic
         @JvmOverloads
         @RequiresApi(23)
-        @Discouraged(
-            "It is recommended to construct a CreateCredentialRequest by directly instantiating a CreateCredentialRequest subclass"
-        )
         fun createFrom(
             type: String,
             credentialData: Bundle,
diff --git a/credentials/credentials/src/main/java/androidx/credentials/Credential.kt b/credentials/credentials/src/main/java/androidx/credentials/Credential.kt
index 6f20564..6f92745 100644
--- a/credentials/credentials/src/main/java/androidx/credentials/Credential.kt
+++ b/credentials/credentials/src/main/java/androidx/credentials/Credential.kt
@@ -17,7 +17,6 @@
 package androidx.credentials
 
 import android.os.Bundle
-import androidx.annotation.Discouraged
 import androidx.annotation.RequiresApi
 import androidx.credentials.internal.FrameworkClassParsingException
 
@@ -40,15 +39,17 @@
         /**
          * Parses the raw data into an instance of [Credential].
          *
+         * It is recommended to construct a Credential by directly instantiating a Credential
+         * subclass, instead of using this API. This API should only be used by a small subset of
+         * system apps that reconstruct an existing object for user interactions such as collecting
+         * consents.
+         *
          * @param type matches [Credential.type], the credential type
          * @param data matches [Credential.data], the credential data in the [Bundle] format; this
          *   should be constructed and retrieved from the a given [Credential] itself and never be
          *   created from scratch
          */
         @JvmStatic
-        @Discouraged(
-            "It is recommended to construct a Credential by directly instantiating a Credential subclass"
-        )
         fun createFrom(type: String, data: Bundle): Credential {
             return try {
                 when (type) {
@@ -70,13 +71,15 @@
         /**
          * Parses the [credential] into an instance of [Credential].
          *
+         * It is recommended to construct a Credential by directly instantiating a Credential
+         * subclass, instead of using this API. This API should only be used by a small subset of
+         * system apps that reconstruct an existing object for user interactions such as collecting
+         * consents.
+         *
          * @param credential the framework Credential object
          */
         @JvmStatic
         @RequiresApi(34)
-        @Discouraged(
-            "It is recommended to construct a Credential by directly instantiating a Credential subclass"
-        )
         fun createFrom(credential: android.credentials.Credential): Credential {
             return createFrom(credential.type, credential.data)
         }
diff --git a/credentials/credentials/src/main/java/androidx/credentials/CredentialOption.kt b/credentials/credentials/src/main/java/androidx/credentials/CredentialOption.kt
index 89d293c..3c58088 100644
--- a/credentials/credentials/src/main/java/androidx/credentials/CredentialOption.kt
+++ b/credentials/credentials/src/main/java/androidx/credentials/CredentialOption.kt
@@ -18,7 +18,6 @@
 
 import android.content.ComponentName
 import android.os.Bundle
-import androidx.annotation.Discouraged
 import androidx.annotation.IntDef
 import androidx.annotation.RequiresApi
 import androidx.annotation.RestrictTo
@@ -120,13 +119,15 @@
         /**
          * Parses the [option] into an instance of [CredentialOption].
          *
+         * It is recommended to construct a CredentialOption by directly instantiating a
+         * CredentialOption subclass, instead of using this API. This API should only be used by a
+         * small subset of system apps that reconstruct an existing object for user interactions
+         * such as collecting consents.
+         *
          * @param option the framework CredentialOption object
          */
         @RequiresApi(34)
         @JvmStatic
-        @Discouraged(
-            "It is recommended to construct a CredentialOption by directly instantiating a CredentialOption subclass"
-        )
         fun createFrom(option: android.credentials.CredentialOption): CredentialOption {
             return createFrom(
                 option.type,
@@ -140,6 +141,11 @@
         /**
          * Parses the raw data into an instance of [CredentialOption].
          *
+         * It is recommended to construct a CredentialOption by directly instantiating a
+         * CredentialOption subclass, instead of using this API. This API should only be used by a
+         * small subset of system apps that reconstruct an existing object for user interactions
+         * such as collecting consents.
+         *
          * @param type matches [CredentialOption.type]
          * @param requestData matches [CredentialOption.requestData], the request data in the
          *   [Bundle] format; this should be constructed and retrieved from the a given
@@ -152,9 +158,6 @@
          *   provider is eligible
          */
         @JvmStatic
-        @Discouraged(
-            "It is recommended to construct a CredentialOption by directly instantiating a CredentialOption subclass"
-        )
         fun createFrom(
             type: String,
             requestData: Bundle,
diff --git a/credentials/credentials/src/main/java/androidx/credentials/GetCredentialRequest.kt b/credentials/credentials/src/main/java/androidx/credentials/GetCredentialRequest.kt
index 5f49e5e..ef20a75 100644
--- a/credentials/credentials/src/main/java/androidx/credentials/GetCredentialRequest.kt
+++ b/credentials/credentials/src/main/java/androidx/credentials/GetCredentialRequest.kt
@@ -18,7 +18,6 @@
 
 import android.content.ComponentName
 import android.os.Bundle
-import androidx.annotation.Discouraged
 import androidx.annotation.RequiresApi
 import androidx.credentials.internal.FrameworkClassParsingException
 
@@ -180,11 +179,12 @@
         /**
          * Returns the request metadata as a `Bundle`.
          *
+         * This API should only be used by OEM services and library groups.
+         *
          * Note: this is not the equivalent of the complete request itself. For example, it does not
          * include the request's `credentialOptions` or `origin`.
          */
         @JvmStatic
-        @Discouraged("It should only be used by OEM services and library groups")
         fun getRequestMetadataBundle(request: GetCredentialRequest): Bundle {
             val bundle = Bundle()
             bundle.putBoolean(BUNDLE_KEY_PREFER_IDENTITY_DOC_UI, request.preferIdentityDocUi)
@@ -202,13 +202,14 @@
         /**
          * Parses the [request] into an instance of [GetCredentialRequest].
          *
+         * It is recommended to construct a GetCredentialRequest by direct constructor calls,
+         * instead of using this API. This API should only be used by a small subset of system apps
+         * that reconstruct an existing object for user interactions such as collecting consents.
+         *
          * @param request the framework GetCredentialRequest object
          */
         @RequiresApi(34)
         @JvmStatic
-        @Discouraged(
-            "It is recommended to construct a GetCredentialRequest by directly instantiating a GetCredentialRequest"
-        )
         fun createFrom(request: android.credentials.GetCredentialRequest): GetCredentialRequest {
             return createFrom(
                 request.credentialOptions.map { CredentialOption.createFrom(it) },
@@ -220,14 +221,15 @@
         /**
          * Parses the raw data into an instance of [GetCredentialRequest].
          *
+         * It is recommended to construct a GetCredentialRequest by direct constructor calls,
+         * instead of using this API. This API should only be used by a small subset of system apps
+         * that reconstruct an existing object for user interactions such as collecting consents.
+         *
          * @param credentialOptions matches [GetCredentialRequest.credentialOptions]
          * @param origin matches [GetCredentialRequest.origin]
          * @param metadata request metadata serialized as a Bundle using [getRequestMetadataBundle]
          */
         @JvmStatic
-        @Discouraged(
-            "It is recommended to construct a GetCredentialRequest by directly instantiating a GetCredentialRequest"
-        )
         fun createFrom(
             credentialOptions: List<CredentialOption>,
             origin: String?,
diff --git a/datastore/datastore-benchmark/build.gradle b/datastore/datastore-benchmark/build.gradle
index 9cfb056..3fdc300 100644
--- a/datastore/datastore-benchmark/build.gradle
+++ b/datastore/datastore-benchmark/build.gradle
@@ -32,6 +32,7 @@
 
 dependencies {
     androidTestImplementation(project(":datastore:datastore-core"))
+    androidTestImplementation(project(":datastore:datastore-guava"))
     androidTestImplementation(project(":internal-testutils-datastore"))
     androidTestImplementation(libs.kotlinStdlib)
     androidTestImplementation(projectOrArtifact(":benchmark:benchmark-junit4"))
@@ -40,6 +41,7 @@
     androidTestImplementation(libs.testCore)
     androidTestImplementation(libs.testRunner)
     androidTestImplementation(libs.testRules)
+    androidTestImplementation(libs.truth)
     androidTestImplementation(libs.kotlinCoroutinesTest)
 }
 
diff --git a/datastore/datastore-benchmark/src/androidTest/java/androidx/datastore/core/GuavaDataStoreSingleProcessTest.java b/datastore/datastore-benchmark/src/androidTest/java/androidx/datastore/core/GuavaDataStoreSingleProcessTest.java
new file mode 100644
index 0000000..dd8a6bcc
--- /dev/null
+++ b/datastore/datastore-benchmark/src/androidTest/java/androidx/datastore/core/GuavaDataStoreSingleProcessTest.java
@@ -0,0 +1,135 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.datastore.core;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import androidx.benchmark.BenchmarkState;
+import androidx.benchmark.junit4.BenchmarkRule;
+import androidx.datastore.guava.GuavaDataStore;
+
+import com.google.common.util.concurrent.ListenableFuture;
+
+import org.junit.Assert;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+import java.io.File;
+
+public class GuavaDataStoreSingleProcessTest {
+    @Rule
+    public BenchmarkRule benchmarkRule = new BenchmarkRule();
+    @Rule
+    public TemporaryFolder tmp = new TemporaryFolder();
+
+    private static Byte incrementByte(Byte byteIn) {
+        return ++byteIn;
+    }
+
+    private static Byte sameValueByte(Byte byteIn) {
+        return byteIn;
+    }
+
+    @Test
+    public void testCreate() throws Exception {
+        BenchmarkState state = benchmarkRule.getState();
+        while (state.keepRunning()) {
+            File testFile = tmp.newFile();
+            GuavaDataStore<Byte> store = new GuavaDataStore.Builder<Byte>(
+                    new TestingSerializer(),
+                    () -> testFile
+            ).build();
+
+            state.pauseTiming();
+            Assert.assertNotNull(store);
+            state.resumeTiming();
+        }
+    }
+
+    @Test
+    public void testRead() throws Exception {
+        BenchmarkState state = benchmarkRule.getState();
+        File testFile = tmp.newFile();
+        GuavaDataStore<Byte> store = new GuavaDataStore.Builder<Byte>(
+                new TestingSerializer(),
+                () -> testFile
+        ).build();
+        ListenableFuture<Byte> updateFuture = store.updateDataAsync(
+                GuavaDataStoreSingleProcessTest::incrementByte
+        );
+        assertThat(updateFuture.get()).isEqualTo(1);
+
+        while (state.keepRunning()) {
+            Byte currentData = store.getDataAsync().get();
+
+            state.pauseTiming();
+            assertThat(currentData).isEqualTo(1);
+            state.resumeTiming();
+        }
+    }
+
+    @Test
+    public void testUpdate_withoutValueChange() throws Exception {
+        BenchmarkState state = benchmarkRule.getState();
+        File testFile = tmp.newFile();
+        GuavaDataStore<Byte> store = new GuavaDataStore.Builder<Byte>(
+                new TestingSerializer(),
+                () -> testFile
+        ).build();
+        ListenableFuture<Byte> updateFuture = store.updateDataAsync(
+                GuavaDataStoreSingleProcessTest::incrementByte
+        );
+        assertThat(updateFuture.get()).isEqualTo(1);
+
+        while (state.keepRunning()) {
+            Byte updatedData = store.updateDataAsync(
+                    GuavaDataStoreSingleProcessTest::sameValueByte).get();
+
+            state.pauseTiming();
+            assertThat(updatedData).isEqualTo(1);
+            state.resumeTiming();
+        }
+    }
+
+    @Test
+    public void testUpdate_withValueChange() throws Exception {
+        BenchmarkState state = benchmarkRule.getState();
+        File testFile = tmp.newFile();
+        byte counter = 0;
+        GuavaDataStore<Byte> store = new GuavaDataStore.Builder<Byte>(
+                new TestingSerializer(),
+                () -> testFile
+        ).build();
+        // first update creates the file
+        ListenableFuture<Byte> updateFuture = store.updateDataAsync(
+                GuavaDataStoreSingleProcessTest::incrementByte
+        );
+        counter++;
+        assertThat(updateFuture.get()).isEqualTo(counter);
+
+        while (state.keepRunning()) {
+            Byte updatedData = store.updateDataAsync(
+                    GuavaDataStoreSingleProcessTest::incrementByte).get();
+
+            state.pauseTiming();
+            counter++;
+            assertThat(updatedData).isEqualTo(counter);
+            state.resumeTiming();
+        }
+    }
+}
diff --git a/development/importMaven/src/main/kotlin/androidx/build/importMaven/KmpConfig.kt b/development/importMaven/src/main/kotlin/androidx/build/importMaven/KmpConfig.kt
index d9e2cb5..13283e7 100644
--- a/development/importMaven/src/main/kotlin/androidx/build/importMaven/KmpConfig.kt
+++ b/development/importMaven/src/main/kotlin/androidx/build/importMaven/KmpConfig.kt
@@ -27,6 +27,7 @@
             KonanTarget.WATCHOS_ARM64,
             KonanTarget.WATCHOS_SIMULATOR_ARM64,
             KonanTarget.WATCHOS_X64,
+            KonanTarget.WATCHOS_DEVICE_ARM64,
             KonanTarget.TVOS_ARM64,
             KonanTarget.TVOS_SIMULATOR_ARM64,
             KonanTarget.TVOS_X64,
diff --git a/docs-public/build.gradle b/docs-public/build.gradle
index 2cc4035..31c4e19 100644
--- a/docs-public/build.gradle
+++ b/docs-public/build.gradle
@@ -226,31 +226,32 @@
     docs("androidx.media2:media2-widget:1.3.0")
     docs("androidx.media:media:1.7.0")
     // androidx.media3 is not hosted in androidx
-    docsWithoutApiSince("androidx.media3:media3-cast:1.4.1")
-    docsWithoutApiSince("androidx.media3:media3-common:1.4.1")
-    docsWithoutApiSince("androidx.media3:media3-container:1.4.1")
-    docsWithoutApiSince("androidx.media3:media3-database:1.4.1")
-    docsWithoutApiSince("androidx.media3:media3-datasource:1.4.1")
-    docsWithoutApiSince("androidx.media3:media3-datasource-cronet:1.4.1")
-    docsWithoutApiSince("androidx.media3:media3-datasource-okhttp:1.4.1")
-    docsWithoutApiSince("androidx.media3:media3-datasource-rtmp:1.4.1")
-    docsWithoutApiSince("androidx.media3:media3-decoder:1.4.1")
-    docsWithoutApiSince("androidx.media3:media3-effect:1.4.1")
-    docsWithoutApiSince("androidx.media3:media3-exoplayer:1.4.1")
-    docsWithoutApiSince("androidx.media3:media3-exoplayer-dash:1.4.1")
-    docsWithoutApiSince("androidx.media3:media3-exoplayer-hls:1.4.1")
-    docsWithoutApiSince("androidx.media3:media3-exoplayer-ima:1.4.1")
-    docsWithoutApiSince("androidx.media3:media3-exoplayer-rtsp:1.4.1")
-    docsWithoutApiSince("androidx.media3:media3-exoplayer-smoothstreaming:1.4.1")
-    docsWithoutApiSince("androidx.media3:media3-exoplayer-workmanager:1.4.1")
-    docsWithoutApiSince("androidx.media3:media3-extractor:1.4.1")
-    docsWithoutApiSince("androidx.media3:media3-muxer:1.4.1")
-    docsWithoutApiSince("androidx.media3:media3-session:1.4.1")
-    docsWithoutApiSince("androidx.media3:media3-test-utils:1.4.1")
-    docsWithoutApiSince("androidx.media3:media3-test-utils-robolectric:1.4.1")
-    docsWithoutApiSince("androidx.media3:media3-transformer:1.4.1")
-    docsWithoutApiSince("androidx.media3:media3-ui:1.4.1")
-    docsWithoutApiSince("androidx.media3:media3-ui-leanback:1.4.1")
+    docsWithoutApiSince("androidx.media3:media3-cast:1.5.0-alpha01")
+    docsWithoutApiSince("androidx.media3:media3-common:1.5.0-alpha01")
+    docsWithoutApiSince("androidx.media3:media3-common-ktx:1.5.0-alpha01")
+    docsWithoutApiSince("androidx.media3:media3-container:1.5.0-alpha01")
+    docsWithoutApiSince("androidx.media3:media3-database:1.5.0-alpha01")
+    docsWithoutApiSince("androidx.media3:media3-datasource:1.5.0-alpha01")
+    docsWithoutApiSince("androidx.media3:media3-datasource-cronet:1.5.0-alpha01")
+    docsWithoutApiSince("androidx.media3:media3-datasource-okhttp:1.5.0-alpha01")
+    docsWithoutApiSince("androidx.media3:media3-datasource-rtmp:1.5.0-alpha01")
+    docsWithoutApiSince("androidx.media3:media3-decoder:1.5.0-alpha01")
+    docsWithoutApiSince("androidx.media3:media3-effect:1.5.0-alpha01")
+    docsWithoutApiSince("androidx.media3:media3-exoplayer:1.5.0-alpha01")
+    docsWithoutApiSince("androidx.media3:media3-exoplayer-dash:1.5.0-alpha01")
+    docsWithoutApiSince("androidx.media3:media3-exoplayer-hls:1.5.0-alpha01")
+    docsWithoutApiSince("androidx.media3:media3-exoplayer-ima:1.5.0-alpha01")
+    docsWithoutApiSince("androidx.media3:media3-exoplayer-rtsp:1.5.0-alpha01")
+    docsWithoutApiSince("androidx.media3:media3-exoplayer-smoothstreaming:1.5.0-alpha01")
+    docsWithoutApiSince("androidx.media3:media3-exoplayer-workmanager:1.5.0-alpha01")
+    docsWithoutApiSince("androidx.media3:media3-extractor:1.5.0-alpha01")
+    docsWithoutApiSince("androidx.media3:media3-muxer:1.5.0-alpha01")
+    docsWithoutApiSince("androidx.media3:media3-session:1.5.0-alpha01")
+    docsWithoutApiSince("androidx.media3:media3-test-utils:1.5.0-alpha01")
+    docsWithoutApiSince("androidx.media3:media3-test-utils-robolectric:1.5.0-alpha01")
+    docsWithoutApiSince("androidx.media3:media3-transformer:1.5.0-alpha01")
+    docsWithoutApiSince("androidx.media3:media3-ui:1.5.0-alpha01")
+    docsWithoutApiSince("androidx.media3:media3-ui-leanback:1.5.0-alpha01")
     docs("androidx.mediarouter:mediarouter:1.7.0")
     docs("androidx.mediarouter:mediarouter-testing:1.7.0")
     docs("androidx.metrics:metrics-performance:1.0.0-beta01")
diff --git a/docs-tip-of-tree/build.gradle b/docs-tip-of-tree/build.gradle
index 1719a47..ee86b2b 100644
--- a/docs-tip-of-tree/build.gradle
+++ b/docs-tip-of-tree/build.gradle
@@ -208,6 +208,7 @@
     kmpDocs(project(":ink:ink-geometry"))
     kmpDocs(project(":ink:ink-nativeloader"))
     kmpDocs(project(":ink:ink-strokes"))
+    kmpDocs(project(":ink:ink-rendering"))
     docs(project(":input:input-motionprediction"))
     docs(project(":interpolator:interpolator"))
     docs(project(":javascriptengine:javascriptengine"))
diff --git a/docs/benchmarking_images/filter_initial.png b/docs/benchmarking_images/filter_initial.png
deleted file mode 100644
index b538051..0000000
--- a/docs/benchmarking_images/filter_initial.png
+++ /dev/null
Binary files differ
diff --git a/docs/benchmarking_images/filter_metric.png b/docs/benchmarking_images/filter_metric.png
deleted file mode 100644
index 0e714fd..0000000
--- a/docs/benchmarking_images/filter_metric.png
+++ /dev/null
Binary files differ
diff --git a/docs/benchmarking_images/filter_test.png b/docs/benchmarking_images/filter_test.png
deleted file mode 100644
index cfc8a28..0000000
--- a/docs/benchmarking_images/filter_test.png
+++ /dev/null
Binary files differ
diff --git a/docs/benchmarking_images/query_first.png b/docs/benchmarking_images/query_first.png
new file mode 100644
index 0000000..a16fcd6
--- /dev/null
+++ b/docs/benchmarking_images/query_first.png
Binary files differ
diff --git a/docs/benchmarking_images/query_second.png b/docs/benchmarking_images/query_second.png
new file mode 100644
index 0000000..b4b5c7a
--- /dev/null
+++ b/docs/benchmarking_images/query_second.png
Binary files differ
diff --git a/docs/benchmarking_images/result_plot.png b/docs/benchmarking_images/result_plot.png
index d0b878e..d2f875d 100644
--- a/docs/benchmarking_images/result_plot.png
+++ b/docs/benchmarking_images/result_plot.png
Binary files differ
diff --git a/fragment/fragment/build.gradle b/fragment/fragment/build.gradle
index eff0092..01fbd329 100644
--- a/fragment/fragment/build.gradle
+++ b/fragment/fragment/build.gradle
@@ -40,7 +40,7 @@
     api("androidx.lifecycle:lifecycle-livedata-core:2.6.1")
     api("androidx.lifecycle:lifecycle-viewmodel:2.6.1")
     api("androidx.lifecycle:lifecycle-viewmodel-savedstate:2.6.1")
-    implementation("androidx.profileinstaller:profileinstaller:1.3.1")
+    implementation("androidx.profileinstaller:profileinstaller:1.4.0")
     api("androidx.savedstate:savedstate:1.2.1")
     api("androidx.annotation:annotation-experimental:1.4.1")
     api(libs.kotlinStdlib)
diff --git a/gradle/verification-keyring.keys b/gradle/verification-keyring.keys
index 4a0d0b9..de71d39 100644
--- a/gradle/verification-keyring.keys
+++ b/gradle/verification-keyring.keys
@@ -31,6 +31,45 @@
 =7AnO
 -----END PGP PUBLIC KEY BLOCK-----
 
+pub    81C27DE945332233
+sub    126BBD1EF340F4BD
+-----BEGIN PGP PUBLIC KEY BLOCK-----
+Version: BCPG v@RELEASE_NAME@
+
+mQMuBEvACI4RCACNKnrdBk54/vBJSu2RJCG3VXR1h5DPGxJR1QIXDylZC8g4XQoj
+0cE7Ij61hd1VYAWlzlrfXtHi80BIXoGza61OaA0pUCxTuLyAL22x0GmdNpGBkigz
+r58LnwaqTxmGwQk/1mjJID2lDzi+GeEtKZd2xmYg7thIYAaKTQaQAzShfsntIdtl
+8XH7X7qpze+CGqh1cQR89aJXjdaspj9hT4Bgj5EfQk3DKUXDXAD4lg5IP2mLGpeb
+MzBPdhdvF1L6nR+CbNgwCFdDwcqsYxaAc2gxFUAoX4U9p/rG4lfZ8ft3zkKv/N0c
+2bRTpMR3HCSk/Q6io6C8JfEynpTXE1HjG633AQCpTien60HqyCD5o8riRDOeSN8F
+Ib7CSH5l9A+eVcPWGQf/UuYeEjOPMl9RChB6ZpqmjwnXpDWlE8hVaWVOHp80CDuB
+gxEcsWJ62VCiXJPrC7fBrQo6rjaSioP8rT2Ge8CSESANVBFLtuyhpNFeek0SJGQ9
+0bCplP9cizGDCVKxiEH8Toi1ifzeFRQYjum22YElrhpcPQhf0PLIxUmllFHr1P+t
+f8jlfkf23IgdhAhUedVloh0oPS0epixPB098acZCRKPoYZzTXmEXjp5q+JGPE/Fh
+3Rlt0SGXEcrHILQThpGjn5xG9xzH+WNYz6kZ01CLpgUdNvB291C0lvsVq3r4U0lq
+eCZCNJ4JfCm3KYzAt603nlWILnmTynavaWDO2JYhXQf+OyuDPrOrYdkELczLKI4p
++1pEaVLQ8dZRL0W/XJ+RFMtmWyoQ4ypqlYkhxj+zslaG9UAOBwodIACMvlGrVHMi
+BaY0q+/9oZtMqEKN+jp8z/sYyDlOPFeDOU00vk7XU0v6Vy4Jm9ogSitlMRuvSQxr
+XGh1tDLbz+3MNyFjISdHxtc2gLqA74uAfIfI0Wwkx07xLwiI4y/GR6BB4oHVmytn
+R2jjTquLfbNXRwf/4x1GVj/V7ztszVqNa3n4LtDcK0cfXFfwJ7/skhuGmReyzxjA
+yTiSlbCf13zA6GLr1qZaJv3t8iSNA/TfP9NplbZoaROLhHSOp++GwplpbJFaBec2
+XrkCDQRLwAiOEAgA/CUGWGQsNucNS2dDPbk/lUx6MaFqRV+CcwAsCLUPCBQ22Crz
+T1OvwnybE+/wc9KxqBsGcQxg8STGP01nFEttn755O55Avk19kWP5EWVzndIqsjFb
+cInQHVAeglz6F0a/7SFmaznrKvWCeHwjawEbAQXkCd8ZPRqeP2RWpwcxjoxFJRhQ
+nVaEJ2u8XAU5RCrfsnfaRD/99NcCrE+5xfyWncF6n5FEmgVt9GQNyjYZPzO3Ikzj
+mpay0nboxpocDV3iTJe1aRBAZfUxP9R4mFMb0R7/6CP4YGKF4CnKCzUZFaD7aswh
+mi+VVVFOcyurT+VJrX7Iaxe/305JWqKy0G/W0wADBgf/TZE2OQJ2EC+Dqkk74I1e
+UPcVwsdDdzhVsRvjeyISg03nuTT/VZ/9qObCe69l1M4SfJNoqLm2QD8dUzWTBG7l
+SseL4HjMckapIql8Vy5lNhnnzOjk2znIvaL4CuS0vFSGaXC0FS52BhtbxHmUsHr7
+xgASj95Us7dp3C9oO/iD+k1rWpoD7QungfXP7HrXxzoFiYFZhcY+YMdM2K/pr+Ba
+z3vakSsNGU1vs96l94QWGUf681xGlyw2uyWRuNPWhMYgGSJ05LIAtm3vIcWfNp7r
+T14lVSSJCqEP8/f/50N5MeqBXEDrOO4lj1meUL5MK8Nxk6qEacYaMwxTpz5losxt
+7IhhBBgRCAAJBQJLwAiOAhsMAAoJEIHCfelFMyIzCE4A/iu1wm8vFwBxrqYWOO/c
+2BbjtX4tROpH2+P2Fk13iYkMAP4xCfyw10teMvj6fu3u9+H1XvC1AQZTqHnaG/8Y
+ss9xVg==
+=Zs5g
+-----END PGP PUBLIC KEY BLOCK-----
+
 pub    82B5574242C20D6F
 -----BEGIN PGP PUBLIC KEY BLOCK-----
 Version: BCPG v@RELEASE_NAME@
@@ -3675,6 +3714,42 @@
 =BZt3
 -----END PGP PUBLIC KEY BLOCK-----
 
+pub    E3706F202E9D239F
+uid    Kevin Galligan <[email protected]>
+
+sub    D78536E5591A9F98
+-----BEGIN PGP PUBLIC KEY BLOCK-----
+Version: BCPG v@RELEASE_NAME@
+
+mQENBFv2t+sBCAD0cnAam8BG5SU9U2raD+KzaZI2q16YPR0DJwTGoeYyD2eDgVJl
+p6rRxuOwMxXz+jqbgZRpXf3Obww6zD8w9fKZLoMRHOlUL2su+/9SDvTmfGPMLAJN
+4+mKkQQC7AorsCMJdOdj7qHI8yNTACVXfUvGFKsjBHfF/VDJJE/Epfe5TAWO+7wI
+SbUr4rwDBFKs+ImrKFuYJ1E/0fe4KI4o90wHix/dIlorCcLA6lVMEv7+4K1hKxI6
+n06ePVOkAvyd8fhF5vuilPSj7z/maKuJk4sxiDukDbhUP0Pi1sm+Q5CVhHxr1sMb
+vbuofW2LEDy9vXOH82Yx8cEorG3wuYPLUZQhABEBAAG0JEtldmluIEdhbGxpZ2Fu
+IDxrZ2FsbGlnYW5AZ21haWwuY29tPrkBDQRb9rfrAQgAwPWCdDqGjwiu/ZeqTnVy
++RjKz0WVLbto+oejZc6IpoV9tfhuPe0RQCaN/XQO7caydd6r3O/UPLIhM96n0M22
+fl8UlAB26ktPk/qB3P5/Ct7JwcvQL63JN/VeVkTokhIM7yRoTaN3v4hXxd3iKUqG
+tfJroTG8P2qAgYp6Ip9toobJjR+E2H2TqzhVpiQi1O3ckwJoswt/CcLvOAumNvNi
+vG7bscacdNbKFvgdF9c7X5pdz+tpIroGUTP4T+ks0JYg/mUb3vi6M5di4j/Th3Zq
+7LvxAsCQ4PnTgwMqOGsg1zr0tDUNrYGKKaDnO2GDFB4p0Qp4v850MLSdu1ikcR8z
+VwARAQABiQE8BBgBCAAmAhsMFiEEMTp3/l2aFlc/Qz1043BvIC6dI58FAl/rcWgF
+CQe3IH0ACgkQ43BvIC6dI5+U7ggA4b1RiObBRlUBMtHWSzW4kfll3DzeJ50wMI//
+teDUrUFver7cUgAL78TppHzSAop5OVdhGgQ4Pky1jOwut16DPYcyk9VFM+36Osi2
+br6/+BlerFTAPGwqA89J6Nn5Hp8RwRViuZETRDmMa37kzP+kyOnqpG9qyc9g7snx
+VyL9/pr+2nJUujqOz/wQJ3pVhQfdAWD4qxn3l2hbTZodjHA7xrFEA/Bjmws4FWmG
+YNEPWh5Y2xP6cIYLvvCWv0EfTpVecmxhGRuEQnVjNVHd+Cvj0kCQFA0crVqeua9o
+xdguiKIAQGZSY/FBfZSb/qlA2p4qOVcCyNZu/D6KfuZ7V/nHkIkBPAQYAQgAJhYh
+BDE6d/5dmhZXP0M9dONwbyAunSOfBQJb9rfrAhsMBQkDwmcAAAoJEONwbyAunSOf
+rjAH/AtAgoux6VBs14KqF3pHybeWeC8sRinetIPzVDS+tdWIkjQlumN8jtJ2yyDc
+gxaA9uWMBr2RHhCx025C2vdZS1tAzsl5nph2tmMSB23XxXw0nCesAtVCG13SdYbY
+GmqP9RlTFCkhCLcbTMTsruZ/RGY/pSQ94ZQUnN5j+/f580laNcBcWDQVxFigtbT6
+nD9sodquxSrNudDLMJtQKyPjp73t8WqRK2XIVXLti+wwRjRa/toOKxQ9WN5qJWB6
+UozXpk2LhYACdb0srg4k7gFESpaMveYkQYbpDyrg62NUvVAhOhWjorx9uZlIxukz
+oet48NTC5KcQaZnUl4QNhii5r5Y=
+=XJL9
+-----END PGP PUBLIC KEY BLOCK-----
+
 pub    E3822B59020A349D
 sub    9351716690874F25
 sub    60EB70DDAAC2EC21
@@ -4347,6 +4422,35 @@
 =Jcla
 -----END PGP PUBLIC KEY BLOCK-----
 
+pub    021E3BE573F727ED
+uid    Martin Grebac <[email protected]>
+
+sub    BDF1323BF318930C
+-----BEGIN PGP PUBLIC KEY BLOCK-----
+Version: BCPG v@RELEASE_NAME@
+
+mQENBE61V0EBCADCoY3yP7fLCiOl51HcW2AhJmsit4U3YHihnoiX2jieO0Xw5grz
+esFdx5tP8OTwh3YjEXp3Q2UzbAr25di3YhImVw+kLa+cpJPED0DNboR3zySjcehB
+/Z7ugel2MjZAUPMnRXYGCMhf4e4EufJDN7md86Ky8Fml/lImJvF+9wY1UrrroBLR
+wNqy5FheOoPIMsEV2u8fNGS2PiqJSTiSFzLwJvVxT7vR9C+ZuNWjCdYcYxpr6Drc
+fUpWDU/q0cOoHuZnIulBPo38GBF8lwELgJC9HxhqdYjXl+l7eZlbDFADNME0Mm25
+fcnnwWTJaBEn36ui70731cASsOMnfOxW2AVfABEBAAG0KE1hcnRpbiBHcmViYWMg
+PG1hcnRpbi5ncmViYWNAb3JhY2xlLmNvbT65AQ0ETrVXQQEIAOAsXQH0kFQhfz7Z
+rWQwjCdpE4vmmIQNHCK7lC0x7i+1BkWkdpoTmE5F7soh07zlIN4x+Ph3dw2FRAIu
+zQYb8ZO8ikGTCzkdVUa6yGYowBJArnrjQZYoTJWLENqAC47/e73s5JHo/mb2rF5R
+u19l4A/xJUZ6idjBNZ9pXVJxxmYdajA+B3Muft/dUKtEg1TjWXziAh5iqXqBG49Y
+G/TQdQeHPqaizhG2KeRSmPLkrBFI+ehj6qLBwf3S5KzFWrQGxbHJoVLSG32lJhzf
+fURysXoAlKEoF7BL1PShG8szMEe/x52xU2/na1x8dvhT4X60OMYS2Etd892+Zz8k
+ekIyOp8AEQEAAYkBHwQYAQIACQUCTrVXQQIbDAAKCRACHjvlc/cn7RUPCACbEFsB
+ZhSf3K3L5at+Od4dMuSmsIRT+GarrUg1gDNeCkA4qHI0xhDt+AyM4B0CzgoO0M08
+9xln4NVpvGM7GIt5DW0OlvSz1fJl+OU2tFyBiPV7/rNQh4eoEhQ9cXdEyKW/xSRJ
+7rPHXGXK0Ty1L3haQXtH3bvlVdgNjwM07VXHNhDhv/ibWvJcywX1zr3gRH30AmrG
+MASErCvzBz1m2953e8ijTGhKynjOFhJ/aHWT7QF/ugjDPILZP782fdiww7ByDuzh
+0FSvMv+4beiBt86Ky5STNQeePIWwPDJwTjktJ6fQm8J1VeBrs58JjmjbmRQ0Hs65
++zsMlK6ZweSFADZU
+=dWYO
+-----END PGP PUBLIC KEY BLOCK-----
+
 pub    02216ED811210DAA
 sub    8C40458A5F28CF7B
 -----BEGIN PGP PUBLIC KEY BLOCK-----
diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml
index efe3e56..1ab52b7 100644
--- a/gradle/verification-metadata.xml
+++ b/gradle/verification-metadata.xml
@@ -11,11 +11,6 @@
       </key-servers>
       <trusted-artifacts>
          <trust file="apiLevels.json" reason="We do not sign this metadata"/>
-         <trust group="androidx[.]annotation" version="1\.[0-7]\..*" regex="true" reason="Old versions, before signing"/>
-         <trust group="androidx[.]annotation" version="1\.9\.0-alpha[0-9][2-9]" regex="true" reason="New versions, not yet signed"/>
-         <trust group="androidx[.]collection" version="1\.[0-3]\..*" regex="true" reason="Old versions, before signing"/>
-         <trust group="androidx[.]collection" version="1\.5\.0-alpha[0-9][1-9]" regex="true" reason="Old versions, before signing"/>
-
          <trust group="com.android.ndk.thirdparty" reason="b/215430394"/>
          <trust group="com.android.tools" name="desugar_jdk_libs" reason="b/215430394"/>
          <trust group="com.android.tools" name="desugar_jdk_libs_configuration" reason="b/215430394"/>
@@ -59,6 +54,10 @@
          <trust group="^com[.]android($|([.].*))" version="8.0.0" regex="true" reason="old version, before signing"/>
          <trust group="^com[.]android($|([.].*))" version="8.1.0" regex="true" reason="old version, before signing"/>
          <trust group="^com[.]android($|([.].*))" version="8.5.0-alpha05" regex="true" reason="old version, before signing"/>
+         <trust group="androidx[.]annotation" version="1\.9\.0-alpha[0-9][2-9]" regex="true" reason="New versions, not yet signed"/>
+         <trust group="androidx[.]annotation" version="1\.[0-7]\..*" regex="true" reason="Old versions, before signing"/>
+         <trust group="androidx[.]collection" version="1\.5\.0-alpha[0-9][1-9]" regex="true" reason="Old versions, before signing"/>
+         <trust group="androidx[.]collection" version="1\.[0-3]\..*" regex="true" reason="Old versions, before signing"/>
       </trusted-artifacts>
       <trusted-keys>
          <trusted-key id="00089EE8C3AFA95A854D0F1DF800DD0933ECF7F7" group="com.google.guava"/>
@@ -157,6 +156,7 @@
          <trusted-key id="2E5B73C6EFD2EB453104C2EAE6EC76B4C6D3AE8E" group="com.google.protobuf"/>
          <trusted-key id="2E92113263FC31C74CCBAAB20E91C2DE43B72BB1" group="org.ec4j.core"/>
          <trusted-key id="3051D45031E13516A6E8FAFF280D66A55F5316C5" group="org.bitbucket.b_c"/>
+         <trusted-key id="313A77FE5D9A16573F433D74E3706F202E9D239F" group="co.touchlab"/>
          <trusted-key id="31BAE2E51D95E0F8AD9B7BCC40A3C4432BD7308C" group="com.googlecode.juniversalchardet"/>
          <trusted-key id="31FAE244A81D64507B47182E1B2718089CE964B8" group="com.thoughtworks.qdox"/>
          <trusted-key id="3288B8BE8512D6C0CA185268C51E6CBC7FF46F0B">
@@ -206,6 +206,7 @@
          <trusted-key id="4BF79B8259007B566D2FCE82296CD27F60EED12C" group="com.google.crypto.tink"/>
          <trusted-key id="4DADED739CDF2CD0E48E0EC44044EDF1BB73EFEA" group="jaxen" name="jaxen"/>
          <trusted-key id="4DB1A49729B053CAF015CEE9A6ADFC93EF34893E" group="org.hamcrest"/>
+         <trusted-key id="4F332021E20F59100C978257021E3BE573F727ED" group="net.java.dev.msv"/>
          <trusted-key id="4F7E32D440EF90A83011A8FC6425559C47CC79C4">
             <trusting group="com.sun.activation"/>
             <trusting group="javax.activation"/>
@@ -259,18 +260,19 @@
          <trusted-key id="6A814B1F869C2BBEAB7CB7271A2A1C94BDE89688" group="org.codehaus.plexus"/>
          <trusted-key id="6BDACA2C0493CCA133B372D09C4F7E9D98B1CC53" group="org.apache"/>
          <trusted-key id="6CB87B18A453990EAC9453F87D713008CC07E9AD" group="xerces" name="xercesImpl"/>
+         <trusted-key id="6D98490C6F1ACDDD448E45954F77679369475BAA" group="com.yarnpkg"/>
          <trusted-key id="6DD3B8C64EF75253BEB2C53AD908A43FB7EC07AC">
             <trusting group="com.sun.activation"/>
             <trusting group="jakarta.activation"/>
          </trusted-key>
          <trusted-key id="6EAD752B3E2B38E8E2236D7BA9321EDAA5CB3202" group="ch.randelshofer" name="fastdoubleparser"/>
          <trusted-key id="6F538074CCEBF35F28AF9B066A0975F8B1127B83">
-            <trusting group="org.jetbrains.kotlin"/>
-            <trusting group="org.jetbrains.kotlin.jvm"/>
-            <trusting group="org.jetbrains.kotlin.plugin.serialization"/>
             <trusting group="" name="kotlin-native-prebuilt-linux-x86_64"/>
             <trusting group="" name="kotlin-native-prebuilt-macos-aarch64"/>
             <trusting group="" name="kotlin-native-prebuilt-macos-x86_64"/>
+            <trusting group="org.jetbrains.kotlin"/>
+            <trusting group="org.jetbrains.kotlin.jvm"/>
+            <trusting group="org.jetbrains.kotlin.plugin.serialization"/>
          </trusted-key>
          <trusted-key id="6F656B7F6BFB238D38ACF81F3C27D97B0C83A85C" group="com.google.errorprone"/>
          <trusted-key id="6F7E5ACBCD02DB60DFD232E45E1F79A7C298661E">
@@ -289,7 +291,6 @@
             <trusting group="org.jvnet.staxex"/>
             <trusting group="^com[.]sun($|([.].*))" regex="true"/>
          </trusted-key>
-         <trusted-key id="6D98490C6F1ACDDD448E45954F77679369475BAA" group="com.yarnpkg"/>
          <trusted-key id="713DA88BE50911535FE716F5208B0AB1D63011C7" group="org.apache.tomcat" name="annotations-api"/>
          <trusted-key id="7186BBF993566D8C2F4F7ED7D945E643368FEF62" group="io.github.eisop"/>
          <trusted-key id="719F7C29985A8E95F58F47194D8159D6A1159B69" group="dev.zacsweers.moshix"/>
@@ -345,13 +346,14 @@
          </trusted-key>
          <trusted-key id="8B39C4ACE0F448789FE19C8BAC0E2034B1389C89" group="androidx.build.gradle.gcpbuildcache" name="gcpbuildcache"/>
          <trusted-key id="8DF3B0AA23ED78BE5233F6C2DEA3D207428EF16D" group="com.linkedin.dexmaker"/>
-         <trusted-key id="8E3A02905A1AE67E7B0F9ACD3967D4EDA591B991" group="org.jetbrains.kotlinx" name="kotlinx-html-jvm"/>
+         <trusted-key id="8E3A02905A1AE67E7B0F9ACD3967D4EDA591B991" group="org.jetbrains.kotlinx"/>
          <trusted-key id="8F9A3C6D105B9F57844A721D79E193516BE7998F" group="org.dom4j" name="dom4j"/>
          <trusted-key id="908366594E746BF3C449F5622BE5D98F751F4136" group="org.pcollections"/>
          <trusted-key id="90EE19787A7BCF6FD37A1E9180C08B1C29100955">
             <trusting group="com.jakewharton.android.repackaged"/>
             <trusting group="com.squareup" name="javawriter"/>
             <trusting group="com.squareup.retrofit2"/>
+            <trusting group="com.squareup.wire"/>
          </trusted-key>
          <trusted-key id="95115197C5227C0887299D000F9FE62F88E938D8" group="com.google.dagger"/>
          <trusted-key id="98465301A4939C0279F2E847D89D05374952262B" group="org.jetbrains.dokka"/>
@@ -387,6 +389,7 @@
             <trusting group="com.android"/>
             <trusting group="com.android.databinding"/>
             <trusting group="com.android.kotlin.multiplatform.library"/>
+            <trusting group="com.android.settings"/>
             <trusting group="com.android.tools"/>
             <trusting group="com.android.tools.analytics-library"/>
             <trusting group="com.android.tools.build"/>
@@ -398,7 +401,6 @@
             <trusting group="com.android.tools.layoutlib"/>
             <trusting group="com.android.tools.lint"/>
             <trusting group="com.android.tools.utp"/>
-            <trusting group="com.android.settings"/>
             <trusting group="^androidx\..*" regex="true"/>
          </trusted-key>
          <trusted-key id="A6D6C97108B8585F91B158748671A8DF71296252" group="^com[.]squareup($|([.].*))" regex="true"/>
@@ -474,7 +476,10 @@
          <trusted-key id="D066F5C471D32A00D244F99D6ED0F678B90EB06E" group="com.github.johnrengelman"/>
          <trusted-key id="D196A5E3E70732EEB2E5007F1861C322C56014B2" group="commons-lang"/>
          <trusted-key id="D433F9C895710DB8AB087FA6B7C3B43D18EAA8B7" group="org.codehaus.mojo"/>
-         <trusted-key id="D477D51812E692011DB11E66A6EA2E2BF22E0543" group="io.github.java-diff-utils"/>
+         <trusted-key id="D477D51812E692011DB11E66A6EA2E2BF22E0543">
+            <trusting group="com.github.jsqlparser"/>
+            <trusting group="io.github.java-diff-utils"/>
+         </trusted-key>
          <trusted-key id="D4C89EA4AAF455FD88B22087EFE8086F9E93774E" group="junit"/>
          <trusted-key id="D54A395B5CF3F86EB45F6E426B1B008864323B92" group="org.antlr"/>
          <trusted-key id="D5F46BC0B86AF5DC56DF58F05E975CB00C643DBF" group="com.google.inject"/>
@@ -505,6 +510,7 @@
          <trusted-key id="E01ED293981AE484403B65D7DA70BCBA6D76AD03" group="com.charleskorn.kaml"/>
          <trusted-key id="E0D98C5FD55A8AF232290E58DEE12B9896F97E34" group="org.pcollections"/>
          <trusted-key id="E2ACB037933CDEAAB7BF77D49A2C7A98E457C53D" group="org.springframework"/>
+         <trusted-key id="E376140124BED4E3C06D227581C27DE945332233" group="com.google.auto"/>
          <trusted-key id="E3A9F95079E84CE201F7CF60BEDE11EAF1164480" group="org.hamcrest"/>
          <trusted-key id="E62231331BCA7E1F292C9B88C1B12A5D99C0729D" group="org.jetbrains"/>
          <trusted-key id="E77417AC194160A3FABD04969A259C7EE636C5ED">
@@ -718,6 +724,11 @@
             <sha256 value="f4d85c3e4d411694337cb873abea09b242b664bb013320be6105327c45991537" origin="Generated by Gradle" reason="https://github.com/google/guava/issues/7154"/>
          </artifact>
       </component>
+      <component group="com.google.guava" name="guava" version="33.0.0-jre">
+         <artifact name="guava-33.0.0-android.jar">
+            <sha1 value="cfbbdc54f232feedb85746aeeea0722f5244bb9a" origin="Generated by Gradle" reason="https://github.com/google/guava/issues/7154"/>
+         </artifact>
+      </component>
       <component group="com.google.guava" name="guava" version="33.2.1-jre">
          <artifact name="guava-33.2.1-android.jar">
             <sha256 value="6b55fbe6ffee621454c03df7bea720d189789e136391a524e29506ff40654180" origin="Generated by Gradle" reason="https://github.com/google/guava/issues/7154"/>
@@ -934,6 +945,14 @@
             <sha256 value="06d119f29e22323371017da67d10c74a156b15f20d9c82116a57fee1454c6c68" origin="Generated by Gradle" reason="Artifact is not signed"/>
          </artifact>
       </component>
+      <component group="org.jetbrains.kotlinx" name="kotlinx-benchmark-plugin" version="0.4.11">
+         <artifact name="kotlinx-benchmark-plugin-0.4.11.jar">
+            <sha256 value="5c337e082137eb3cdf64b27b11e16b97e5dd0a7905aa9c2d7c8c323601e5c31b" origin="Generated by Gradle" reason="Artifact is not signed"/>
+         </artifact>
+         <artifact name="kotlinx-benchmark-plugin-0.4.11.module">
+            <sha256 value="af5e8d80674cd6f5bce92451296f3238c6524b880179d56f08187cef3de723ec" origin="Generated by Gradle" reason="Artifact is not signed"/>
+         </artifact>
+      </component>
       <component group="org.jetbrains.kotlinx" name="kotlinx-benchmark-plugin" version="0.4.8">
          <artifact name="kotlinx-benchmark-plugin-0.4.8.jar">
             <sha256 value="dcae0aabbae9374f6326e2dd26493dafaac0a7790d2d982a61fa3c779eea660c" origin="Generated by Gradle" reason="Artifact is not signed"/>
@@ -965,14 +984,6 @@
             <sha256 value="35428beab195a9a9df2afd6614ff1e4e4feac1a2c689006b1d649abe4a9391e7" origin="Generated by Gradle" reason="Artifact is not signed"/>
          </artifact>
       </component>
-      <component group="org.jetbrains.kotlinx" name="kotlinx-benchmark-plugin" version="0.4.11">
-         <artifact name="kotlinx-benchmark-plugin-0.4.11.jar">
-            <sha256 value="5c337e082137eb3cdf64b27b11e16b97e5dd0a7905aa9c2d7c8c323601e5c31b" origin="Generated by Gradle" reason="Artifact is not signed"/>
-         </artifact>
-         <artifact name="kotlinx-benchmark-plugin-0.4.11.module">
-            <sha256 value="af5e8d80674cd6f5bce92451296f3238c6524b880179d56f08187cef3de723ec" origin="Generated by Gradle" reason="Artifact is not signed"/>
-         </artifact>
-      </component>
       <component group="org.jetbrains.skiko" name="skiko" version="0.7.7">
          <artifact name="skiko-metadata-0.7.7-all.jar">
             <sha256 value="c0c39f941138dd193676a3b1c28b8a36b7433ec760b979c69699241bdecee4cb" origin="Generated by Gradle" reason="Artifact is not signed"/>
@@ -983,6 +994,17 @@
             <pgp value="FB35C8D02B4724DADA23DE0AFD116C1969FCCFF3"/>
          </artifact>
       </component>
+      <component group="org.nodejs" name="node" version="16.20.2">
+         <artifact name="node-16.20.2-darwin-arm64.tar.gz">
+            <sha256 value="6a5c4108475871362d742b988566f3fe307f6a67ce14634eb3fbceb4f9eea88c" origin="Generated by Node" reason="Artifact is not signed. Remove when https://github.com/nodejs/node/issues/53917 is resolved"/>
+         </artifact>
+         <artifact name="node-16.20.2-darwin-x64.tar.gz">
+            <sha256 value="d7a46eaf2b57ffddeda16ece0d887feb2e31a91ad33f8774da553da0249dc4a6" origin="Generated by Node" reason="Artifact is not signed. Remove when https://github.com/nodejs/node/issues/53917 is resolved"/>
+         </artifact>
+         <artifact name="node-16.20.2-linux-x64.tar.gz">
+            <sha256 value="c9193e6c414891694759febe846f4f023bf48410a6924a8b1520c46565859665" origin="Generated by Node" reason="Artifact is not signed. Remove when https://github.com/nodejs/node/issues/53917 is resolved"/>
+         </artifact>
+      </component>
       <component group="org.ow2" name="ow2" version="1.5">
          <artifact name="ow2-1.5.pom">
             <sha256 value="0f8a1b116e760b8fe6389c51b84e4b07a70fc11082d4f936e453b583dd50b43b" origin="Generated by Gradle" reason="Artifact is not signed"/>
@@ -1066,6 +1088,14 @@
             <sha256 value="4823677670797c2b71e8ebbe5437c41151f4e8edb7c6c0d473279715070f36d3" origin="Generated by Gradle" reason="Artifact is not signed"/>
          </artifact>
       </component>
+      <component group="relaxngDatatype" name="relaxngDatatype" version="20020414">
+         <artifact name="relaxngDatatype-20020414.jar">
+            <sha1 value="de7952cecd05b65e0e4370cc93fc03035175eef5" origin="Generated by relaxngDatatype" reason="Artifact from 2002. No SHA256"/>
+         </artifact>
+         <artifact name="relaxngDatatype-20020414.pom">
+            <sha1 value="4b062d8eb2bd190074fc686daea9531411b1e123" origin="Generated by relaxngDatatype" reason="Artifact from 2002. No SHA256"/>
+         </artifact>
+      </component>
       <component group="xmlpull" name="xmlpull" version="1.1.3.1">
          <artifact name="xmlpull-1.1.3.1.jar">
             <sha256 value="34e08ee62116071cbb69c0ed70d15a7a5b208d62798c59f2120bb8929324cb63" origin="Generated by Gradle" reason="Artifact is not signed"/>
@@ -1082,16 +1112,5 @@
             <sha256 value="4e54622f5dc0f8b6c51e28650268f001e3b55d076c8e3a9d9731c050820c0a3d" origin="Generated by Gradle" reason="Artifact is not signed"/>
          </artifact>
       </component>
-      <component group="org.nodejs" name="node" version="16.20.2">
-         <artifact name="node-16.20.2-darwin-arm64.tar.gz">
-            <sha256 value="6a5c4108475871362d742b988566f3fe307f6a67ce14634eb3fbceb4f9eea88c" origin="Generated by Node" reason="Artifact is not signed. Remove when https://github.com/nodejs/node/issues/53917 is resolved"/>
-         </artifact>
-         <artifact name="node-16.20.2-darwin-x64.tar.gz">
-            <sha256 value="d7a46eaf2b57ffddeda16ece0d887feb2e31a91ad33f8774da553da0249dc4a6" origin="Generated by Node" reason="Artifact is not signed. Remove when https://github.com/nodejs/node/issues/53917 is resolved"/>
-         </artifact>
-         <artifact name="node-16.20.2-linux-x64.tar.gz">
-            <sha256 value="c9193e6c414891694759febe846f4f023bf48410a6924a8b1520c46565859665" origin="Generated by Node" reason="Artifact is not signed. Remove when https://github.com/nodejs/node/issues/53917 is resolved"/>
-         </artifact>
-      </component>
    </components>
 </verification-metadata>
diff --git a/ink/ink-brush/src/jvmAndroidMain/kotlin/androidx/ink/brush/BrushBehavior.kt b/ink/ink-brush/src/jvmAndroidMain/kotlin/androidx/ink/brush/BrushBehavior.kt
index af765a0..9697d8c 100644
--- a/ink/ink-brush/src/jvmAndroidMain/kotlin/androidx/ink/brush/BrushBehavior.kt
+++ b/ink/ink-brush/src/jvmAndroidMain/kotlin/androidx/ink/brush/BrushBehavior.kt
@@ -145,7 +145,7 @@
                 val result = node as T
                 if (predicate(result)) return result
             }
-            stack.addAll(node.inputs())
+            stack.addAll(node.inputs)
         }
         return null
     }
@@ -346,6 +346,7 @@
 
     override fun equals(other: Any?): Boolean {
         if (other == null || other !is BrushBehavior) return false
+        if (other === this) return true
         return targetNodes == other.targetNodes
     }
 
@@ -368,7 +369,7 @@
         while (!stack.isEmpty()) {
             stack.removeLast().let { node ->
                 orderedNodes.addFirst(node)
-                stack.addAll(node.inputs())
+                stack.addAll(node.inputs)
             }
         }
 
@@ -1023,15 +1024,54 @@
         }
     }
 
+    /** Interpolation functions for use in an [InterpolationNode]. */
+    public class Interpolation private constructor(@JvmField internal val value: Int) {
+
+        internal fun toSimpleString(): String =
+            when (this) {
+                LERP -> "LERP"
+                INVERSE_LERP -> "INVERSE_LERP"
+                else -> "INVALID"
+            }
+
+        override fun toString(): String = PREFIX + this.toSimpleString()
+
+        override fun equals(other: Any?): Boolean {
+            if (other == null || other !is Interpolation) return false
+            return value == other.value
+        }
+
+        override fun hashCode(): Int = value.hashCode()
+
+        public companion object {
+            /**
+             * Linear interpolation. Evaluates to the [InterpolationNode.startInput] value when the
+             * [InterpolationNode.paramInput] value is 0, and to the [InterpolationNode.endInput]
+             * value when the [InterpolationNode.paramInput] value is 1.
+             */
+            @JvmField public val LERP: Interpolation = Interpolation(0)
+            /**
+             * Inverse linear interpolation. Evaluates to 0 when the [InterpolationNode.paramInput]
+             * value is equal to the [InterpolationNode.startInput] value, and to 1 when the
+             * parameter is equal to the [InterpolationNode.endInput] value. Evaluates to null when
+             * the [InterpolationNode.startInput] and [InterpolationNode.endInput] values are equal.
+             */
+            @JvmField public val INVERSE_LERP: Interpolation = Interpolation(1)
+
+            private const val PREFIX = "BrushBehavior.Interpolation."
+        }
+    }
+
     /**
      * Represents one node in a [BrushBehavior]'s expression graph. [Node] objects are immutable and
      * their inputs must be chosen at construction time; therefore, they can only ever be assembled
      * into an acyclic graph.
      */
-    public abstract class Node internal constructor() {
-        /** Returns the ordered list of inputs that this node directly depends on. */
-        public open fun inputs(): List<ValueNode> = emptyList()
-
+    public abstract class Node
+    internal constructor(
+        /** The ordered list of inputs that this node directly depends on. */
+        public val inputs: List<ValueNode>
+    ) {
         /** Appends a native version of this [Node] to a native [BrushBehavior]. */
         internal abstract fun appendToNativeBrushBehavior(nativeBehaviorPointer: Long)
     }
@@ -1040,7 +1080,7 @@
      * A [ValueNode] is a non-terminal node in the graph; it produces a value to be consumed as an
      * input by other [Node]s, and may itself depend on zero or more inputs.
      */
-    public abstract class ValueNode internal constructor() : Node() {}
+    public abstract class ValueNode internal constructor(inputs: List<ValueNode>) : Node(inputs) {}
 
     /** A [ValueNode] that gets data from the stroke input batch. */
     public class SourceNode
@@ -1050,7 +1090,7 @@
         public val sourceValueRangeLowerBound: Float,
         public val sourceValueRangeUpperBound: Float,
         public val sourceOutOfRangeBehavior: OutOfRange = OutOfRange.CLAMP,
-    ) : ValueNode() {
+    ) : ValueNode(emptyList()) {
         init {
             require(sourceValueRangeLowerBound.isFinite()) {
                 "sourceValueRangeLowerBound must be finite, was $sourceValueRangeLowerBound"
@@ -1104,7 +1144,7 @@
     }
 
     /** A [ValueNode] that produces a constant output value. */
-    public class ConstantNode constructor(public val value: Float) : ValueNode() {
+    public class ConstantNode constructor(public val value: Float) : ValueNode(emptyList()) {
         init {
             require(value.isFinite()) { "value must be finite, was $value" }
         }
@@ -1135,9 +1175,7 @@
      */
     public class FallbackFilterNode
     constructor(public val isFallbackFor: OptionalInputProperty, public val input: ValueNode) :
-        ValueNode() {
-        override fun inputs(): List<ValueNode> = listOf(input)
-
+        ValueNode(listOf(input)) {
         override fun appendToNativeBrushBehavior(nativeBehaviorPointer: Long) {
             nativeAppendFallbackFilterNode(nativeBehaviorPointer, isFallbackFor.value)
         }
@@ -1147,6 +1185,7 @@
 
         override fun equals(other: Any?): Boolean {
             if (other == null || other !is FallbackFilterNode) return false
+            if (other === this) return true
             return isFallbackFor == other.isFallbackFor && input == other.input
         }
 
@@ -1175,15 +1214,13 @@
         // The [enabledToolTypes] val below is a defensive copy of this parameter.
         enabledToolTypes: Set<InputToolType>,
         public val input: ValueNode,
-    ) : ValueNode() {
+    ) : ValueNode(listOf(input)) {
         public val enabledToolTypes: Set<InputToolType> = unmodifiableSet(enabledToolTypes.toSet())
 
         init {
             require(!enabledToolTypes.isEmpty()) { "enabledToolTypes must be non-empty" }
         }
 
-        override fun inputs(): List<ValueNode> = listOf(input)
-
         override fun appendToNativeBrushBehavior(nativeBehaviorPointer: Long) {
             nativeAppendToolTypeFilterNode(
                 nativeBehaviorPointer = nativeBehaviorPointer,
@@ -1198,6 +1235,7 @@
 
         override fun equals(other: Any?): Boolean {
             if (other == null || other !is ToolTypeFilterNode) return false
+            if (other === this) return true
             return enabledToolTypes == other.enabledToolTypes && input == other.input
         }
 
@@ -1229,15 +1267,13 @@
         public val dampingSource: DampingSource,
         public val dampingGap: Float,
         public val input: ValueNode,
-    ) : ValueNode() {
+    ) : ValueNode(listOf(input)) {
         init {
             require(dampingGap.isFinite() && dampingGap >= 0.0f) {
                 "dampingGap must be finite and non-negative, was $dampingGap"
             }
         }
 
-        override fun inputs(): List<ValueNode> = listOf(input)
-
         override fun appendToNativeBrushBehavior(nativeBehaviorPointer: Long) {
             nativeAppendDampingNode(nativeBehaviorPointer, dampingSource.value, dampingGap)
         }
@@ -1247,6 +1283,7 @@
 
         override fun equals(other: Any?): Boolean {
             if (other == null || other !is DampingNode) return false
+            if (other === this) return true
             return dampingSource == other.dampingSource &&
                 dampingGap == other.dampingGap &&
                 input == other.input
@@ -1271,9 +1308,7 @@
     /** A [ValueNode] that maps an input value through a response curve. */
     public class ResponseNode
     constructor(public val responseCurve: EasingFunction, public val input: ValueNode) :
-        ValueNode() {
-        override fun inputs(): List<ValueNode> = listOf(input)
-
+        ValueNode(listOf(input)) {
         override fun appendToNativeBrushBehavior(nativeBehaviorPointer: Long) {
             when (responseCurve) {
                 is EasingFunction.Predefined ->
@@ -1312,6 +1347,7 @@
 
         override fun equals(other: Any?): Boolean {
             if (other == null || other !is ResponseNode) return false
+            if (other === this) return true
             return responseCurve == other.responseCurve && input == other.input
         }
 
@@ -1372,9 +1408,7 @@
         public val operation: BinaryOp,
         public val firstInput: ValueNode,
         public val secondInput: ValueNode,
-    ) : ValueNode() {
-        override fun inputs(): List<ValueNode> = listOf(firstInput, secondInput)
-
+    ) : ValueNode(listOf(firstInput, secondInput)) {
         override fun appendToNativeBrushBehavior(nativeBehaviorPointer: Long) {
             nativeAppendBinaryOpNode(nativeBehaviorPointer, operation.value)
         }
@@ -1384,6 +1418,7 @@
 
         override fun equals(other: Any?): Boolean {
             if (other == null || other !is BinaryOpNode) return false
+            if (other === this) return true
             return operation == other.operation &&
                 firstInput == other.firstInput &&
                 secondInput == other.secondInput
@@ -1404,6 +1439,55 @@
     }
 
     /**
+     * A [ValueNode] that interpolates between two inputs based on a parameter input. The specific
+     * kind of interpolation performed depends on the [Interpolation] parameter.
+     */
+    public class InterpolationNode
+    constructor(
+        /** What kind of interpolation to perform. */
+        public val interpolation: Interpolation,
+        /** The input whose value is used as the parameter within the interpolation range. */
+        public val paramInput: ValueNode,
+        /** The input whose value forms the start of the interpolation range. */
+        public val startInput: ValueNode,
+        /** The input whose value forms the end of the interpolation range. */
+        public val endInput: ValueNode,
+    ) : ValueNode(listOf(paramInput, startInput, endInput)) {
+        override fun appendToNativeBrushBehavior(nativeBehaviorPointer: Long) {
+            nativeAppendInterpolationNode(nativeBehaviorPointer, interpolation.value)
+        }
+
+        override fun toString(): String =
+            "InterpolationNode(${interpolation.toSimpleString()}, $paramInput, $startInput, $endInput)"
+
+        override fun equals(other: Any?): Boolean {
+            if (other == null || other !is InterpolationNode) return false
+            if (other === this) return true
+            return interpolation == other.interpolation &&
+                paramInput == other.paramInput &&
+                startInput == other.startInput &&
+                endInput == other.endInput
+        }
+
+        override fun hashCode(): Int {
+            var result = interpolation.hashCode()
+            result = 31 * result + paramInput.hashCode()
+            result = 31 * result + startInput.hashCode()
+            result = 31 * result + endInput.hashCode()
+            return result
+        }
+
+        /**
+         * Appends a native `BrushBehavior::InterpolationNode` to a native brush behavior struct.
+         */
+        // TODO: b/355248266 - @Keep must go in Proguard config file instead.
+        private external fun nativeAppendInterpolationNode(
+            nativeBehaviorPointer: Long,
+            interpolation: Int,
+        )
+    }
+
+    /**
      * A [TargetNode] is a terminal node in the graph; it does not produce a value and cannot be
      * used as an input to other [Node]s, but instead applies a modification to the brush tip state.
      * A [BrushBehavior] consists of a list of [TargetNode]s and the various [ValueNode]s that they
@@ -1415,7 +1499,7 @@
         public val targetModifierRangeLowerBound: Float,
         public val targetModifierRangeUpperBound: Float,
         public val input: ValueNode,
-    ) : Node() {
+    ) : Node(listOf(input)) {
         init {
             require(targetModifierRangeLowerBound.isFinite()) {
                 "targetModifierRangeLowerBound must be finite, was $targetModifierRangeLowerBound"
@@ -1428,8 +1512,6 @@
             }
         }
 
-        override fun inputs(): List<ValueNode> = listOf(input)
-
         override fun appendToNativeBrushBehavior(nativeBehaviorPointer: Long) {
             nativeAppendTargetNode(
                 nativeBehaviorPointer,
@@ -1444,6 +1526,7 @@
 
         override fun equals(other: Any?): Boolean {
             if (other == null || other !is TargetNode) return false
+            if (other === this) return true
             return target == other.target &&
                 targetModifierRangeLowerBound == other.targetModifierRangeLowerBound &&
                 targetModifierRangeUpperBound == other.targetModifierRangeUpperBound &&
diff --git a/ink/ink-brush/src/jvmAndroidMain/kotlin/androidx/ink/brush/BrushPaint.kt b/ink/ink-brush/src/jvmAndroidMain/kotlin/androidx/ink/brush/BrushPaint.kt
index 002412f..4e3a1ab 100644
--- a/ink/ink-brush/src/jvmAndroidMain/kotlin/androidx/ink/brush/BrushPaint.kt
+++ b/ink/ink-brush/src/jvmAndroidMain/kotlin/androidx/ink/brush/BrushPaint.kt
@@ -208,6 +208,12 @@
                 SRC_ATOP -> "BrushPaint.BlendMode.SRC_ATOP"
                 SRC_IN -> "BrushPaint.BlendMode.SRC_IN"
                 SRC_OVER -> "BrushPaint.BlendMode.SRC_OVER"
+                DST_OVER -> "BrushPaint.BlendMode.DST_OVER"
+                SRC -> "BrushPaint.BlendMode.SRC"
+                DST -> "BrushPaint.BlendMode.DST"
+                SRC_OUT -> "BrushPaint.BlendMode.SRC_OUT"
+                DST_ATOP -> "BrushPaint.BlendMode.DST_ATOP"
+                XOR -> "BrushPaint.BlendMode.XOR"
                 else -> "BrushPaint.BlendMode.INVALID($value)"
             }
 
diff --git a/ink/ink-brush/src/jvmAndroidMain/kotlin/androidx/ink/brush/InputToolType.kt b/ink/ink-brush/src/jvmAndroidMain/kotlin/androidx/ink/brush/InputToolType.kt
index 90626ce..db2f8f5 100644
--- a/ink/ink-brush/src/jvmAndroidMain/kotlin/androidx/ink/brush/InputToolType.kt
+++ b/ink/ink-brush/src/jvmAndroidMain/kotlin/androidx/ink/brush/InputToolType.kt
@@ -21,8 +21,8 @@
 import kotlin.jvm.JvmStatic
 
 /**
- * The type of input tool used in producing [com.google.inputmethod.ink.strokes.StrokeInput], used
- * by [BrushBehavior] to define when a behavior is applicable.
+ * The type of input tool used in producing [androidx.ink.strokes.StrokeInput], used by
+ * [BrushBehavior] to define when a behavior is applicable.
  */
 @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) // PublicApiNotReadyForJetpackReview
 
diff --git a/ink/ink-brush/src/jvmAndroidTest/kotlin/androidx/ink/brush/BrushBehaviorTest.kt b/ink/ink-brush/src/jvmAndroidTest/kotlin/androidx/ink/brush/BrushBehaviorTest.kt
index 18d8afe..2b071ca 100644
--- a/ink/ink-brush/src/jvmAndroidTest/kotlin/androidx/ink/brush/BrushBehaviorTest.kt
+++ b/ink/ink-brush/src/jvmAndroidTest/kotlin/androidx/ink/brush/BrushBehaviorTest.kt
@@ -466,6 +466,42 @@
     }
 
     @Test
+    fun interpolationConstants_areDistinct() {
+        val list =
+            listOf<BrushBehavior.Interpolation>(
+                BrushBehavior.Interpolation.LERP,
+                BrushBehavior.Interpolation.INVERSE_LERP,
+            )
+        assertThat(list.toSet()).hasSize(list.size)
+    }
+
+    @Test
+    fun interpolationHashCode_withIdenticalValues_match() {
+        assertThat(BrushBehavior.Interpolation.LERP.hashCode())
+            .isEqualTo(BrushBehavior.Interpolation.LERP.hashCode())
+
+        assertThat(BrushBehavior.Interpolation.LERP.hashCode())
+            .isNotEqualTo(BrushBehavior.Interpolation.INVERSE_LERP.hashCode())
+    }
+
+    @Test
+    fun interpolationEquals_checksEqualityOfValues() {
+        assertThat(BrushBehavior.Interpolation.LERP).isEqualTo(BrushBehavior.Interpolation.LERP)
+
+        assertThat(BrushBehavior.Interpolation.LERP)
+            .isNotEqualTo(BrushBehavior.Interpolation.INVERSE_LERP)
+        assertThat(BrushBehavior.Interpolation.LERP).isNotEqualTo(null)
+    }
+
+    @Test
+    fun interpolationToString_returnsCorrectString() {
+        assertThat(BrushBehavior.Interpolation.LERP.toString())
+            .isEqualTo("BrushBehavior.Interpolation.LERP")
+        assertThat(BrushBehavior.Interpolation.INVERSE_LERP.toString())
+            .isEqualTo("BrushBehavior.Interpolation.INVERSE_LERP")
+    }
+
+    @Test
     fun sourceNodeConstructor_throwsForNonFiniteSourceValueRange() {
         assertFailsWith<IllegalArgumentException> {
             BrushBehavior.SourceNode(BrushBehavior.Source.NORMALIZED_PRESSURE, Float.NaN, 1f)
@@ -489,7 +525,7 @@
     @Test
     fun sourceNodeInputs_isEmpty() {
         val node = BrushBehavior.SourceNode(BrushBehavior.Source.NORMALIZED_PRESSURE, 0f, 1f)
-        assertThat(node.inputs()).isEmpty()
+        assertThat(node.inputs).isEmpty()
     }
 
     @Test
@@ -526,7 +562,7 @@
 
     @Test
     fun constantNodeInputs_isEmpty() {
-        assertThat(BrushBehavior.ConstantNode(42f).inputs()).isEmpty()
+        assertThat(BrushBehavior.ConstantNode(42f).inputs).isEmpty()
     }
 
     @Test
@@ -557,7 +593,7 @@
         val input = BrushBehavior.ConstantNode(0f)
         val node =
             BrushBehavior.FallbackFilterNode(BrushBehavior.OptionalInputProperty.PRESSURE, input)
-        assertThat(node.inputs()).containsExactly(input)
+        assertThat(node.inputs).containsExactly(input)
     }
 
     @Test
@@ -622,7 +658,7 @@
     fun toolTypeFilterNodeInputs_containsInput() {
         val input = BrushBehavior.ConstantNode(0f)
         val node = BrushBehavior.ToolTypeFilterNode(setOf(InputToolType.STYLUS), input)
-        assertThat(node.inputs()).containsExactly(input)
+        assertThat(node.inputs).containsExactly(input)
     }
 
     @Test
@@ -702,7 +738,7 @@
     fun dampingNodeInputs_containsInput() {
         val input = BrushBehavior.ConstantNode(0f)
         val node = BrushBehavior.DampingNode(BrushBehavior.DampingSource.TIME_IN_SECONDS, 1f, input)
-        assertThat(node.inputs()).containsExactly(input)
+        assertThat(node.inputs).containsExactly(input)
     }
 
     @Test
@@ -765,7 +801,7 @@
     fun responseNodeInputs_containsInput() {
         val input = BrushBehavior.ConstantNode(0f)
         val node = BrushBehavior.ResponseNode(EasingFunction.Predefined.EASE, input)
-        assertThat(node.inputs()).containsExactly(input)
+        assertThat(node.inputs).containsExactly(input)
     }
 
     @Test
@@ -823,7 +859,7 @@
         val firstInput = BrushBehavior.ConstantNode(0f)
         val secondInput = BrushBehavior.ConstantNode(1f)
         val node = BrushBehavior.BinaryOpNode(BrushBehavior.BinaryOp.SUM, firstInput, secondInput)
-        assertThat(node.inputs()).containsExactly(firstInput, secondInput).inOrder()
+        assertThat(node.inputs).containsExactly(firstInput, secondInput).inOrder()
     }
 
     @Test
@@ -884,6 +920,90 @@
     }
 
     @Test
+    fun interpolationNodeInputs_containsInputsInOrder() {
+        val paramInput = BrushBehavior.ConstantNode(0.5f)
+        val startInput = BrushBehavior.ConstantNode(0f)
+        val endInput = BrushBehavior.ConstantNode(1f)
+        val node =
+            BrushBehavior.InterpolationNode(
+                interpolation = BrushBehavior.Interpolation.LERP,
+                paramInput = paramInput,
+                startInput = startInput,
+                endInput = endInput,
+            )
+        assertThat(node.inputs).containsExactly(paramInput, startInput, endInput).inOrder()
+    }
+
+    @Test
+    fun interpolationNodeToString() {
+        val node =
+            BrushBehavior.InterpolationNode(
+                BrushBehavior.Interpolation.LERP,
+                BrushBehavior.ConstantNode(0.5f),
+                BrushBehavior.ConstantNode(0f),
+                BrushBehavior.ConstantNode(1f),
+            )
+        assertThat(node.toString())
+            .isEqualTo(
+                "InterpolationNode(LERP, ConstantNode(0.5), ConstantNode(0.0), ConstantNode(1.0))"
+            )
+    }
+
+    @Test
+    fun interpolationNodeEquals_checksEqualityOfValues() {
+        val node1 =
+            BrushBehavior.InterpolationNode(
+                BrushBehavior.Interpolation.LERP,
+                BrushBehavior.ConstantNode(0.5f),
+                BrushBehavior.ConstantNode(0f),
+                BrushBehavior.ConstantNode(1f),
+            )
+        val node2 =
+            BrushBehavior.InterpolationNode(
+                BrushBehavior.Interpolation.LERP,
+                BrushBehavior.ConstantNode(0.5f),
+                BrushBehavior.ConstantNode(0f),
+                BrushBehavior.ConstantNode(1f),
+            )
+        val node3 =
+            BrushBehavior.InterpolationNode(
+                BrushBehavior.Interpolation.LERP,
+                BrushBehavior.ConstantNode(0.5f),
+                BrushBehavior.ConstantNode(0f),
+                BrushBehavior.ConstantNode(2f),
+            )
+        assertThat(node1).isEqualTo(node2)
+        assertThat(node1).isNotEqualTo(node3)
+    }
+
+    @Test
+    fun interpolationNodeHashCode_withIdenticalValues_match() {
+        val node1 =
+            BrushBehavior.InterpolationNode(
+                BrushBehavior.Interpolation.LERP,
+                BrushBehavior.ConstantNode(0.5f),
+                BrushBehavior.ConstantNode(0f),
+                BrushBehavior.ConstantNode(1f),
+            )
+        val node2 =
+            BrushBehavior.InterpolationNode(
+                BrushBehavior.Interpolation.LERP,
+                BrushBehavior.ConstantNode(0.5f),
+                BrushBehavior.ConstantNode(0f),
+                BrushBehavior.ConstantNode(1f),
+            )
+        val node3 =
+            BrushBehavior.InterpolationNode(
+                BrushBehavior.Interpolation.LERP,
+                BrushBehavior.ConstantNode(0.5f),
+                BrushBehavior.ConstantNode(0f),
+                BrushBehavior.ConstantNode(2f),
+            )
+        assertThat(node1.hashCode()).isEqualTo(node2.hashCode())
+        assertThat(node1.hashCode()).isNotEqualTo(node3.hashCode())
+    }
+
+    @Test
     fun targetNodeConstructor_throwsForNonFiniteTargetModifierRange() {
         val input = BrushBehavior.ConstantNode(0f)
         assertFailsWith<IllegalArgumentException> {
@@ -911,7 +1031,7 @@
     fun targetNodeInputs_containsInput() {
         val input = BrushBehavior.ConstantNode(0f)
         val node = BrushBehavior.TargetNode(BrushBehavior.Target.SIZE_MULTIPLIER, 0f, 1f, input)
-        assertThat(node.inputs()).containsExactly(input)
+        assertThat(node.inputs).containsExactly(input)
     }
 
     @Test
diff --git a/ink/ink-geometry/src/androidMain/kotlin/androidx/ink/geometry/AndroidGraphicsConversionExtensions.android.kt b/ink/ink-geometry/src/androidMain/kotlin/androidx/ink/geometry/AndroidGraphicsConversionExtensions.android.kt
index 6efc190..6ff4706 100644
--- a/ink/ink-geometry/src/androidMain/kotlin/androidx/ink/geometry/AndroidGraphicsConversionExtensions.android.kt
+++ b/ink/ink-geometry/src/androidMain/kotlin/androidx/ink/geometry/AndroidGraphicsConversionExtensions.android.kt
@@ -20,7 +20,6 @@
 
 import android.graphics.Matrix
 import androidx.annotation.RestrictTo
-import androidx.ink.geometry.internal.getValue
 import androidx.ink.geometry.internal.threadLocal
 
 /** Scratch space to be used as the argument to [Matrix.getValues] and [Matrix.setValues]. */
diff --git a/ink/ink-geometry/src/jvmAndroidMain/kotlin/androidx/ink/geometry/PartitionedMesh.kt b/ink/ink-geometry/src/jvmAndroidMain/kotlin/androidx/ink/geometry/PartitionedMesh.kt
index 6f186f6..c6d218a 100644
--- a/ink/ink-geometry/src/jvmAndroidMain/kotlin/androidx/ink/geometry/PartitionedMesh.kt
+++ b/ink/ink-geometry/src/jvmAndroidMain/kotlin/androidx/ink/geometry/PartitionedMesh.kt
@@ -20,14 +20,13 @@
 import androidx.annotation.IntRange
 import androidx.annotation.RestrictTo
 import androidx.annotation.VisibleForTesting
-import androidx.ink.geometry.internal.getValue
 import androidx.ink.geometry.internal.threadLocal
 import androidx.ink.nativeloader.NativeLoader
 
 /**
  * An immutable** complex shape expressed as a set of triangles. This is used to represent the shape
  * of a stroke or other complex objects. The mesh may be divided into multiple partitions, which
- * enables certain brush effects (e.g. "multi-coat"), and allows strokes to be created using greater
+ * enables certain brush effects (e.g. "multi-coat"), and allows ink to create strokes using greater
  * than 2^16 triangles (which must be rendered in multiple passes).
  *
  * A [PartitionedMesh] may optionally have one or more "outlines", which are polylines that traverse
@@ -118,7 +117,7 @@
     @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) // NonPublicApi
     public fun renderGroupFormat(@IntRange(from = 0) groupIndex: Int): MeshFormat {
         require(groupIndex >= 0 && groupIndex < getRenderGroupCount()) {
-            "groupIndex=$groupIndex must be between 0 and renderGroupCount=${getRenderGroupCount()}"
+            "groupIndex=$groupIndex must be between 0 and getRenderGroupCount()=${getRenderGroupCount()}"
         }
         return MeshFormat(ModeledShapeNative.getRenderGroupFormat(nativeAddress, groupIndex))
     }
@@ -130,23 +129,23 @@
     @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) // NonPublicApi
     public fun renderGroupMeshes(@IntRange(from = 0) groupIndex: Int): List<Mesh> {
         require(groupIndex >= 0 && groupIndex < getRenderGroupCount()) {
-            "groupIndex=$groupIndex must be between 0 and renderGroupCount=${getRenderGroupCount()}"
+            "groupIndex=$groupIndex must be between 0 and getRenderGroupCount()=${getRenderGroupCount()}"
         }
         return meshesByGroup[groupIndex]
     }
 
-    /** Returns the number of outlines that comprise this shape. */
+    /** Returns the number of outlines that comprise the render group at [groupIndex]. */
     @IntRange(from = 0)
     public fun getOutlineCount(@IntRange(from = 0) groupIndex: Int): Int {
         require(groupIndex >= 0 && groupIndex < getRenderGroupCount()) {
-            "groupIndex=$groupIndex must be between 0 and renderGroupCount=${getRenderGroupCount()}"
+            "groupIndex=$groupIndex must be between 0 and getRenderGroupCount()=${getRenderGroupCount()}"
         }
         return ModeledShapeNative.getOutlineCount(nativeAddress, groupIndex).also { check(it >= 0) }
     }
 
     /**
-     * Returns the number of vertices that are in the outline at index [outlineIndex], and within
-     * the render group at [groupIndex].
+     * Returns the number of vertices that are in the outline at [outlineIndex] in the render group
+     * at [groupIndex].
      */
     @IntRange(from = 0)
     public fun getOutlineVertexCount(
@@ -154,7 +153,7 @@
         @IntRange(from = 0) outlineIndex: Int,
     ): Int {
         require(outlineIndex >= 0 && outlineIndex < getOutlineCount(groupIndex)) {
-            "outlineIndex=$outlineIndex must be between 0 and outlineCount=${getOutlineCount(groupIndex)}"
+            "outlineIndex=$outlineIndex must be between 0 and getOutlineCount=${getOutlineCount(groupIndex)}"
         }
         return ModeledShapeNative.getOutlineVertexCount(nativeAddress, groupIndex, outlineIndex)
             .also { check(it >= 0) }
@@ -248,7 +247,7 @@
     @FloatRange(from = 0.0, to = 1.0)
     public fun computeCoverage(
         box: Box,
-        boxToThis: AffineTransform = AffineTransform.IDENTITY
+        boxToThis: AffineTransform = AffineTransform.IDENTITY,
     ): Float =
         ModeledShapeNative.modeledShapeBoxCoverage(
             nativeAddress = nativeAddress,
@@ -381,7 +380,7 @@
      *
      * This is equivalent to:
      * ```
-     * this.coverage(box, boxToThis) > coverageThreshold
+     * computeCoverage(box, boxToThis) > coverageThreshold
      * ```
      *
      * but may be faster.
@@ -418,7 +417,7 @@
      *
      * This is equivalent to:
      * ```
-     * this.coverage(parallelogram, parallelogramToThis) > coverageThreshold
+     * computeCoverage(parallelogram, parallelogramToThis) > coverageThreshold
      * ```
      *
      * but may be faster.
@@ -458,7 +457,7 @@
      *
      * This is equivalent to:
      * ```
-     * this.coverage(other, otherShapeToThis) > coverageThreshold
+     * computeCoverage(other, otherShapeToThis) > coverageThreshold
      * ```
      *
      * but may be faster.
@@ -565,7 +564,7 @@
     )
 
     /**
-     * JNI method to construct C++ [ModeledShape] and [Triangle] objects and calculate coverage
+     * JNI method to construct C++ `ModeledShape` and `Triangle` objects and calculate coverage
      * using them.
      */
     // TODO: b/355248266 - @Keep must go in Proguard config file instead.
@@ -586,7 +585,7 @@
     ): Float
 
     /**
-     * JNI method to construct C++ [ModeledShape] and [Triangle] objects and calculate coverage
+     * JNI method to construct C++ `ModeledShape` and `Triangle` objects and calculate coverage
      * using them.
      */
     // TODO: b/355248266 - @Keep must go in Proguard config file instead.
@@ -605,8 +604,8 @@
     ): Float
 
     /**
-     * JNI method to construct C++ [ModeledShape] and [Parallelogram] objects and calculate coverage
-     * using them.
+     * JNI method to construct C++ `ModeledShape` and `Quad` objects and calculate coverage using
+     * them.
      */
     // TODO: b/355248266 - @Keep must go in Proguard config file instead.
     external fun modeledShapeParallelogramCoverage(
@@ -625,7 +624,7 @@
         parallelogramToThisTransformF: Float,
     ): Float
 
-    /** JNI method to construct C++ two [ModeledShape] objects and calculate coverage using them. */
+    /** JNI method to construct C++ two `ModeledShape` objects and calculate coverage using them. */
     // TODO: b/355248266 - @Keep must go in Proguard config file instead.
     external fun modeledShapeModeledShapeCoverage(
         thisShapeNativeAddress: Long,
@@ -639,8 +638,8 @@
     ): Float
 
     /**
-     * JNI method to construct C++ [ModeledShape] and [Triangle] objects and call native
-     * [coverageIsGreaterThan] on them.
+     * JNI method to construct C++ `ModeledShape` and `Triangle` objects and call native
+     * `CoverageIsGreaterThan` on them.
      */
     // TODO: b/355248266 - @Keep must go in Proguard config file instead.
     external fun modeledShapeTriangleCoverageIsGreaterThan(
@@ -661,8 +660,8 @@
     ): Boolean
 
     /**
-     * JNI method to construct C++ [ModeledShape] and [Box] objects and call native
-     * [coverageIsGreaterThan] on them.
+     * JNI method to construct C++ `ModeledShape` and `Rect` objects and call native
+     * `CoverageIsGreaterThan` on them.
      */
     // TODO: b/355248266 - @Keep must go in Proguard config file instead.
     external fun modeledShapeBoxCoverageIsGreaterThan(
@@ -681,8 +680,8 @@
     ): Boolean
 
     /**
-     * JNI method to construct C++ [ModeledShape] and [Parallelogram] objects and call native
-     * [coverageIsGreaterThan] on them.
+     * JNI method to construct C++ `ModeledShape` and `Quad` objects and call native
+     * `CoverageIsGreaterThan` on them.
      */
     // TODO: b/355248266 - @Keep must go in Proguard config file instead.
     external fun modeledShapeParallelogramCoverageIsGreaterThan(
@@ -703,8 +702,8 @@
     ): Boolean
 
     /**
-     * JNI method to construct two C++ [ModeledShape] objects and call native
-     * [coverageIsGreaterThan] on them.
+     * JNI method to construct two C++ `ModeledShape` objects and call native
+     * `CoverageIsGreaterThan` on them.
      */
     // TODO: b/355248266 - @Keep must go in Proguard config file instead.
     external fun modeledShapeModeledShapeCoverageIsGreaterThan(
diff --git a/ink/ink-geometry/src/jvmAndroidMain/kotlin/androidx/ink/geometry/internal/ThreadLocalDelegate.kt b/ink/ink-geometry/src/jvmAndroidMain/kotlin/androidx/ink/geometry/internal/ThreadLocalDelegate.kt
index af7bfed..9422c12 100644
--- a/ink/ink-geometry/src/jvmAndroidMain/kotlin/androidx/ink/geometry/internal/ThreadLocalDelegate.kt
+++ b/ink/ink-geometry/src/jvmAndroidMain/kotlin/androidx/ink/geometry/internal/ThreadLocalDelegate.kt
@@ -16,12 +16,10 @@
 
 package androidx.ink.geometry.internal
 
-import androidx.annotation.RestrictTo
 import kotlin.reflect.KProperty
 
 /**
- * Allows more convenient lambda syntax for declaring and initializing a [ThreadLocal]. Use with
- * `by` to treat it as a delegate and access its value implicitly.
+ * [ThreadLocal] subclass that can be used as a read-only delegate with the `by` operator.
  *
  * Example:
  * ```
@@ -30,18 +28,13 @@
  * foo.y = 6F
  * ```
  */
-@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
-public fun <T> threadLocal(initialValueProvider: () -> T): ThreadLocal<T> =
-    object : ThreadLocal<T>() {
-        override fun initialValue(): T = initialValueProvider()
-    }
+internal fun <T> threadLocal(initialValueProvider: () -> T): ThreadLocalDelegate<T> =
+    ThreadLocalDelegate(initialValueProvider)
 
-/**
- * Allows a [ThreadLocal] to act as a delegate, so a `ThreadLocal<T>` can act in code like a simple
- * `T` object. This method doesn't need to be called explicitly, as it is an operator for access.
- * See [threadLocal] for easier syntax for declaration and initialization, as well as for examples.
- */
-@Suppress("NOTHING_TO_INLINE")
-@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
-public inline operator fun <T> ThreadLocal<T>.getValue(thisObj: Any?, property: KProperty<*>): T =
-    get()!!
+internal class ThreadLocalDelegate<T> constructor(private val initialValueProvider: () -> T) :
+    ThreadLocal<T>() {
+    override fun initialValue(): T = initialValueProvider()
+
+    @Suppress("NOTHING_TO_INLINE")
+    inline operator fun getValue(thisObj: Any?, property: KProperty<*>): T = get()!!
+}
diff --git a/ink/ink-geometry/src/jvmAndroidTest/kotlin/androidx/ink/geometry/PartitionedMeshTest.kt b/ink/ink-geometry/src/jvmAndroidTest/kotlin/androidx/ink/geometry/PartitionedMeshTest.kt
index 50106fe..adf567e 100644
--- a/ink/ink-geometry/src/jvmAndroidTest/kotlin/androidx/ink/geometry/PartitionedMeshTest.kt
+++ b/ink/ink-geometry/src/jvmAndroidTest/kotlin/androidx/ink/geometry/PartitionedMeshTest.kt
@@ -38,6 +38,14 @@
     }
 
     @Test
+    fun computeBoundingBox_reusesAllocations() {
+        val partitionedMesh = buildTestStrokeShape()
+
+        val boundingBox = partitionedMesh.computeBoundingBox()
+        assertThat(partitionedMesh.computeBoundingBox()).isSameInstanceAs(boundingBox)
+    }
+
+    @Test
     fun getRenderGroupCount_whenEmptyShape_shouldBeZero() {
         val partitionedMesh = PartitionedMesh()
 
@@ -133,11 +141,11 @@
     }
 
     /**
-     * Verifies that [PartitionedMesh.coverage] calls the correct JNI method for [PartitionedMesh]
-     * and [Triangle].
+     * Verifies that [PartitionedMesh.computeCoverage] calls the correct JNI method for
+     * [PartitionedMesh] and [Triangle].
      */
     @Test
-    fun coverage_forPartitionedMeshAndTriangle_callsJniAndReturnsFloat() {
+    fun computeCoverage_forPartitionedMeshAndTriangle_callsJniAndReturnsFloat() {
         val partitionedMesh = buildTestStrokeShape()
         val intersectingTriangle =
             ImmutableTriangle(
@@ -158,11 +166,11 @@
     }
 
     /**
-     * Verifies that [PartitionedMesh.coverage] calls the correct JNI method for [PartitionedMesh]
-     * and [Box].
+     * Verifies that [PartitionedMesh.computeCoverage] calls the correct JNI method for
+     * [PartitionedMesh] and [Box].
      */
     @Test
-    fun coverage_forPartitionedMeshAndBox_callsJniAndReturnsFloat() {
+    fun computeCoverage_forPartitionedMeshAndBox_callsJniAndReturnsFloat() {
         val partitionedMesh = buildTestStrokeShape()
         val intersectingBox =
             ImmutableBox.fromTwoPoints(ImmutableVec(0f, 0f), ImmutableVec(100f, 100f))
@@ -175,11 +183,11 @@
     }
 
     /**
-     * Verifies that [PartitionedMesh.coverage] calls the correct JNI method for [PartitionedMesh]
-     * and [Parallelogram].
+     * Verifies that [PartitionedMesh.computeCoverage] calls the correct JNI method for
+     * [PartitionedMesh] and [Parallelogram].
      */
     @Test
-    fun coverage_forPartitionedMeshAndParallelogram_callsJniAndReturnsFloat() {
+    fun computeCoverage_forPartitionedMeshAndParallelogram_callsJniAndReturnsFloat() {
         val partitionedMesh = buildTestStrokeShape()
         val intersectingParallelogram =
             ImmutableParallelogram.fromCenterAndDimensions(
@@ -203,11 +211,11 @@
     }
 
     /**
-     * Verifies that [PartitionedMesh.coverage] calls the correct JNI method for two
+     * Verifies that [PartitionedMesh.computeCoverage] calls the correct JNI method for two
      * [PartitionedMesh]es.
      */
     @Test
-    fun coverage_forTwoPartitionedMeshes_callsJniAndReturnsFloat() {
+    fun computeCoverage_forTwoPartitionedMeshes_callsJniAndReturnsFloat() {
         val partitionedMesh = buildTestStrokeShape()
         val intersectingShape =
             Stroke(
@@ -228,11 +236,11 @@
     }
 
     /**
-     * Verifies that [PartitionedMesh.coverageIsGreaterThan] calls the correct JNI method for
+     * Verifies that [PartitionedMesh.computeCoverageIsGreaterThan] calls the correct JNI method for
      * [PartitionedMesh] and [Triangle].
      */
     @Test
-    fun coverageIsGreaterThan_forPartitionedMeshAndTriangle_callsJniAndReturnsFloat() {
+    fun computeCoverageIsGreaterThan_forPartitionedMeshAndTriangle_callsJniAndReturnsFloat() {
         val partitionedMesh = buildTestStrokeShape()
         val intersectingTriangle =
             ImmutableTriangle(
@@ -256,7 +264,7 @@
     }
 
     /**
-     * Verifies that [PartitionedMesh.coverageIsGreaterThan] calls the correct JNI method for
+     * Verifies that [PartitionedMesh.computeCoverageIsGreaterThan] calls the correct JNI method for
      * [PartitionedMesh] and [Box].
      *
      * For this test, `partitionedMesh` consists of triangulation of a straight line [Stroke] from
@@ -266,7 +274,7 @@
      * coverage of zero.
      */
     @Test
-    fun coverageIsGreaterThan_forPartitionedMeshAndBox_callsJniAndReturnsBoolean() {
+    fun computeCoverageIsGreaterThan_forPartitionedMeshAndBox_callsJniAndReturnsBoolean() {
         val partitionedMesh = buildTestStrokeShape()
         val intersectingBox =
             ImmutableBox.fromTwoPoints(ImmutableVec(10f, 3f), ImmutableVec(15f, 5f))
@@ -280,11 +288,11 @@
     }
 
     /**
-     * Verifies that [PartitionedMesh.coverageIsGreaterThan] calls the correct JNI method for
+     * Verifies that [PartitionedMesh.computeCoverageIsGreaterThan] calls the correct JNI method for
      * [PartitionedMesh] and [Parallelogram].
      */
     @Test
-    fun coverageIsGreaterThan_forPartitionedMeshAndParallelogram_callsJniAndReturnsBoolean() {
+    fun computeCoverageIsGreaterThan_forPartitionedMeshAndParallelogram_callsJniAndReturnsBoolean() {
         val partitionedMesh = buildTestStrokeShape()
         val intersectingParallelogram =
             ImmutableParallelogram.fromCenterAndDimensions(
@@ -316,7 +324,7 @@
     }
 
     /**
-     * Verifies that [PartitionedMesh.coverage] calls the correct JNI method for two
+     * Verifies that [PartitionedMesh.computeCoverage] calls the correct JNI method for two
      * [PartitionedMesh]s.
      *
      * For this test, `partitionedMesh` consists of triangulation of a straight line [Stroke] from
@@ -327,7 +335,7 @@
      * `partitionedMesh`, and has zero coverage.
      */
     @Test
-    fun coverageIsGreaterThan_forTwoPartitionedMeshes_callsJniAndReturnsBoolean() {
+    fun computeCoverageIsGreaterThan_forTwoPartitionedMeshes_callsJniAndReturnsBoolean() {
         val partitionedMesh = buildTestStrokeShape()
         val intersectingShape =
             Stroke(
diff --git a/ink/ink-rendering/api/current.txt b/ink/ink-rendering/api/current.txt
new file mode 100644
index 0000000..e6f50d0
--- /dev/null
+++ b/ink/ink-rendering/api/current.txt
@@ -0,0 +1 @@
+// Signature format: 4.0
diff --git a/ink/ink-rendering/api/res-current.txt b/ink/ink-rendering/api/res-current.txt
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/ink/ink-rendering/api/res-current.txt
diff --git a/ink/ink-rendering/api/restricted_current.txt b/ink/ink-rendering/api/restricted_current.txt
new file mode 100644
index 0000000..e6f50d0
--- /dev/null
+++ b/ink/ink-rendering/api/restricted_current.txt
@@ -0,0 +1 @@
+// Signature format: 4.0
diff --git a/ink/ink-rendering/build.gradle b/ink/ink-rendering/build.gradle
new file mode 100644
index 0000000..e7df440
--- /dev/null
+++ b/ink/ink-rendering/build.gradle
@@ -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.
+ */
+
+import androidx.build.LibraryType
+import androidx.build.PlatformIdentifier
+
+plugins {
+  id("AndroidXPlugin")
+  id("com.android.library")
+}
+
+androidXMultiplatform {
+  android()
+
+  defaultPlatform(PlatformIdentifier.ANDROID)
+
+  sourceSets {
+
+    androidMain {
+      dependencies {
+        implementation("androidx.collection:collection:1.4.3")
+        implementation(project(":core:core"))
+        implementation(project(":ink:ink-nativeloader"))
+        implementation(project(":ink:ink-geometry"))
+        implementation(project(":ink:ink-brush"))
+        implementation(project(":ink:ink-strokes"))
+      }
+    }
+
+    androidInstrumentedTest {
+      dependencies {
+        implementation(libs.testExtJunit)
+        implementation(libs.testRules)
+        implementation(libs.testRunner)
+        implementation(libs.espressoCore)
+        implementation(libs.junit)
+        implementation(libs.truth)
+	implementation(project(":test:screenshot:screenshot"))
+      }
+    }
+  }
+}
+
+android {
+  namespace = "androidx.ink.rendering"
+  compileSdk = 35
+  sourceSets.androidTest.assets.srcDirs +=
+      project.rootDir.absolutePath + "/../../golden/ink/ink-rendering"
+}
+
+androidx {
+    name = "Ink Rendering"
+    type = LibraryType.PUBLISHED_LIBRARY
+    inceptionYear = "2024"
+    description = "Display beautiful strokes"
+}
diff --git a/ink/ink-rendering/src/androidInstrumentedTest/AndroidManifest.xml b/ink/ink-rendering/src/androidInstrumentedTest/AndroidManifest.xml
new file mode 100644
index 0000000..648a857
--- /dev/null
+++ b/ink/ink-rendering/src/androidInstrumentedTest/AndroidManifest.xml
@@ -0,0 +1,27 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+Copyright (C) 2024 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+  <application>
+    <activity
+        android:name="androidx.ink.rendering.android.canvas.CanvasStrokeRendererTestActivity"/>
+    <activity
+        android:name="androidx.ink.rendering.android.canvas.internal.CanvasMeshRendererScreenshotTestActivity"/>
+    <activity
+        android:name="androidx.ink.rendering.android.view.ViewStrokeRendererTestActivity"/>
+  </application>
+</manifest>
+
diff --git a/ink/ink-rendering/src/androidInstrumentedTest/kotlin/androidx/ink/rendering/android/canvas/CanvasStrokeRendererTest.kt b/ink/ink-rendering/src/androidInstrumentedTest/kotlin/androidx/ink/rendering/android/canvas/CanvasStrokeRendererTest.kt
new file mode 100644
index 0000000..14c0c57
--- /dev/null
+++ b/ink/ink-rendering/src/androidInstrumentedTest/kotlin/androidx/ink/rendering/android/canvas/CanvasStrokeRendererTest.kt
@@ -0,0 +1,596 @@
+/*
+ * 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.ink.rendering.android.canvas
+
+import androidx.annotation.ColorInt
+import androidx.core.graphics.ColorUtils
+import androidx.ink.brush.Brush
+import androidx.ink.brush.BrushBehavior
+import androidx.ink.brush.BrushCoat
+import androidx.ink.brush.BrushFamily
+import androidx.ink.brush.BrushPaint
+import androidx.ink.brush.BrushPaint.BlendMode
+import androidx.ink.brush.BrushPaint.TextureOrigin
+import androidx.ink.brush.BrushPaint.TextureSizeUnit
+import androidx.ink.brush.BrushTip
+import androidx.ink.brush.ExperimentalInkCustomBrushApi
+import androidx.ink.brush.InputToolType
+import androidx.ink.brush.StockBrushes
+import androidx.ink.geometry.Angle
+import androidx.ink.rendering.test.R
+import androidx.ink.strokes.ImmutableStrokeInputBatch
+import androidx.ink.strokes.InProgressStroke
+import androidx.ink.strokes.MutableStrokeInputBatch
+import androidx.test.espresso.Espresso.onView
+import androidx.test.espresso.action.ViewActions.captureToBitmap
+import androidx.test.espresso.matcher.ViewMatchers.withId
+import androidx.test.ext.junit.rules.ActivityScenarioRule
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.MediumTest
+import androidx.test.screenshot.AndroidXScreenshotTestRule
+import androidx.test.screenshot.assertAgainstGolden
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/** Emulator-based screenshot test of [CanvasStrokeRenderer] for Stroke and InProgressStroke. */
+@OptIn(ExperimentalInkCustomBrushApi::class)
+@RunWith(AndroidJUnit4::class)
+@MediumTest
+class CanvasStrokeRendererTest {
+
+    @get:Rule
+    val activityScenarioRule = ActivityScenarioRule(CanvasStrokeRendererTestActivity::class.java)
+
+    @get:Rule val screenshotRule = AndroidXScreenshotTestRule(SCREENSHOT_GOLDEN_DIRECTORY)
+
+    @Test
+    fun drawsSimpleStrokes() {
+        activityScenarioRule.scenario.onActivity { activity ->
+            activity.addStrokeRows(
+                listOf(
+                    Pair(
+                        "Solid",
+                        finishedInProgressStroke(
+                            brush(color = TestColors.AVOCADO_GREEN),
+                            INPUTS_ZIGZAG
+                        ),
+                    ),
+                    Pair(
+                        "Translucent",
+                        finishedInProgressStroke(
+                            brush(
+                                BrushFamily(BrushTip(opacityMultiplier = 1.0F)),
+                                TestColors.COBALT_BLUE.withAlpha(0.4),
+                            ),
+                            INPUTS_TWIST,
+                        ),
+                    ),
+                    Pair(
+                        "Tiled",
+                        finishedInProgressStroke(
+                            tiledBrush(
+                                textureSizeUnit = TextureSizeUnit.STROKE_COORDINATES,
+                                textureSize = 10f
+                            ),
+                            INPUTS_ZIGZAG,
+                        ),
+                    ),
+                    Pair(
+                        "Multicoat",
+                        finishedInProgressStroke(
+                            brush(
+                                BrushFamily(
+                                    listOf(
+                                        BrushCoat(
+                                            paint =
+                                                tiledBrushPaint(
+                                                    textureSizeUnit =
+                                                        TextureSizeUnit.STROKE_COORDINATES,
+                                                    textureSize = 10f,
+                                                )
+                                        ),
+                                        BrushCoat(tip = BrushTip(scaleX = 0.5f, scaleY = 0.5f)),
+                                    )
+                                ),
+                                TestColors.RED,
+                            ),
+                            INPUTS_TWIST,
+                        ),
+                    ),
+                    // TODO: b/330528190 - Add row for atlased textures
+                    Pair(
+                        """
+              Opacity &
+              HSL Shift
+            """
+                            .trimIndent(),
+                        finishedInProgressStroke(
+                            brush(
+                                BrushFamily(
+                                    BrushTip(
+                                        behaviors =
+                                            listOf(
+                                                BrushBehavior(
+                                                    source =
+                                                        BrushBehavior.Source
+                                                            .DISTANCE_TRAVELED_IN_MULTIPLES_OF_BRUSH_SIZE,
+                                                    target =
+                                                        BrushBehavior.Target.OPACITY_MULTIPLIER,
+                                                    sourceValueRangeLowerBound = 0f,
+                                                    sourceValueRangeUpperBound = 2f,
+                                                    targetModifierRangeLowerBound = 1f,
+                                                    targetModifierRangeUpperBound = 0.25f,
+                                                    sourceOutOfRangeBehavior =
+                                                        BrushBehavior.OutOfRange.MIRROR,
+                                                ),
+                                                BrushBehavior(
+                                                    source =
+                                                        BrushBehavior.Source
+                                                            .DISTANCE_TRAVELED_IN_MULTIPLES_OF_BRUSH_SIZE,
+                                                    target =
+                                                        BrushBehavior.Target.HUE_OFFSET_IN_RADIANS,
+                                                    sourceValueRangeLowerBound = 0f,
+                                                    sourceValueRangeUpperBound = 3f,
+                                                    targetModifierRangeLowerBound = 0f,
+                                                    targetModifierRangeUpperBound =
+                                                        Angle.FULL_TURN_RADIANS,
+                                                    sourceOutOfRangeBehavior =
+                                                        BrushBehavior.OutOfRange.REPEAT,
+                                                ),
+                                            )
+                                    )
+                                ),
+                                TestColors.AVOCADO_GREEN,
+                            ),
+                            INPUTS_TWIST,
+                        ),
+                    ),
+                    // TODO: b/274461578 - Add row for winding textures
+                )
+            )
+        }
+        assertScreenshot("SimpleStrokes")
+    }
+
+    @Test
+    fun supportsTextureOrigins() {
+        activityScenarioRule.scenario.onActivity { activity ->
+            activity.addStrokeRows(
+                listOf(
+                    Pair(
+                        "STROKE_SPACE_ORIGIN",
+                        finishedInProgressStroke(
+                            tiledBrush(
+                                textureUri = CanvasStrokeRendererTestActivity.TEXTURE_URI_CIRCLE,
+                                textureSize = 1f,
+                                textureSizeUnit = TextureSizeUnit.BRUSH_SIZE,
+                                textureOrigin = TextureOrigin.STROKE_SPACE_ORIGIN,
+                                textureOffsetX = 0.5f,
+                                textureOffsetY = 0.5f,
+                                brushSize = 25f,
+                            ),
+                            INPUTS_ZAGZIG,
+                        ),
+                    ),
+                    Pair(
+                        "FIRST_STROKE_INPUT",
+                        finishedInProgressStroke(
+                            tiledBrush(
+                                textureUri = CanvasStrokeRendererTestActivity.TEXTURE_URI_CIRCLE,
+                                textureSize = 1f,
+                                textureSizeUnit = TextureSizeUnit.BRUSH_SIZE,
+                                textureOrigin = TextureOrigin.FIRST_STROKE_INPUT,
+                                textureOffsetX = 0.5f,
+                                textureOffsetY = 0.5f,
+                                brushSize = 25f,
+                            ),
+                            INPUTS_ZAGZIG,
+                        ),
+                    ),
+                    Pair(
+                        "LAST_STROKE_INPUT",
+                        finishedInProgressStroke(
+                            tiledBrush(
+                                textureUri = CanvasStrokeRendererTestActivity.TEXTURE_URI_CIRCLE,
+                                textureSize = 1f,
+                                textureSizeUnit = TextureSizeUnit.BRUSH_SIZE,
+                                textureOrigin = TextureOrigin.LAST_STROKE_INPUT,
+                                textureOffsetX = 0.5f,
+                                textureOffsetY = 0.5f,
+                                brushSize = 25f,
+                            ),
+                            INPUTS_ZAGZIG,
+                        ),
+                    ),
+                )
+            )
+        }
+        assertScreenshot("TextureOrigins")
+    }
+
+    @Test
+    fun supportsTextureSizeUnits() {
+        activityScenarioRule.scenario.onActivity { activity ->
+            activity.addStrokeRows(
+                listOf(
+                    Pair(
+                        """
+              textureSize=
+              BRUSH_SIZE*1
+              brushSize=15
+            """
+                            .trimIndent(),
+                        finishedInProgressStroke(
+                            tiledBrush(
+                                textureSize = 1f,
+                                textureSizeUnit = TextureSizeUnit.BRUSH_SIZE,
+                                brushSize = 15f,
+                            ),
+                            INPUTS_ZIGZAG,
+                        ),
+                    ),
+                    Pair(
+                        """
+              textureSize=
+              BRUSH_SIZE*1
+              brushSize=30
+            """
+                            .trimIndent(),
+                        finishedInProgressStroke(
+                            tiledBrush(
+                                textureSize = 1f,
+                                textureSizeUnit = TextureSizeUnit.BRUSH_SIZE,
+                                brushSize = 30f,
+                            ),
+                            INPUTS_ZIGZAG,
+                        ),
+                    ),
+                    Pair(
+                        """
+              textureSize=
+              BRUSH_SIZE/2
+              brushSize=30
+            """
+                            .trimIndent(),
+                        finishedInProgressStroke(
+                            tiledBrush(
+                                textureSize = 0.5f,
+                                textureSizeUnit = TextureSizeUnit.BRUSH_SIZE,
+                                brushSize = 30f,
+                            ),
+                            INPUTS_ZIGZAG,
+                        ),
+                    ),
+                    // TODO: b/336835642 - add row for STROKE_SIZE
+                    Pair(
+                        """
+              textureSize=
+              STROKE_COORDS*5
+            """
+                            .trimIndent(),
+                        finishedInProgressStroke(
+                            tiledBrush(
+                                textureSize = 5f,
+                                textureSizeUnit = TextureSizeUnit.STROKE_COORDINATES
+                            ),
+                            INPUTS_ZIGZAG,
+                        ),
+                    ),
+                    Pair(
+                        """
+              textureSize=
+              STROKE_COORDS*10
+            """
+                            .trimIndent(),
+                        finishedInProgressStroke(
+                            tiledBrush(
+                                textureSize = 10f,
+                                textureSizeUnit = TextureSizeUnit.STROKE_COORDINATES
+                            ),
+                            INPUTS_ZIGZAG,
+                        ),
+                    ),
+                )
+            )
+        }
+        assertScreenshot("TextureSizeUnits")
+    }
+
+    @Test
+    fun supportsBlendModesWithBrushColor() {
+        activityScenarioRule.scenario.onActivity { activity ->
+            activity.addStrokeRows(
+                listOf(
+                    Pair(
+                        """
+              MODULATE
+              WHITE
+            """
+                            .trimIndent(),
+                        colorBlendedStroke(BlendMode.MODULATE, TestColors.WHITE),
+                    ),
+                    Pair(
+                        """
+              MODULATE
+              RED.withAlpha(0.5)
+            """
+                            .trimIndent(),
+                        colorBlendedStroke(BlendMode.MODULATE, TestColors.RED.withAlpha(0.5)),
+                    ),
+                    Pair(
+                        """
+              DST_IN
+              RED.withAlpha(0.5)
+            """
+                            .trimIndent(),
+                        colorBlendedStroke(BlendMode.DST_IN, TestColors.RED.withAlpha(0.5)),
+                    ),
+                    Pair(
+                        """
+              DST_OUT
+              RED.withAlpha(0.5)
+            """
+                            .trimIndent(),
+                        colorBlendedStroke(BlendMode.DST_OUT, TestColors.RED.withAlpha(0.5)),
+                    ),
+                    Pair(
+                        """
+              SRC_ATOP
+              RED.withAlpha(0.5)
+            """
+                            .trimIndent(),
+                        colorBlendedStroke(BlendMode.SRC_ATOP, TestColors.RED.withAlpha(0.5)),
+                    ),
+                    Pair(
+                        """
+              SRC_IN
+              RED.withAlpha(0.5)
+            """
+                            .trimIndent(),
+                        colorBlendedStroke(BlendMode.SRC_IN, TestColors.RED.withAlpha(0.5)),
+                    ),
+                    Pair(
+                        """
+              SRC
+              RED.withAlpha(0.5)
+            """
+                            .trimIndent(),
+                        colorBlendedStroke(BlendMode.SRC, TestColors.RED.withAlpha(0.5)),
+                    ),
+                )
+            )
+        }
+        assertScreenshot("BlendWithBrushColor")
+    }
+
+    @Test
+    fun supportsBlendModesWithTwoTextures() {
+        activityScenarioRule.scenario.onActivity { activity ->
+            activity.addStrokeRows(
+                listOf(
+                    Pair("SRC", textureBlendedStroke(BlendMode.SRC)),
+                    Pair("DST", textureBlendedStroke(BlendMode.DST)),
+                    Pair("SRC_OVER", textureBlendedStroke(BlendMode.SRC_OVER)),
+                    Pair("DST_OVER", textureBlendedStroke(BlendMode.DST_OVER)),
+                    Pair("SRC_OUT", textureBlendedStroke(BlendMode.SRC_OUT)),
+                    Pair("DST_ATOP", textureBlendedStroke(BlendMode.DST_ATOP)),
+                    Pair("XOR", textureBlendedStroke(BlendMode.XOR)),
+                )
+            )
+        }
+        assertScreenshot("BlendTwoTextures")
+    }
+
+    @Test
+    fun supportsTextureOffset() {
+        activityScenarioRule.scenario.onActivity { activity ->
+            activity.addStrokeRows(
+                listOf(
+                    Pair(
+                        """
+              offsetX=0.0
+              offsetY=0.0
+            """
+                            .trimIndent(),
+                        textureTransformStroke(offsetX = 0.0f, offsetY = 0.0f),
+                    ),
+                    Pair(
+                        """
+              offsetX=0.25
+              offsetY=0.0
+            """
+                            .trimIndent(),
+                        textureTransformStroke(offsetX = 0.25f, offsetY = 0.0f),
+                    ),
+                    Pair(
+                        """
+              offsetX=0.5
+              offsetY=0.0
+            """
+                            .trimIndent(),
+                        textureTransformStroke(offsetX = 0.5f, offsetY = 0.0f),
+                    ),
+                    Pair(
+                        """
+              offsetX=0.75
+              offsetY=0.0
+            """
+                            .trimIndent(),
+                        textureTransformStroke(offsetX = 0.75f, offsetY = 0.0f),
+                    ),
+                    Pair(
+                        """
+              offsetX=0.25
+              offsetY=0.25
+            """
+                            .trimIndent(),
+                        textureTransformStroke(offsetX = 0.25f, offsetY = 0.25f),
+                    ),
+                )
+            )
+        }
+        assertScreenshot("TextureOffset")
+    }
+
+    private fun assertScreenshot(filename: String) {
+        onView(withId(R.id.stroke_grid))
+            .perform(
+                captureToBitmap() {
+                    it.assertAgainstGolden(screenshotRule, "${this::class.simpleName}_$filename")
+                }
+            )
+    }
+
+    private companion object {
+        val NO_PREDICTION = ImmutableStrokeInputBatch.EMPTY
+
+        val INPUTS_ZIGZAG =
+            MutableStrokeInputBatch()
+                .addOrThrow(InputToolType.UNKNOWN, x = 0F, y = 0F, elapsedTimeMillis = 100)
+                .addOrThrow(InputToolType.UNKNOWN, x = 40F, y = 40F, elapsedTimeMillis = 150)
+                .addOrThrow(InputToolType.UNKNOWN, x = 0F, y = 70F, elapsedTimeMillis = 200)
+                .addOrThrow(InputToolType.UNKNOWN, x = 30F, y = 100F, elapsedTimeMillis = 250)
+                .asImmutable()
+
+        val INPUTS_ZAGZIG =
+            MutableStrokeInputBatch()
+                .addOrThrow(InputToolType.UNKNOWN, x = 30F, y = 0F, elapsedTimeMillis = 100)
+                .addOrThrow(InputToolType.UNKNOWN, x = 0F, y = 40F, elapsedTimeMillis = 150)
+                .addOrThrow(InputToolType.UNKNOWN, x = 40F, y = 70F, elapsedTimeMillis = 200)
+                .addOrThrow(InputToolType.UNKNOWN, x = 5F, y = 90F, elapsedTimeMillis = 250)
+                .asImmutable()
+
+        val INPUTS_TWIST =
+            MutableStrokeInputBatch()
+                .addOrThrow(InputToolType.UNKNOWN, x = 0F, y = 0F, elapsedTimeMillis = 100)
+                .addOrThrow(InputToolType.UNKNOWN, x = 80F, y = 100F, elapsedTimeMillis = 150)
+                .addOrThrow(InputToolType.UNKNOWN, x = 0F, y = 100F, elapsedTimeMillis = 200)
+                .addOrThrow(InputToolType.UNKNOWN, x = 80F, y = 0F, elapsedTimeMillis = 250)
+                .asImmutable()
+
+        fun brush(
+            family: BrushFamily = StockBrushes.markerLatest,
+            @ColorInt color: Int = TestColors.BLACK,
+            size: Float = 15F,
+            epsilon: Float = 0.1F,
+        ) = Brush.createWithColorIntArgb(family, color, size, epsilon)
+
+        fun tiledBrush(
+            textureUri: String = CanvasStrokeRendererTestActivity.TEXTURE_URI_CHECKERBOARD,
+            textureSizeUnit: TextureSizeUnit,
+            textureSize: Float,
+            textureOrigin: TextureOrigin = TextureOrigin.STROKE_SPACE_ORIGIN,
+            textureOffsetX: Float = 0f,
+            textureOffsetY: Float = 0f,
+            @ColorInt brushColor: Int = TestColors.BLACK,
+            brushSize: Float = 15f,
+        ): Brush {
+            val paint =
+                tiledBrushPaint(
+                    textureUri = textureUri,
+                    textureSizeUnit = textureSizeUnit,
+                    textureSize = textureSize,
+                    textureOrigin = textureOrigin,
+                    textureOffsetX = textureOffsetX,
+                    textureOffsetY = textureOffsetY,
+                )
+            return brush(BrushFamily(paint = paint), brushColor, brushSize)
+        }
+
+        fun tiledBrushPaint(
+            textureUri: String = CanvasStrokeRendererTestActivity.TEXTURE_URI_CHECKERBOARD,
+            textureSizeUnit: TextureSizeUnit,
+            textureSize: Float,
+            textureOrigin: TextureOrigin = TextureOrigin.STROKE_SPACE_ORIGIN,
+            textureOffsetX: Float = 0f,
+            textureOffsetY: Float = 0f,
+        ): BrushPaint {
+            val textureLayer =
+                BrushPaint.TextureLayer(
+                    colorTextureUri = textureUri,
+                    sizeX = textureSize,
+                    sizeY = textureSize,
+                    offsetX = textureOffsetX,
+                    offsetY = textureOffsetY,
+                    sizeUnit = textureSizeUnit,
+                    origin = textureOrigin,
+                )
+            return BrushPaint(listOf(textureLayer))
+        }
+
+        fun textureTransformStroke(offsetX: Float, offsetY: Float): InProgressStroke =
+            finishedInProgressStroke(
+                tiledBrush(
+                    textureSize = 30f,
+                    textureOffsetX = offsetX,
+                    textureOffsetY = offsetY,
+                    textureSizeUnit = TextureSizeUnit.STROKE_COORDINATES,
+                    brushSize = 30f,
+                ),
+                INPUTS_ZIGZAG,
+            )
+
+        fun finishedInProgressStroke(brush: Brush, inputs: ImmutableStrokeInputBatch) =
+            InProgressStroke().apply {
+                start(brush)
+                enqueueInputs(inputs, NO_PREDICTION).getOrThrow()
+                finishInput()
+                updateShape(inputs.getDurationMillis()).getOrThrow()
+            }
+
+        fun colorBlendedStroke(blendMode: BlendMode, @ColorInt color: Int): InProgressStroke {
+            val textureLayer =
+                BrushPaint.TextureLayer(
+                    CanvasStrokeRendererTestActivity.TEXTURE_URI_POOP_EMOJI,
+                    sizeX = 1f,
+                    sizeY = 1f,
+                    sizeUnit = TextureSizeUnit.BRUSH_SIZE,
+                    blendMode = blendMode,
+                )
+            val paint = BrushPaint(listOf(textureLayer))
+            val brush = brush(BrushFamily(paint = paint), color, size = 30f)
+            return finishedInProgressStroke(brush, INPUTS_TWIST)
+        }
+
+        fun textureBlendedStroke(blendMode: BlendMode): InProgressStroke {
+            val textureLayer1 =
+                BrushPaint.TextureLayer(
+                    CanvasStrokeRendererTestActivity.TEXTURE_URI_AIRPLANE_EMOJI,
+                    sizeX = 1f,
+                    sizeY = 1f,
+                    sizeUnit = TextureSizeUnit.BRUSH_SIZE,
+                    blendMode = blendMode,
+                )
+            val textureLayer2 =
+                BrushPaint.TextureLayer(
+                    CanvasStrokeRendererTestActivity.TEXTURE_URI_POOP_EMOJI,
+                    sizeX = 1f,
+                    sizeY = 1f,
+                    sizeUnit = TextureSizeUnit.BRUSH_SIZE,
+                )
+            val paint = BrushPaint(listOf(textureLayer1, textureLayer2))
+            val brush = brush(BrushFamily(paint = paint), color = TestColors.WHITE, size = 40f)
+            return finishedInProgressStroke(brush, INPUTS_ZIGZAG)
+        }
+
+        @ColorInt
+        fun Int.withAlpha(alpha: Double): Int {
+            return ColorUtils.setAlphaComponent(this, (alpha * 255).toInt())
+        }
+    }
+}
diff --git a/ink/ink-rendering/src/androidInstrumentedTest/kotlin/androidx/ink/rendering/android/canvas/CanvasStrokeRendererTestActivity.kt b/ink/ink-rendering/src/androidInstrumentedTest/kotlin/androidx/ink/rendering/android/canvas/CanvasStrokeRendererTestActivity.kt
new file mode 100644
index 0000000..be6d493
--- /dev/null
+++ b/ink/ink-rendering/src/androidInstrumentedTest/kotlin/androidx/ink/rendering/android/canvas/CanvasStrokeRendererTestActivity.kt
@@ -0,0 +1,179 @@
+/*
+ * 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.ink.rendering.android.canvas
+
+import android.app.Activity
+import android.content.Context
+import android.graphics.BitmapFactory
+import android.graphics.Canvas
+import android.graphics.Matrix
+import android.os.Build
+import android.os.Bundle
+import android.view.Gravity
+import android.view.View
+import android.widget.GridLayout
+import android.widget.TextView
+import androidx.ink.brush.ExperimentalInkCustomBrushApi
+import androidx.ink.geometry.AffineTransform
+import androidx.ink.geometry.BoxAccumulator
+import androidx.ink.geometry.ImmutableAffineTransform
+import androidx.ink.geometry.ImmutableBox
+import androidx.ink.geometry.ImmutableVec
+import androidx.ink.rendering.android.TextureBitmapStore
+import androidx.ink.rendering.test.R
+import androidx.ink.strokes.InProgressStroke
+
+/** An [Activity] to support [CanvasStrokeRendererTest]. */
+@OptIn(ExperimentalInkCustomBrushApi::class)
+class CanvasStrokeRendererTestActivity : Activity() {
+    private val textureStore = TextureBitmapStore { uri ->
+        when (uri) {
+            TEXTURE_URI_AIRPLANE_EMOJI -> R.drawable.airplane_emoji
+            TEXTURE_URI_CHECKERBOARD -> R.drawable.checkerboard_black_and_transparent
+            TEXTURE_URI_CIRCLE -> R.drawable.circle
+            TEXTURE_URI_POOP_EMOJI -> R.drawable.poop_emoji
+            else -> null
+        }?.let { BitmapFactory.decodeResource(resources, it) }
+    }
+    private val meshRenderer =
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+            CanvasStrokeRenderer.create(textureStore)
+        } else {
+            null
+        }
+    private val pathRenderer = CanvasStrokeRenderer.create(textureStore, forcePathRendering = true)
+    private val defaultRenderer = CanvasStrokeRenderer.create()
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        setContentView(R.layout.canvas_stroke_renderer_test)
+    }
+
+    fun addStrokeRows(labelsAndStrokes: List<Pair<String, InProgressStroke>>) {
+        val grid = findViewById<GridLayout>(R.id.stroke_grid)
+        for ((label, stroke) in labelsAndStrokes) {
+            val row = grid.rowCount
+            grid.rowCount = row + 1
+            grid.addView(
+                TextView(this).apply {
+                    text = label
+                    setTextSize(10.0F)
+                },
+                gridLayoutParams(row, col = 0, Gravity.CENTER_VERTICAL),
+            )
+            if (meshRenderer != null) {
+                grid.addView(
+                    StrokeView(this, meshRenderer, stroke),
+                    gridLayoutParams(row, col = 1, Gravity.FILL),
+                )
+            } else {
+                grid.addView(
+                    TextView(this).apply { text = "N/A" },
+                    gridLayoutParams(row, col = 1, Gravity.CENTER),
+                )
+            }
+            grid.addView(
+                StrokeView(this, pathRenderer, stroke),
+                gridLayoutParams(row, col = 2, Gravity.FILL),
+            )
+            grid.addView(
+                StrokeView(this, defaultRenderer, stroke),
+                gridLayoutParams(row, col = 3, Gravity.FILL),
+            )
+        }
+    }
+
+    private fun gridLayoutParams(row: Int, col: Int, gravity: Int): GridLayout.LayoutParams {
+        val params =
+            GridLayout.LayoutParams(
+                GridLayout.spec(row, /* weight= */ 1f),
+                GridLayout.spec(col, /* weight= */ 0f),
+            )
+        if (gravity == Gravity.FILL) {
+            params.width = 0
+            params.height = 0
+        }
+        params.setGravity(gravity)
+        return params
+    }
+
+    private class StrokeView(
+        context: Context,
+        val renderer: CanvasStrokeRenderer,
+        val inProgressStroke: InProgressStroke,
+    ) : View(context) {
+
+        val bounds = BoxAccumulator().also { inProgressStroke.populateMeshBounds(0, it) }.box!!
+        val finishedStrokeTranslateX = 20 + (bounds.xMax - bounds.xMin)
+        val scaledStrokeTranslateY = 10 + (bounds.yMax - bounds.yMin)
+        val scaleValueY = 0.5f
+        val totalGridBounds =
+            ImmutableBox.fromTwoPoints(
+                ImmutableVec(bounds.xMin, bounds.yMin),
+                ImmutableVec(
+                    finishedStrokeTranslateX + (bounds.xMax - bounds.xMin),
+                    scaledStrokeTranslateY + scaleValueY * (bounds.yMax - bounds.yMin),
+                ),
+            )
+
+        override fun onDraw(canvas: Canvas) {
+            super.onDraw(canvas)
+            canvas.translate(
+                (width - (totalGridBounds.xMax - totalGridBounds.xMin)) / 2,
+                (height - (totalGridBounds.yMax - totalGridBounds.yMin)) / 2,
+            )
+            renderer.draw(canvas, inProgressStroke, AffineTransform.IDENTITY)
+
+            // Draw Stroke next to InProgressStroke, with a small gap between them.
+            val stroke = inProgressStroke.toImmutable()
+            renderer.draw(
+                canvas,
+                stroke,
+                ImmutableAffineTransform.translate(ImmutableVec(finishedStrokeTranslateX, 0f)),
+            )
+
+            // Draw the InProgressStroke and Stroke again in a second row with a non-trivial
+            // transform
+            // and using android.graphics.Matrix instead of AffineTransform.
+            renderer.draw(
+                canvas,
+                inProgressStroke,
+                Matrix().apply {
+                    setSkew(0.5f, 0f)
+                    postScale(1f, scaleValueY)
+                    postTranslate(0f, scaledStrokeTranslateY)
+                },
+            )
+            renderer.draw(
+                canvas,
+                stroke,
+                Matrix().apply {
+                    setSkew(0.5f, 0f)
+                    postScale(1f, scaleValueY)
+                    postTranslate(finishedStrokeTranslateX, scaledStrokeTranslateY)
+                },
+            )
+        }
+    }
+
+    companion object {
+        const val TEXTURE_URI_AIRPLANE_EMOJI = "ink://ink/texture:airplane-emoji"
+        const val TEXTURE_URI_CHECKERBOARD = "ink://ink/texture:checkerboard-overlay-pen"
+        const val TEXTURE_URI_CIRCLE = "ink://ink/texture:circle"
+        const val TEXTURE_URI_POOP_EMOJI = "ink://ink/texture:poop-emoji"
+    }
+}
diff --git a/ink/ink-rendering/src/androidInstrumentedTest/kotlin/androidx/ink/rendering/android/canvas/TestColors.kt b/ink/ink-rendering/src/androidInstrumentedTest/kotlin/androidx/ink/rendering/android/canvas/TestColors.kt
new file mode 100644
index 0000000..fd7c886
--- /dev/null
+++ b/ink/ink-rendering/src/androidInstrumentedTest/kotlin/androidx/ink/rendering/android/canvas/TestColors.kt
@@ -0,0 +1,63 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.ink.rendering.android.canvas
+
+import androidx.annotation.ColorInt
+
+/**
+ * [ColorInt] constants for use in tests.
+ *
+ * Channels are in ARGB order, per the definition of [ColorInt]. Use the helper functions defined
+ * below to convert to other channel orders.
+ *
+ * These colors have different values for all RGB channels and at least one channel with a value
+ * strictly between 0.0 (0x00) and 1.0 (0xff). These properties help check for channel order
+ * scrambling (for example, incorrect mixing of RGB and BGR formats) and gamma correction errors.
+ */
+object TestColors {
+    /**
+     * Near-white color for backgrounds and elements without textures. For textured elements that
+     * need a 100% white base color, use [WHITE_FOR_TEXTURE].
+     */
+    @ColorInt const val WHITE = 0xfff5f8ff.toInt()
+    // Gray and black are not pure desaturated tones, because we need different values in the
+    // different channels.
+    @ColorInt const val LIGHT_GRAY = 0xffbaccc0.toInt()
+    @ColorInt const val DARK_GRAY = 0xff4d4239.toInt()
+    @ColorInt const val BLACK = 0xff290e1c.toInt()
+    @ColorInt const val RED = 0xfff7251e.toInt()
+    @ColorInt const val ORANGE = 0xffff6e40.toInt()
+    @ColorInt const val LIGHT_ORANGE = 0xffffccbc.toInt()
+    @ColorInt const val YELLOW = 0xfff7f12d.toInt()
+    @ColorInt const val AVOCADO_GREEN = 0xff558b2f.toInt()
+    @ColorInt const val GREEN = 0xff00c853.toInt()
+    @ColorInt const val CYAN = 0xff2be3f0.toInt()
+    @ColorInt const val LIGHT_BLUE = 0xff4fb5e8.toInt()
+    @ColorInt const val BLUE = 0xff304ffe.toInt()
+    @ColorInt const val COBALT_BLUE = 0xff01579b.toInt()
+    @ColorInt const val DEEP_PURPLE = 0xff8e24aa.toInt()
+    @ColorInt const val MAGENTA = 0xffed26e0.toInt()
+    @ColorInt const val HOT_PINK = 0xffff4081.toInt()
+
+    /** White base color for elements that have a texture applied. */
+    @ColorInt const val WHITE_FOR_TEXTURE = 0xffffffff.toInt()
+
+    @ColorInt const val TRANSLUCENT_ORANGE = 0x80ffbf00.toInt()
+
+    @JvmStatic
+    fun colorIntToRgba(@ColorInt argb: Int): Int = (argb shl 8) or ((argb shr 24) and 0xff)
+}
diff --git a/compose/foundation/foundation-layout/src/commonStubsMain/kotlin/androidx/compose/foundation/layout/RowColumnMeasurePolicy.commonStubs.kt b/ink/ink-rendering/src/androidInstrumentedTest/kotlin/androidx/ink/rendering/android/canvas/TestConstants.kt
similarity index 67%
copy from compose/foundation/foundation-layout/src/commonStubsMain/kotlin/androidx/compose/foundation/layout/RowColumnMeasurePolicy.commonStubs.kt
copy to ink/ink-rendering/src/androidInstrumentedTest/kotlin/androidx/ink/rendering/android/canvas/TestConstants.kt
index 263e22e..b481afb 100644
--- a/compose/foundation/foundation-layout/src/commonStubsMain/kotlin/androidx/compose/foundation/layout/RowColumnMeasurePolicy.commonStubs.kt
+++ b/ink/ink-rendering/src/androidInstrumentedTest/kotlin/androidx/ink/rendering/android/canvas/TestConstants.kt
@@ -1,5 +1,5 @@
 /*
- * Copyright 2024 The Android Open Source Project
+ * 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.
@@ -14,10 +14,6 @@
  * limitations under the License.
  */
 
-package androidx.compose.foundation.layout
+package androidx.ink.rendering.android.canvas
 
-@Suppress("NOTHING_TO_INLINE")
-internal actual inline fun initCause(
-    exception: IllegalArgumentException,
-    cause: Exception
-): Throwable = implementedInJetBrainsFork()
+internal const val SCREENSHOT_GOLDEN_DIRECTORY = "ink/ink-rendering"
diff --git a/ink/ink-rendering/src/androidInstrumentedTest/kotlin/androidx/ink/rendering/android/canvas/internal/BrushPaintCacheTest.kt b/ink/ink-rendering/src/androidInstrumentedTest/kotlin/androidx/ink/rendering/android/canvas/internal/BrushPaintCacheTest.kt
new file mode 100644
index 0000000..d646160
--- /dev/null
+++ b/ink/ink-rendering/src/androidInstrumentedTest/kotlin/androidx/ink/rendering/android/canvas/internal/BrushPaintCacheTest.kt
@@ -0,0 +1,403 @@
+/*
+ * 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.ink.rendering.android.canvas.internal
+
+import android.graphics.Bitmap
+import android.graphics.Bitmap.createBitmap
+import android.graphics.BitmapShader
+import android.graphics.Color
+import android.graphics.ComposeShader
+import android.graphics.Matrix
+import androidx.ink.brush.BrushPaint
+import androidx.ink.brush.ExperimentalInkCustomBrushApi
+import androidx.ink.rendering.android.TextureBitmapStore
+import androidx.ink.strokes.StrokeInput
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@OptIn(ExperimentalInkCustomBrushApi::class)
+@RunWith(AndroidJUnit4::class)
+@SmallTest
+class BrushPaintCacheTest {
+
+    private fun nestedArrayToMatrix(values: Array<Array<Float>>) =
+        Matrix().apply { setValues(values.flatten().toFloatArray()) }
+
+    @Test
+    fun obtain_positionOnlyWithTexture() {
+        var uriLoaded: String? = null
+        val cache =
+            BrushPaintCache(
+                TextureBitmapStore {
+                    uriLoaded = it
+                    createBitmap(10, 20, Bitmap.Config.ARGB_8888)
+                }
+            )
+        val fakeTextureUri = "ink://ink/texture:test-texture-one"
+        val brushPaint =
+            BrushPaint(listOf(BrushPaint.TextureLayer(fakeTextureUri, sizeX = 30F, sizeY = 40F)))
+        val brushSize = 10f
+        val internalToStrokeTransform = Matrix().apply { preTranslate(50F, 60F) }
+
+        val paint =
+            cache.obtain(
+                brushPaint,
+                Color.RED,
+                brushSize,
+                StrokeInput(),
+                StrokeInput(),
+                internalToStrokeTransform,
+            )
+
+        assertThat(uriLoaded).isEqualTo(fakeTextureUri)
+        assertThat(paint.color).isEqualTo(Color.RED)
+        assertThat(paint.shader).isInstanceOf(BitmapShader::class.java)
+        val expectedLocalMatrix =
+            nestedArrayToMatrix(
+                arrayOf(arrayOf(3F, 0F, -50F), arrayOf(0F, 2F, -60F), arrayOf(0F, 0F, 1.0F))
+            )
+        with(Matrix()) {
+            assertThat(paint.shader.getLocalMatrix(this)).isTrue()
+            assertThat(this).isEqualTo(expectedLocalMatrix)
+        }
+
+        val newInternalToStrokeTransform = Matrix().apply { preTranslate(-50F, -60F) }
+        val expectedUpdatedMatrix =
+            nestedArrayToMatrix(
+                arrayOf(arrayOf(3F, 0F, 50F), arrayOf(0F, 2F, 60F), arrayOf(0F, 0F, 1.0F))
+            )
+        assertThat(
+                cache.obtain(
+                    brushPaint,
+                    Color.RED,
+                    brushSize,
+                    StrokeInput(),
+                    StrokeInput(),
+                    newInternalToStrokeTransform,
+                )
+            )
+            .isSameInstanceAs(paint)
+        with(Matrix()) {
+            assertThat(paint.shader.getLocalMatrix(this)).isTrue()
+            assertThat(expectedUpdatedMatrix).isNotEqualTo(expectedLocalMatrix)
+            assertThat(this).isEqualTo(expectedUpdatedMatrix)
+        }
+
+        assertThat(
+                cache.obtain(
+                    brushPaint,
+                    Color.BLUE,
+                    brushSize,
+                    StrokeInput(),
+                    StrokeInput(),
+                    newInternalToStrokeTransform,
+                )
+            )
+            .isSameInstanceAs(paint)
+        assertThat(paint.color).isEqualTo(Color.BLUE)
+    }
+
+    @Test
+    fun obtain_forBrushPaintWithSizeUnitBrushSize() {
+        val cache =
+            BrushPaintCache(TextureBitmapStore { createBitmap(1, 1, Bitmap.Config.ARGB_8888) })
+        val textureUri = "ink://ink/texture:test-texture-one"
+        val brushPaint =
+            BrushPaint(
+                listOf(
+                    BrushPaint.TextureLayer(
+                        textureUri,
+                        sizeX = 2f,
+                        sizeY = 3f,
+                        sizeUnit = BrushPaint.TextureSizeUnit.BRUSH_SIZE,
+                    )
+                )
+            )
+        val internalToStrokeTransform = Matrix().apply { preTranslate(7f, 5f) }
+
+        val paint =
+            cache.obtain(
+                brushPaint,
+                Color.RED,
+                brushSize = 10f,
+                StrokeInput(),
+                StrokeInput(),
+                internalToStrokeTransform,
+            )
+
+        val expectedLocalMatrix =
+            nestedArrayToMatrix(
+                arrayOf(arrayOf(20F, 0F, -7F), arrayOf(0F, 30F, -5F), arrayOf(0F, 0F, 1F))
+            )
+        with(Matrix()) {
+            assertThat(paint.shader.getLocalMatrix(this)).isTrue()
+            assertThat(this).isEqualTo(expectedLocalMatrix)
+        }
+
+        val expectedUpdatedMatrix =
+            nestedArrayToMatrix(
+                arrayOf(arrayOf(40F, 0F, -7F), arrayOf(0F, 60F, -5F), arrayOf(0F, 0F, 1F))
+            )
+        assertThat(
+                cache.obtain(
+                    brushPaint,
+                    Color.RED,
+                    brushSize = 20f,
+                    StrokeInput(),
+                    StrokeInput(),
+                    internalToStrokeTransform,
+                )
+            )
+            .isSameInstanceAs(paint)
+        with(Matrix()) {
+            assertThat(paint.shader.getLocalMatrix(this)).isTrue()
+            assertThat(expectedUpdatedMatrix).isNotEqualTo(expectedLocalMatrix)
+            assertThat(this).isEqualTo(expectedUpdatedMatrix)
+        }
+    }
+
+    @Test
+    fun obtain_multipleTextureLayers() {
+        val urisLoaded: MutableList<String> = mutableListOf()
+        val cache =
+            BrushPaintCache(
+                TextureBitmapStore {
+                    urisLoaded.add(it)
+                    createBitmap(/* width= */ 10, /* height= */ 20, Bitmap.Config.ARGB_8888)
+                }
+            )
+        val fakeTextureUri1 = "ink://ink/texture:test-texture-one"
+        val fakeTextureUri2 = "ink://ink/texture:test-texture-two"
+        val brushPaint =
+            BrushPaint(
+                listOf(
+                    BrushPaint.TextureLayer(fakeTextureUri1, sizeX = 30F, sizeY = 40F),
+                    BrushPaint.TextureLayer(fakeTextureUri2, sizeX = 30F, sizeY = 40F),
+                )
+            )
+
+        val paint =
+            cache.obtain(
+                brushPaint,
+                Color.RED,
+                brushSize = 1f,
+                StrokeInput(),
+                StrokeInput(),
+                Matrix()
+            )
+
+        assertThat(urisLoaded).containsExactly(fakeTextureUri1, fakeTextureUri2).inOrder()
+        assertThat(paint.color).isEqualTo(Color.RED)
+        assertThat(paint.shader).isInstanceOf(ComposeShader::class.java)
+        // Can't really assert in more detail because ComposeShader's fields are not readable.
+    }
+
+    @Test
+    fun obtain_textureLayersThatDoNotLoadAreIgnored() {
+        val urisLoaded: MutableList<String> = mutableListOf()
+        val fakeBrokenTextureUri1 = "//fake/texture:broken:1"
+        val fakeWorkingTextureUri = "ink://ink/texture:test-texture-one"
+        val fakeBrokenTextureUri2 = "//fake/texture:broken:2"
+        val cache =
+            BrushPaintCache(
+                TextureBitmapStore {
+                    urisLoaded.add(it)
+                    if (it == fakeWorkingTextureUri) {
+                        createBitmap(/* width= */ 10, /* height= */ 20, Bitmap.Config.ARGB_8888)
+                    } else {
+                        null
+                    }
+                }
+            )
+        val brushPaint =
+            BrushPaint(
+                listOf(
+                    BrushPaint.TextureLayer(fakeBrokenTextureUri1, sizeX = 30F, sizeY = 40F),
+                    BrushPaint.TextureLayer(fakeWorkingTextureUri, sizeX = 30F, sizeY = 40F),
+                    BrushPaint.TextureLayer(fakeBrokenTextureUri2, sizeX = 30F, sizeY = 40F),
+                )
+            )
+
+        val paint =
+            cache.obtain(brushPaint, Color.RED, brushSize = 1f, StrokeInput(), StrokeInput())
+
+        assertThat(urisLoaded)
+            .containsExactly(fakeBrokenTextureUri1, fakeWorkingTextureUri, fakeBrokenTextureUri2)
+            .inOrder()
+        assertThat(paint.color).isEqualTo(Color.RED)
+        assertThat(paint.shader).isInstanceOf(BitmapShader::class.java)
+    }
+
+    @Test
+    fun obtain_textureLoadingDisabled() {
+        var uriLoaded: String? = null
+        val cache =
+            BrushPaintCache(
+                TextureBitmapStore {
+                    uriLoaded = it
+                    null
+                }
+            )
+        val fakeTextureUri = "ink://ink/texture:test-texture-one"
+        val brushPaint =
+            BrushPaint(listOf(BrushPaint.TextureLayer(fakeTextureUri, sizeX = 30F, sizeY = 40F)))
+        val brushSize = 5f
+        val internalToStrokeTransform = Matrix().apply { preTranslate(50F, 60F) }
+
+        val paint =
+            cache.obtain(
+                brushPaint,
+                Color.RED,
+                brushSize,
+                StrokeInput(),
+                StrokeInput(),
+                internalToStrokeTransform,
+            )
+
+        assertThat(uriLoaded).isEqualTo(fakeTextureUri)
+        assertThat(paint.color).isEqualTo(Color.RED)
+        assertThat(paint.shader).isNull()
+
+        assertThat(
+                cache.obtain(
+                    brushPaint,
+                    Color.BLUE,
+                    brushSize,
+                    StrokeInput(),
+                    StrokeInput(),
+                    internalToStrokeTransform,
+                )
+            )
+            .isSameInstanceAs(paint)
+        assertThat(paint.color).isEqualTo(Color.BLUE)
+    }
+
+    @Test
+    fun obtain_textureLoadingDisabledMultipleLayers() {
+        val urisLoaded: MutableList<String> = mutableListOf()
+        val cache =
+            BrushPaintCache(
+                TextureBitmapStore {
+                    urisLoaded.add(it)
+                    null
+                }
+            )
+        val textureLayerWidth = 30F
+        val textureLayerHeight = 40F
+        val fakeTextureUri1 = "ink://ink/texture:test-one"
+        val fakeTextureUri2 = "ink://ink/texture:test-two"
+        val brushPaint =
+            BrushPaint(
+                listOf(
+                    BrushPaint.TextureLayer(fakeTextureUri1, textureLayerWidth, textureLayerHeight),
+                    BrushPaint.TextureLayer(fakeTextureUri2, textureLayerWidth, textureLayerHeight),
+                )
+            )
+        val internalToStrokeTransform =
+            Matrix().apply { preTranslate(/* dx= */ 50F, /* dy= */ 60F) }
+
+        val paint =
+            cache.obtain(
+                brushPaint,
+                Color.RED,
+                brushSize = 1f,
+                StrokeInput(),
+                StrokeInput(),
+                internalToStrokeTransform,
+            )
+
+        assertThat(urisLoaded).containsExactly(fakeTextureUri1, fakeTextureUri2).inOrder()
+        assertThat(paint.color).isEqualTo(Color.RED)
+        assertThat(paint.shader).isNull()
+    }
+
+    @Test
+    fun obtain_noTexture() {
+        val cache = BrushPaintCache(TextureBitmapStore { null })
+        val brushSize = 15f
+        val internalToStrokeTransform = Matrix().apply { preTranslate(50F, 60F) }
+
+        val paint =
+            cache.obtain(
+                BrushPaint(),
+                Color.RED,
+                brushSize,
+                StrokeInput(),
+                StrokeInput(),
+                internalToStrokeTransform,
+            )
+
+        assertThat(paint.color).isEqualTo(Color.RED)
+        assertThat(paint.shader).isNull()
+
+        // BrushPaint() is a different instance, but is equal.
+        assertThat(
+                cache.obtain(
+                    BrushPaint(),
+                    Color.BLUE,
+                    brushSize,
+                    StrokeInput(),
+                    StrokeInput(),
+                    internalToStrokeTransform,
+                )
+            )
+            .isSameInstanceAs(paint)
+        assertThat(paint.color).isEqualTo(Color.BLUE)
+    }
+
+    @Test
+    fun obtain_defaultInternalToStrokeTransform() {
+        var uriLoaded: String? = null
+        val cache =
+            BrushPaintCache(
+                TextureBitmapStore {
+                    uriLoaded = it
+                    createBitmap(10, 20, Bitmap.Config.ARGB_8888)
+                }
+            )
+        val fakeTextureUri = "ink://ink/texture:test-texture-one"
+        val brushPaint =
+            BrushPaint(
+                // Same size as the Bitmap.
+                listOf(BrushPaint.TextureLayer(fakeTextureUri, sizeX = 10F, sizeY = 20F))
+            )
+
+        val paint =
+            cache.obtain(brushPaint, Color.RED, brushSize = 1f, StrokeInput(), StrokeInput())
+
+        assertThat(uriLoaded).isEqualTo(fakeTextureUri)
+        assertThat(paint.color).isEqualTo(Color.RED)
+        assertThat(paint.shader).isInstanceOf(BitmapShader::class.java)
+        Matrix().let {
+            // Set the matrix to garbage data to make sure it gets overwritten.
+            it.preScale(55555F, 7777777F)
+
+            // getLocalMatrix indicates identity either by returning false or overwriting the result
+            // to
+            // the identity, but it has slightly different behavior on different API versions. The
+            // code
+            // under test doesn't use getLocalMatrix, we're just confirming that our call to
+            // setLocalMatrix matches what we expect.
+            val result = paint.shader.getLocalMatrix(it)
+            // Don't check it.isIdentity, that seems to be incorrect on earlier API levels.
+            assertThat(!result || it == Matrix()).isTrue()
+        }
+    }
+}
diff --git a/ink/ink-rendering/src/androidInstrumentedTest/kotlin/androidx/ink/rendering/android/canvas/internal/CanvasMeshRendererRobolectricTest.kt b/ink/ink-rendering/src/androidInstrumentedTest/kotlin/androidx/ink/rendering/android/canvas/internal/CanvasMeshRendererRobolectricTest.kt
new file mode 100644
index 0000000..07d6c58
--- /dev/null
+++ b/ink/ink-rendering/src/androidInstrumentedTest/kotlin/androidx/ink/rendering/android/canvas/internal/CanvasMeshRendererRobolectricTest.kt
@@ -0,0 +1,71 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.ink.rendering.android.canvas.internal
+
+import android.os.Build
+import androidx.ink.brush.Brush
+import androidx.ink.brush.ExperimentalInkCustomBrushApi
+import androidx.ink.brush.InputToolType
+import androidx.ink.brush.StockBrushes
+import androidx.ink.strokes.ImmutableStrokeInputBatch
+import androidx.ink.strokes.MutableStrokeInputBatch
+import androidx.ink.strokes.Stroke
+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.Test
+import org.junit.runner.RunWith
+
+/**
+ * Non-emulator logic test of [CanvasMeshRenderer].
+ *
+ * Code in this test cannot create an [android.graphics.MeshSpecification] or
+ * [android.graphics.Mesh], but it allows a limited subset of tests to run much more quickly.
+ *
+ * Note that in AndroidX, this test runs on the emulator rather than Robolectric, so it doesn't have
+ * a speed benefit.
+ */
+@SdkSuppress(minSdkVersion = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
+@RunWith(AndroidJUnit4::class)
+@MediumTest
+class CanvasMeshRendererRobolectricTest {
+    private val brush = Brush(family = StockBrushes.markerLatest, size = 10f, epsilon = 0.1f)
+
+    private val stroke =
+        Stroke(
+            brush = brush,
+            inputs =
+                MutableStrokeInputBatch()
+                    .addOrThrow(InputToolType.UNKNOWN, x = 10F, y = 10F, elapsedTimeMillis = 100)
+                    .asImmutable(),
+        )
+
+    @OptIn(ExperimentalInkCustomBrushApi::class) private val meshRenderer = CanvasMeshRenderer()
+
+    @Test
+    fun canDraw_withRenderableMesh_returnsTrue() {
+        assertThat(meshRenderer.canDraw(stroke)).isTrue()
+    }
+
+    @Test
+    fun canDraw_withEmptyStroke_returnsTrue() {
+        val emptyStroke = Stroke(brush, ImmutableStrokeInputBatch.EMPTY)
+
+        assertThat(meshRenderer.canDraw(emptyStroke)).isTrue()
+    }
+}
diff --git a/ink/ink-rendering/src/androidInstrumentedTest/kotlin/androidx/ink/rendering/android/canvas/internal/CanvasMeshRendererScreenshotTest.kt b/ink/ink-rendering/src/androidInstrumentedTest/kotlin/androidx/ink/rendering/android/canvas/internal/CanvasMeshRendererScreenshotTest.kt
new file mode 100644
index 0000000..07058a0
--- /dev/null
+++ b/ink/ink-rendering/src/androidInstrumentedTest/kotlin/androidx/ink/rendering/android/canvas/internal/CanvasMeshRendererScreenshotTest.kt
@@ -0,0 +1,59 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.ink.rendering.android.canvas.internal
+
+import android.os.Build
+import androidx.ink.rendering.android.canvas.SCREENSHOT_GOLDEN_DIRECTORY
+import androidx.test.espresso.Espresso.onView
+import androidx.test.espresso.action.ViewActions.captureToBitmap
+import androidx.test.espresso.matcher.ViewMatchers.isRoot
+import androidx.test.ext.junit.rules.ActivityScenarioRule
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.MediumTest
+import androidx.test.filters.SdkSuppress
+import androidx.test.screenshot.AndroidXScreenshotTestRule
+import androidx.test.screenshot.assertAgainstGolden
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/** Emulator-based screenshot test of [CanvasMeshRenderer]. */
+@SdkSuppress(minSdkVersion = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
+@RunWith(AndroidJUnit4::class)
+@MediumTest
+class CanvasMeshRendererScreenshotTest {
+
+    @get:Rule
+    val activityScenarioRule =
+        ActivityScenarioRule(CanvasMeshRendererScreenshotTestActivity::class.java)
+
+    @get:Rule val screenshotRule = AndroidXScreenshotTestRule(SCREENSHOT_GOLDEN_DIRECTORY)
+
+    @Test
+    fun onView_showsSimpleStroke() {
+        assertScreenshot("app_steady_state")
+    }
+
+    private fun assertScreenshot(filename: String) {
+        onView(isRoot())
+            .perform(
+                captureToBitmap() {
+                    it.assertAgainstGolden(screenshotRule, "${this::class.simpleName}_$filename")
+                }
+            )
+    }
+}
diff --git a/ink/ink-rendering/src/androidInstrumentedTest/kotlin/androidx/ink/rendering/android/canvas/internal/CanvasMeshRendererScreenshotTestActivity.kt b/ink/ink-rendering/src/androidInstrumentedTest/kotlin/androidx/ink/rendering/android/canvas/internal/CanvasMeshRendererScreenshotTestActivity.kt
new file mode 100644
index 0000000..7f68973
--- /dev/null
+++ b/ink/ink-rendering/src/androidInstrumentedTest/kotlin/androidx/ink/rendering/android/canvas/internal/CanvasMeshRendererScreenshotTestActivity.kt
@@ -0,0 +1,98 @@
+/*
+ * 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.ink.rendering.android.canvas.internal
+
+import android.annotation.SuppressLint
+import android.app.Activity
+import android.content.Context
+import android.graphics.Canvas
+import android.graphics.Matrix
+import android.os.Build
+import android.os.Bundle
+import android.view.View
+import androidx.annotation.RequiresApi
+import androidx.ink.brush.Brush
+import androidx.ink.brush.ExperimentalInkCustomBrushApi
+import androidx.ink.brush.InputToolType
+import androidx.ink.brush.StockBrushes
+import androidx.ink.strokes.ImmutableStrokeInputBatch
+import androidx.ink.strokes.MutableStrokeInputBatch
+import androidx.ink.strokes.Stroke
+
+/**
+ * An [Activity] to support [CanvasStrokeUnifiedRendererLegacyTest] by rendering a simple stroke.
+ */
+@SuppressLint("UseSdkSuppress") // SdkSuppress is on the test class.
+@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
+class CanvasMeshRendererScreenshotTestActivity : Activity() {
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        setContentView(StrokeView(this))
+    }
+
+    private inner class StrokeView(context: Context) : View(context) {
+
+        private val inputs =
+            MutableStrokeInputBatch()
+                .addOrThrow(InputToolType.UNKNOWN, x = 0F, y = 0F, elapsedTimeMillis = 100)
+                .addOrThrow(InputToolType.UNKNOWN, x = 80F, y = 100F, elapsedTimeMillis = 150)
+                .addOrThrow(InputToolType.UNKNOWN, x = 0F, y = 100F, elapsedTimeMillis = 200)
+                .addOrThrow(InputToolType.UNKNOWN, x = 80F, y = 0F, elapsedTimeMillis = 250)
+                .asImmutable()
+
+        // Pink twist stroke.
+        private val brush =
+            Brush.createWithColorIntArgb(
+                family = StockBrushes.markerLatest,
+                colorIntArgb = 0x80CC1A99.toInt(),
+                size = 10F,
+                epsilon = 0.1F,
+            )
+        private val stroke = Stroke(brush, inputs)
+        private val transform = Matrix.IDENTITY_MATRIX
+
+        // Green twist stroke, rotated and scaled up.
+        private val brush2 = brush.copyWithColorIntArgb(colorIntArgb = 0xCC33E666.toInt())
+        private val stroke2 = stroke.copy(brush2)
+        private val transform2 =
+            Matrix().apply {
+                postRotate(/* degrees= */ 30F)
+                postScale(7F, 7F)
+            }
+
+        // Stroke with no inputs, and therefore an empty [ModeledShape].
+        private val emptyStroke = Stroke(brush, ImmutableStrokeInputBatch.EMPTY)
+
+        @OptIn(ExperimentalInkCustomBrushApi::class) private val renderer = CanvasMeshRenderer()
+
+        override fun onDraw(canvas: Canvas) {
+            super.onDraw(canvas)
+
+            val xBetweenStrokes = 115F
+            val y = 100F
+            canvas.translate(20F, y)
+
+            // The empty stroke should of course not be visible, but the [draw] call should succeed.
+            renderer.draw(canvas, emptyStroke, Matrix.IDENTITY_MATRIX)
+            // Expected result: pink stroke on left, large green rotated stroke on right.
+            renderer.draw(canvas, stroke, transform)
+            canvas.translate(xBetweenStrokes, 0F)
+            renderer.draw(canvas, stroke2, transform2)
+        }
+    }
+}
diff --git a/ink/ink-rendering/src/androidInstrumentedTest/kotlin/androidx/ink/rendering/android/canvas/internal/CanvasMeshRendererTest.kt b/ink/ink-rendering/src/androidInstrumentedTest/kotlin/androidx/ink/rendering/android/canvas/internal/CanvasMeshRendererTest.kt
new file mode 100644
index 0000000..4e89280
--- /dev/null
+++ b/ink/ink-rendering/src/androidInstrumentedTest/kotlin/androidx/ink/rendering/android/canvas/internal/CanvasMeshRendererTest.kt
@@ -0,0 +1,261 @@
+/*
+ * 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.ink.rendering.android.canvas.internal
+
+import android.graphics.Matrix
+import android.graphics.RenderNode
+import android.os.Build
+import androidx.core.os.BuildCompat
+import androidx.ink.brush.Brush
+import androidx.ink.brush.ExperimentalInkCustomBrushApi
+import androidx.ink.brush.InputToolType
+import androidx.ink.brush.StockBrushes
+import androidx.ink.brush.color.Color
+import androidx.ink.brush.color.toArgb
+import androidx.ink.strokes.InProgressStroke
+import androidx.ink.strokes.MutableStrokeInputBatch
+import androidx.ink.strokes.Stroke
+import androidx.ink.strokes.testing.buildStrokeInputBatchFromPoints
+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.Test
+import org.junit.runner.RunWith
+
+/**
+ * Emulator-based logic test of [CanvasMeshRenderer].
+ *
+ * TODO(b/293163827) Move this to [CanvasMeshRendererRobolectricTest] once a shadow exists for
+ *   [android.graphics.MeshSpecification].
+ */
+@RunWith(AndroidJUnit4::class)
+@MediumTest
+@SdkSuppress(minSdkVersion = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
+class CanvasMeshRendererTest {
+
+    private val brush =
+        Brush.createWithColorIntArgb(
+            family = StockBrushes.markerLatest,
+            colorIntArgb = Color.Black.toArgb(),
+            size = 10F,
+            epsilon = 0.1F,
+        )
+
+    private val stroke =
+        Stroke(
+            brush = brush,
+            inputs =
+                MutableStrokeInputBatch()
+                    .addOrThrow(InputToolType.UNKNOWN, x = 10F, y = 10F, elapsedTimeMillis = 100)
+                    .asImmutable(),
+        )
+
+    private val clock = FakeClock()
+
+    @OptIn(ExperimentalInkCustomBrushApi::class)
+    private val meshRenderer = CanvasMeshRenderer(getDurationTimeMillis = clock::currentTimeMillis)
+
+    @Test
+    fun obtainShaderMetadata_whenCalledTwiceWithSamePackedInstance_returnsCachedValue() {
+        assertThat(stroke.shape.getRenderGroupCount()).isEqualTo(1)
+        val meshFormat = stroke.shape.renderGroupFormat(0)
+
+        assertThat(meshRenderer.obtainShaderMetadata(meshFormat, isPacked = true))
+            .isSameInstanceAs(meshRenderer.obtainShaderMetadata(meshFormat, isPacked = true))
+    }
+
+    @Test
+    fun obtainShaderMetadata_whenCalledTwiceWithEquivalentPackedFormat_returnsCachedValue() {
+        val anotherStroke =
+            Stroke(
+                brush = brush,
+                inputs =
+                    MutableStrokeInputBatch()
+                        .addOrThrow(
+                            InputToolType.UNKNOWN,
+                            x = 99F,
+                            y = 99F,
+                            elapsedTimeMillis = 100
+                        )
+                        .asImmutable(),
+            )
+
+        assertThat(stroke.shape.getRenderGroupCount()).isEqualTo(1)
+        val strokeFormat = stroke.shape.renderGroupFormat(0)
+        assertThat(anotherStroke.shape.getRenderGroupCount()).isEqualTo(1)
+        val anotherStrokeFormat = anotherStroke.shape.renderGroupFormat(0)
+
+        assertThat(meshRenderer.obtainShaderMetadata(anotherStrokeFormat, isPacked = true))
+            .isSameInstanceAs(meshRenderer.obtainShaderMetadata(strokeFormat, isPacked = true))
+    }
+
+    @Test
+    fun createAndroidMesh_fromInProgressStroke_returnsMesh() {
+        val inProgressStroke =
+            InProgressStroke().apply {
+                start(
+                    Brush.createWithColorIntArgb(StockBrushes.markerLatest, 0x44112233, 10f, 0.25f)
+                )
+                assertThat(
+                        enqueueInputs(
+                                buildStrokeInputBatchFromPoints(
+                                    floatArrayOf(10f, 20f, 100f, 120f),
+                                    startTime = 0L
+                                ),
+                                MutableStrokeInputBatch(),
+                            )
+                            .isSuccess
+                    )
+                    .isTrue()
+                assertThat(updateShape(3L).isSuccess).isTrue()
+            }
+        assertThat(meshRenderer.createAndroidMesh(inProgressStroke, coatIndex = 0, meshIndex = 0))
+            .isNotNull()
+    }
+
+    @Test
+    fun obtainShaderMetadata_whenCalledTwiceWithSameUnpackedInstance_returnsCachedValue() {
+        val inProgressStroke = InProgressStroke()
+        inProgressStroke.start(brush)
+        assertThat(inProgressStroke.getBrushCoatCount()).isEqualTo(1)
+        assertThat(inProgressStroke.getMeshPartitionCount(0)).isEqualTo(1)
+        val meshFormat = inProgressStroke.getMeshFormat(0, 0)
+
+        assertThat(meshRenderer.obtainShaderMetadata(meshFormat, isPacked = false))
+            .isSameInstanceAs(meshRenderer.obtainShaderMetadata(meshFormat, isPacked = false))
+    }
+
+    @Test
+    fun obtainShaderMetadata_whenCalledTwiceWithEquivalentUnpackedFormat_returnsCachedValue() {
+        val inProgressStroke = InProgressStroke()
+        inProgressStroke.start(brush)
+        assertThat(inProgressStroke.getBrushCoatCount()).isEqualTo(1)
+        assertThat(inProgressStroke.getMeshPartitionCount(0)).isEqualTo(1)
+
+        val anotherInProgressStroke = InProgressStroke()
+        anotherInProgressStroke.start(brush)
+        assertThat(anotherInProgressStroke.getBrushCoatCount()).isEqualTo(1)
+        assertThat(anotherInProgressStroke.getMeshPartitionCount(0)).isEqualTo(1)
+
+        assertThat(
+                meshRenderer.obtainShaderMetadata(
+                    inProgressStroke.getMeshFormat(0, 0),
+                    isPacked = false
+                )
+            )
+            .isSameInstanceAs(
+                meshRenderer.obtainShaderMetadata(
+                    anotherInProgressStroke.getMeshFormat(0, 0),
+                    isPacked = false,
+                )
+            )
+    }
+
+    @Test
+    fun drawStroke_whenAndroidU_shouldSaveRecentlyDrawnMesh() {
+        if (BuildCompat.isAtLeastV()) {
+            return
+        }
+        val renderNode = RenderNode("test")
+        val canvas = renderNode.beginRecording()
+        assertThat(meshRenderer.getRecentlyDrawnAndroidMeshesCount()).isEqualTo(0)
+
+        meshRenderer.draw(canvas, stroke, Matrix())
+        assertThat(meshRenderer.getRecentlyDrawnAndroidMeshesCount()).isEqualTo(1)
+
+        // New uniform value for transform scale, new mesh is created and drawn.
+        meshRenderer.draw(canvas, stroke, Matrix().apply { setScale(3F, 4F) })
+        assertThat(meshRenderer.getRecentlyDrawnAndroidMeshesCount()).isEqualTo(2)
+
+        // Same uniform value for transform scale, same mesh is drawn again.
+        meshRenderer.draw(canvas, stroke, Matrix().apply { setScale(3F, 4F) })
+        assertThat(meshRenderer.getRecentlyDrawnAndroidMeshesCount()).isEqualTo(2)
+
+        // Transform is the same but color is different, new mesh is created and drawn.
+        val strokeNewColor =
+            stroke.copy(stroke.brush.copyWithColorIntArgb(colorIntArgb = Color.White.toArgb()))
+        meshRenderer.draw(canvas, strokeNewColor, Matrix().apply { setScale(3F, 4F) })
+        assertThat(meshRenderer.getRecentlyDrawnAndroidMeshesCount()).isEqualTo(3)
+
+        // Move forward just a little bit of time, the same meshes should be saved.
+        clock.currentTimeMillis += 3500
+        meshRenderer.draw(canvas, strokeNewColor, Matrix().apply { setScale(3F, 4F) })
+        assertThat(meshRenderer.getRecentlyDrawnAndroidMeshesCount()).isEqualTo(3)
+
+        // Entirely different Ink mesh, so a new Android mesh is created and drawn.
+        val strokeNewMesh = stroke.copy(brush = stroke.brush.copy(size = 33F))
+        meshRenderer.draw(canvas, strokeNewMesh, Matrix())
+        assertThat(meshRenderer.getRecentlyDrawnAndroidMeshesCount()).isEqualTo(4)
+
+        // Move forward enough time that older meshes would be cleaned up, but not enough time to
+        // actually trigger a cleanup. This confirms that cleanup isn't attempted on every draw
+        // call,
+        // which would significantly degrade performance.
+        clock.currentTimeMillis += 1999
+        meshRenderer.draw(canvas, strokeNewMesh, Matrix())
+        assertThat(meshRenderer.getRecentlyDrawnAndroidMeshesCount()).isEqualTo(4)
+
+        // The next draw after enough time has passed should clean up the (no longer) recently drawn
+        // meshes.
+        clock.currentTimeMillis += 1
+        meshRenderer.draw(canvas, strokeNewColor, Matrix().apply { setScale(3F, 4F) })
+        assertThat(meshRenderer.getRecentlyDrawnAndroidMeshesCount()).isEqualTo(2)
+    }
+
+    /**
+     * Same set of steps as [drawStroke_whenAndroidU_shouldSaveRecentlyDrawnMesh], but there should
+     * never be any saved meshes.
+     */
+    @Test
+    fun drawStroke_whenAndroidVPlus_shouldNotSaveRecentlyDrawnMeshes() {
+        if (!BuildCompat.isAtLeastV()) {
+            return
+        }
+        val renderNode = RenderNode("test")
+        val canvas = renderNode.beginRecording()
+        assertThat(meshRenderer.getRecentlyDrawnAndroidMeshesCount()).isEqualTo(0)
+
+        meshRenderer.draw(canvas, stroke, Matrix())
+        assertThat(meshRenderer.getRecentlyDrawnAndroidMeshesCount()).isEqualTo(0)
+
+        meshRenderer.draw(canvas, stroke, Matrix().apply { setScale(3F, 4F) })
+        assertThat(meshRenderer.getRecentlyDrawnAndroidMeshesCount()).isEqualTo(0)
+
+        meshRenderer.draw(canvas, stroke, Matrix().apply { setScale(3F, 4F) })
+        assertThat(meshRenderer.getRecentlyDrawnAndroidMeshesCount()).isEqualTo(0)
+
+        val strokeNewColor =
+            stroke.copy(stroke.brush.copyWithColorIntArgb(colorIntArgb = Color.White.toArgb()))
+        meshRenderer.draw(canvas, strokeNewColor, Matrix().apply { setScale(3F, 4F) })
+        assertThat(meshRenderer.getRecentlyDrawnAndroidMeshesCount()).isEqualTo(0)
+
+        clock.currentTimeMillis += 2500
+        meshRenderer.draw(canvas, strokeNewColor, Matrix().apply { setScale(3F, 4F) })
+        assertThat(meshRenderer.getRecentlyDrawnAndroidMeshesCount()).isEqualTo(0)
+
+        val strokeNewMesh = stroke.copy(brush = stroke.brush.copy(size = 33F))
+        meshRenderer.draw(canvas, strokeNewMesh, Matrix())
+        assertThat(meshRenderer.getRecentlyDrawnAndroidMeshesCount()).isEqualTo(0)
+
+        clock.currentTimeMillis += 3000
+        meshRenderer.draw(canvas, strokeNewColor, Matrix().apply { setScale(3F, 4F) })
+        assertThat(meshRenderer.getRecentlyDrawnAndroidMeshesCount()).isEqualTo(0)
+    }
+
+    private class FakeClock(var currentTimeMillis: Long = 1000L)
+}
diff --git a/ink/ink-rendering/src/androidInstrumentedTest/kotlin/androidx/ink/rendering/android/view/ViewStrokeRendererTest.kt b/ink/ink-rendering/src/androidInstrumentedTest/kotlin/androidx/ink/rendering/android/view/ViewStrokeRendererTest.kt
new file mode 100644
index 0000000..438538a
--- /dev/null
+++ b/ink/ink-rendering/src/androidInstrumentedTest/kotlin/androidx/ink/rendering/android/view/ViewStrokeRendererTest.kt
@@ -0,0 +1,55 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.ink.rendering.android.view
+
+import androidx.ink.rendering.android.canvas.SCREENSHOT_GOLDEN_DIRECTORY
+import androidx.ink.rendering.test.R
+import androidx.test.espresso.Espresso.onView
+import androidx.test.espresso.action.ViewActions.captureToBitmap
+import androidx.test.espresso.matcher.ViewMatchers.withId
+import androidx.test.ext.junit.rules.ActivityScenarioRule
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.MediumTest
+import androidx.test.screenshot.AndroidXScreenshotTestRule
+import androidx.test.screenshot.assertAgainstGolden
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+@MediumTest
+class ViewStrokeRendererTest {
+
+    @get:Rule
+    val activityScenarioRule = ActivityScenarioRule(ViewStrokeRendererTestActivity::class.java)
+
+    @get:Rule val screenshotRule = AndroidXScreenshotTestRule(SCREENSHOT_GOLDEN_DIRECTORY)
+
+    @Test
+    fun drawWithStrokes_strokesAreAntialiased() {
+        assertScreenshot("StrokesAreAntialiased")
+    }
+
+    private fun assertScreenshot(filename: String) {
+        onView(withId(R.id.activity_root))
+            .perform(
+                captureToBitmap() {
+                    it.assertAgainstGolden(screenshotRule, "${this::class.simpleName}_$filename")
+                }
+            )
+    }
+}
diff --git a/ink/ink-rendering/src/androidInstrumentedTest/kotlin/androidx/ink/rendering/android/view/ViewStrokeRendererTestActivity.kt b/ink/ink-rendering/src/androidInstrumentedTest/kotlin/androidx/ink/rendering/android/view/ViewStrokeRendererTestActivity.kt
new file mode 100644
index 0000000..a27f8df
--- /dev/null
+++ b/ink/ink-rendering/src/androidInstrumentedTest/kotlin/androidx/ink/rendering/android/view/ViewStrokeRendererTestActivity.kt
@@ -0,0 +1,95 @@
+/*
+ * 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.ink.rendering.android.view
+
+import android.app.Activity
+import android.content.Context
+import android.graphics.Canvas
+import android.os.Bundle
+import android.view.View
+import android.widget.RelativeLayout
+import androidx.ink.brush.Brush
+import androidx.ink.brush.InputToolType
+import androidx.ink.brush.StockBrushes
+import androidx.ink.rendering.android.canvas.CanvasStrokeRenderer
+import androidx.ink.rendering.android.canvas.TestColors
+import androidx.ink.rendering.test.R
+import androidx.ink.strokes.MutableStrokeInputBatch
+import androidx.ink.strokes.Stroke
+
+/** An [Activity] to support [ViewStrokeRendererTest]. */
+class ViewStrokeRendererTestActivity : Activity() {
+    private val strokeRenderer = CanvasStrokeRenderer.create()
+
+    private val viewToScreenScaleX = 0.5F
+    private val viewToScreenRotation = -45F
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        setContentView(R.layout.view_stroke_renderer_test)
+
+        // Use a non-trivial view -> screen transform to check that it is correctly applied.
+        val layout = findViewById<RelativeLayout>(R.id.stroke_view_parent)
+        layout.scaleX = viewToScreenScaleX
+        layout.rotation = viewToScreenRotation
+        layout.addView(StrokeView(this, strokeRenderer))
+    }
+
+    /** A [View] that draws multiple transformed strokes using [CanvasStrokeRenderer]. */
+    private inner class StrokeView(context: Context, val strokeRenderer: CanvasStrokeRenderer) :
+        View(context) {
+
+        private val viewStrokeRenderer = ViewStrokeRenderer(strokeRenderer, this)
+
+        private val inputsTwist =
+            MutableStrokeInputBatch()
+                .addOrThrow(InputToolType.UNKNOWN, x = 30F, y = 0F, elapsedTimeMillis = 100)
+                .addOrThrow(InputToolType.UNKNOWN, x = 0F, y = 40F, elapsedTimeMillis = 150)
+                .addOrThrow(InputToolType.UNKNOWN, x = 40F, y = 70F, elapsedTimeMillis = 200)
+                .addOrThrow(InputToolType.UNKNOWN, x = 5F, y = 90F, elapsedTimeMillis = 250)
+                .asImmutable()
+
+        private val stroke =
+            Stroke(
+                Brush.createWithColorIntArgb(
+                    family = StockBrushes.markerLatest,
+                    colorIntArgb = TestColors.BLACK,
+                    size = 10f,
+                    epsilon = 0.1f,
+                ),
+                inputsTwist,
+            )
+
+        override fun onDraw(canvas: Canvas) {
+            super.onDraw(canvas)
+            canvas.drawColor(TestColors.YELLOW)
+
+            viewStrokeRenderer.drawWithStrokes(canvas) { scope ->
+                canvas.translate(300F, 300F)
+                canvas.scale(9F, 3F)
+
+                canvas.save()
+                canvas.rotate(-45F)
+                scope.drawStroke(stroke)
+                canvas.restore()
+
+                canvas.translate(0F, 50F)
+                scope.drawStroke(stroke)
+            }
+        }
+    }
+}
diff --git a/ink/ink-rendering/src/androidInstrumentedTest/res/drawable-mdpi/airplane_emoji.png b/ink/ink-rendering/src/androidInstrumentedTest/res/drawable-mdpi/airplane_emoji.png
new file mode 100644
index 0000000..8407cb6
--- /dev/null
+++ b/ink/ink-rendering/src/androidInstrumentedTest/res/drawable-mdpi/airplane_emoji.png
Binary files differ
diff --git a/ink/ink-rendering/src/androidInstrumentedTest/res/drawable-mdpi/checkerboard_black_and_transparent.png b/ink/ink-rendering/src/androidInstrumentedTest/res/drawable-mdpi/checkerboard_black_and_transparent.png
new file mode 100644
index 0000000..e26d8a4
--- /dev/null
+++ b/ink/ink-rendering/src/androidInstrumentedTest/res/drawable-mdpi/checkerboard_black_and_transparent.png
Binary files differ
diff --git a/ink/ink-rendering/src/androidInstrumentedTest/res/drawable-mdpi/circle.png b/ink/ink-rendering/src/androidInstrumentedTest/res/drawable-mdpi/circle.png
new file mode 100644
index 0000000..a613d10
--- /dev/null
+++ b/ink/ink-rendering/src/androidInstrumentedTest/res/drawable-mdpi/circle.png
Binary files differ
diff --git a/ink/ink-rendering/src/androidInstrumentedTest/res/drawable-mdpi/poop_emoji.png b/ink/ink-rendering/src/androidInstrumentedTest/res/drawable-mdpi/poop_emoji.png
new file mode 100644
index 0000000..73a4dc8
--- /dev/null
+++ b/ink/ink-rendering/src/androidInstrumentedTest/res/drawable-mdpi/poop_emoji.png
Binary files differ
diff --git a/ink/ink-rendering/src/androidInstrumentedTest/res/layout/canvas_stroke_renderer_test.xml b/ink/ink-rendering/src/androidInstrumentedTest/res/layout/canvas_stroke_renderer_test.xml
new file mode 100644
index 0000000..a2baef9
--- /dev/null
+++ b/ink/ink-rendering/src/androidInstrumentedTest/res/layout/canvas_stroke_renderer_test.xml
@@ -0,0 +1,57 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+
+Copyright (C) 2024 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+
+-->
+<GridLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:id="@+id/stroke_grid"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:columnCount="4"
+    android:rowCount="1"
+    tools:ignore="HardcodedText"
+    >
+
+  <!-- Labels for Canvas.draw* API along top of screen. -->
+
+  <TextView
+      android:layout_column="1"
+      android:layout_row="0"
+      android:layout_columnWeight="1"
+      android:layout_rowWeight="0"
+      android:layout_gravity="center_horizontal"
+      android:text="Mesh"
+      />
+
+  <TextView
+      android:layout_column="2"
+      android:layout_row="0"
+      android:layout_columnWeight="1"
+      android:layout_gravity="center_horizontal"
+      android:text="Path"
+      />
+
+  <TextView
+      android:layout_column="3"
+      android:layout_row="0"
+      android:layout_columnWeight="1"
+      android:layout_gravity="center_horizontal"
+      android:text="Default"
+      />
+
+</GridLayout>
diff --git a/ink/ink-rendering/src/androidInstrumentedTest/res/layout/view_stroke_renderer_test.xml b/ink/ink-rendering/src/androidInstrumentedTest/res/layout/view_stroke_renderer_test.xml
new file mode 100644
index 0000000..27cdf55
--- /dev/null
+++ b/ink/ink-rendering/src/androidInstrumentedTest/res/layout/view_stroke_renderer_test.xml
@@ -0,0 +1,29 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+
+Copyright (C) 2024 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+
+-->
+<FrameLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:id="@+id/activity_root"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent">
+  <RelativeLayout
+      android:id="@+id/stroke_view_parent"
+      android:layout_width="400dp"
+      android:layout_height="400dp"
+      />
+</FrameLayout>
diff --git a/ink/ink-rendering/src/androidMain/kotlin/androidx/ink/rendering/android/TextureBitmapStore.kt b/ink/ink-rendering/src/androidMain/kotlin/androidx/ink/rendering/android/TextureBitmapStore.kt
new file mode 100644
index 0000000..62c1efe
--- /dev/null
+++ b/ink/ink-rendering/src/androidMain/kotlin/androidx/ink/rendering/android/TextureBitmapStore.kt
@@ -0,0 +1,43 @@
+/*
+ * 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.ink.rendering.android
+
+import android.graphics.Bitmap
+import androidx.annotation.RestrictTo
+import androidx.ink.brush.ExperimentalInkCustomBrushApi
+
+/**
+ * Interface for a callback to allow the caller to provide a particular [Bitmap] for a texture URI.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) // NonPublicApi
+@ExperimentalInkCustomBrushApi
+public fun interface TextureBitmapStore {
+    /**
+     * Retrieve a [Bitmap] for the given texture URI. This may be called synchronously during
+     * `onDraw`, so loading of texture files from disk and decoding them into [Bitmap] objects
+     * should be done on init. The result may be cached by consumers, so this should return a
+     * deterministic result for a given input.
+     *
+     * Textures can be disabled by having load always return null. null should also be returned when
+     * a texture can not be loaded. If null is returned, the texture layer in question should be
+     * ignored, allowing for graceful fallback. It's recommended that implementations log when a
+     * texture can not be loaded.
+     *
+     * @return The texture bitmap, if any, associated with the given URI.
+     */
+    public fun get(textureImageUri: String): Bitmap?
+}
diff --git a/ink/ink-rendering/src/androidMain/kotlin/androidx/ink/rendering/android/canvas/CanvasStrokeRenderer.kt b/ink/ink-rendering/src/androidMain/kotlin/androidx/ink/rendering/android/canvas/CanvasStrokeRenderer.kt
new file mode 100644
index 0000000..e7782e9
--- /dev/null
+++ b/ink/ink-rendering/src/androidMain/kotlin/androidx/ink/rendering/android/canvas/CanvasStrokeRenderer.kt
@@ -0,0 +1,145 @@
+/*
+ * 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.ink.rendering.android.canvas
+
+import android.graphics.Canvas
+import android.graphics.Matrix
+import androidx.annotation.Px
+import androidx.annotation.RestrictTo
+import androidx.ink.brush.ExperimentalInkCustomBrushApi
+import androidx.ink.geometry.AffineTransform
+import androidx.ink.nativeloader.NativeLoader
+import androidx.ink.rendering.android.TextureBitmapStore
+import androidx.ink.rendering.android.canvas.internal.CanvasPathRenderer
+import androidx.ink.rendering.android.canvas.internal.CanvasStrokeUnifiedRenderer
+import androidx.ink.strokes.InProgressStroke
+import androidx.ink.strokes.Stroke
+
+/** Renders strokes to a [Canvas]. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) // PublicApiNotReadyForJetpackReview
+public interface CanvasStrokeRenderer {
+
+    /**
+     * Render a single [stroke] on the provided [canvas], with its positions transformed by
+     * [strokeToCanvasTransform].
+     *
+     * To avoid needing to calculate and maintain [strokeToCanvasTransform], consider using
+     * [ViewStrokeRenderer] instead.
+     *
+     * TODO: b/353561141 - Reference ComposeStrokeRenderer above once implemented.
+     *
+     * The [strokeToCanvasTransform] should represent the complete transformation from stroke
+     * coordinates to the canvas, modulo translation. Any existing transforms applied to [canvas]
+     * should be undone prior to calling [draw].
+     */
+    public fun draw(canvas: Canvas, stroke: Stroke, strokeToCanvasTransform: AffineTransform)
+
+    /**
+     * Render a single [stroke] on the provided [canvas], with its positions transformed by
+     * [strokeToCanvasTransform].
+     *
+     * To avoid needing to calculate and maintain [strokeToCanvasTransform], consider using
+     * [ViewStrokeRenderer].
+     *
+     * TODO: b/353561141 - Reference ComposeStrokeRenderer above once implemented.
+     *
+     * The [strokeToCanvasTransform] must be affine. It should represent the complete transformation
+     * from stroke coordinates to the canvas, modulo translation. Any existing transforms applied to
+     * [canvas] should be undone prior to calling [draw].
+     */
+    public fun draw(canvas: Canvas, stroke: Stroke, strokeToCanvasTransform: Matrix)
+
+    /**
+     * Render a single [inProgressStroke] on the provided [canvas], with its positions transformed
+     * by [strokeToCanvasTransform].
+     *
+     * The [strokeToCanvasTransform] should represent the complete transformation from stroke
+     * coordinates to the canvas, modulo translation. Any existing transforms applied to [canvas]
+     * should be undone prior to calling [draw].
+     */
+    public fun draw(
+        canvas: Canvas,
+        inProgressStroke: InProgressStroke,
+        strokeToCanvasTransform: AffineTransform,
+    )
+
+    /**
+     * Render a single [inProgressStroke] on the provided [canvas], with its positions transformed
+     * by [strokeToCanvasTransform].
+     *
+     * The [strokeToCanvasTransform] must be affine. It should represent the complete transformation
+     * from stroke coordinates to the canvas, modulo translation. Any existing transforms applied to
+     * [canvas] should be undone prior to calling [draw].
+     */
+    public fun draw(
+        canvas: Canvas,
+        inProgressStroke: InProgressStroke,
+        strokeToCanvasTransform: Matrix,
+    )
+
+    /**
+     * The distance beyond a stroke geometry's bounds that rendering might affect. This is currently
+     * only applicable to in-progress stroke rendering, where the smallest possible region of the
+     * screen is redrawn to optimize performance. But with a custom [CanvasStrokeRenderer], certain
+     * effects like drop shadows or blurs may render beyond the stroke's geometry, and setting a
+     * higher value here can ensure that artifacts are not left on screen after an in-progress
+     * stroke has moved on from a particular region of the screen. This value should be set to the
+     * lowest value that avoids the artifacts.
+     *
+     * Custom [CanvasStrokeRenderer] implementations are generally less efficient than achieving the
+     * same effect with a custom [BrushTip], as well as being less compatible with intersection and
+     * hit testing, and more features over time.
+     *
+     * Custom renderers are possible to maximize control over the final effect on screen, but
+     * consider filing a feature request to support your use case with [BrushTip] directly. The more
+     * your rendering relies on a bigger value here, the more likely it will run into complications
+     * later on as your client integration gets more complex or as we add more features.
+     */
+    @Px public fun strokeModifiedRegionOutsetPx(): Int = 3
+
+    public companion object {
+
+        init {
+            NativeLoader.load()
+        }
+
+        /** Create a [CanvasStrokeRenderer] that is appropriate to the device's API version. */
+        public fun create(): CanvasStrokeRenderer {
+            @OptIn(ExperimentalInkCustomBrushApi::class)
+            return create(textureStore = TextureBitmapStore { null })
+        }
+
+        /**
+         * Create a [CanvasStrokeRenderer] that is appropriate to the device's API version.
+         *
+         * @param textureStore The [TextureBitmapStore] that will be called to retrieve image data
+         *   for drawing textured strokes.
+         * @param forcePathRendering Overrides the drawing strategy selected based on API version to
+         *   always draw strokes using [Canvas.drawPath] instead of [Canvas.drawMesh].
+         */
+        @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) // NonPublicApi
+        @ExperimentalInkCustomBrushApi
+        @JvmOverloads
+        public fun create(
+            textureStore: TextureBitmapStore,
+            forcePathRendering: Boolean = false,
+        ): CanvasStrokeRenderer {
+            if (!forcePathRendering) return CanvasStrokeUnifiedRenderer(textureStore)
+            return CanvasPathRenderer(textureStore)
+        }
+    }
+}
diff --git a/ink/ink-rendering/src/androidMain/kotlin/androidx/ink/rendering/android/canvas/StrokeDrawScope.kt b/ink/ink-rendering/src/androidMain/kotlin/androidx/ink/rendering/android/canvas/StrokeDrawScope.kt
new file mode 100644
index 0000000..c856cd1
--- /dev/null
+++ b/ink/ink-rendering/src/androidMain/kotlin/androidx/ink/rendering/android/canvas/StrokeDrawScope.kt
@@ -0,0 +1,124 @@
+/*
+ * 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.ink.rendering.android.canvas
+
+import android.graphics.Canvas
+import android.graphics.Matrix
+import androidx.annotation.RestrictTo
+import androidx.ink.strokes.Stroke
+
+/**
+ * A utility to simplify usage of [CanvasStrokeRenderer] by automatically calculating the
+ * `strokeToCanvasTransform` parameter of [CanvasStrokeRenderer.draw]. Obtain an instance of this
+ * class using [ViewStrokeRenderer], if using [android.view.View]. Use this scope by calling its
+ * [drawStroke] function.
+ *
+ * TODO: b/353561141 - Reference ComposeStrokeRenderer above once implemented.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) // PublicApiNotReadyForJetpackReview
+public class StrokeDrawScope
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+constructor(private val renderer: CanvasStrokeRenderer) {
+
+    /**
+     * Pre-allocated value updated in [onDrawStart] holding a transform from the
+     * implementation-defined "initial" transform state of the canvas to screen coordinates as
+     * described below.
+     *
+     * We want to be able to calculate the complete (Local -> Screen) transform inside [drawStroke].
+     * The only options to track changes made by client code to the [Canvas] transform state are:
+     * 1. Require the user to explicitly pass the transform as in [CanvasStrokeRenderer],
+     * 2. Create and require clients to use a complete [Canvas] wrapper type, or
+     * 3. Make use of the deprecated [Canvas.getMatrix] method.
+     *
+     * Option 1 is provided, but cumbersome for clients. We are avoiding option 2, because it would
+     * also be cumbersome client code, and we would need to track every API adding or breaking
+     * change in [Canvas].
+     *
+     * Part of the reason for the deprecation documented on [Canvas.getMatrix] is that hardware
+     * accelerated canvases have an implementation-defined matrix value when passed to a `View`,
+     * because they may be anywhere in the `View` hierarchy. However, we can use the delta between
+     * the values returned by two calls to [Canvas.getMatrix] to find the relative change in
+     * transformations.
+     *
+     * We assume that any non-identity value of the matrix at the start of [onDrawStart] is already
+     * part of the [canvasToScreenTransform] passed to [onDrawStart] as shown in the following
+     * diagram:
+     *
+     *                     |-       [canvasToScreenTransform]         -|
+     *                     |                                           |
+     *                     |                   |-  canvas.getMatrix() -|
+     *                     |                   |    in [onDrawStart]   |
+     *
+     * (Local -> Screen) = (Initial -> Screen) * (Canvas ---> Initial) * (Local -> Canvas)
+     *
+     *                                         |           canvas.getMatrix()            |
+     *                                         |-           in [drawStroke]             -|
+     */
+    private val initialCanvasToScreenTransform = Matrix()
+    private lateinit var canvas: Canvas
+
+    /**
+     * Pre-allocated total transform from drawn object local coordinates to screen coordinates
+     * calculated once per call to [drawStroke].
+     */
+    private val localToScreenTransform = Matrix()
+
+    /**
+     * Pre-allocated inverse of [localToScreenTransform] calculated once per call to [drawStroke].
+     *
+     * TODO: b/353302113 - Delete once the renderer can draw without modifying canvas transform
+     *   state.
+     */
+    private val screenToLocalTransform = Matrix()
+
+    /** Overwrite this object for reuse. */
+    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+    public fun onDrawStart(canvasToScreenTransform: Matrix, newCanvas: Canvas) {
+        canvas = newCanvas
+        with(initialCanvasToScreenTransform) {
+            // (Canvas -> Initial)
+            @Suppress("DEPRECATION") canvas.getMatrix(this)
+            // (Initial -> Canvas)
+            invert(this)
+            // (Initial -> Screen) = (Canvas -> Screen) * (Initial -> Canvas)
+            postConcat(canvasToScreenTransform)
+        }
+    }
+
+    /** Draw the given [Stroke] to the [Canvas] represented by this scope. */
+    public fun drawStroke(stroke: Stroke) {
+        // First, calculate (Local -> Screen). That is the transform that the renderer needs.
+        with(localToScreenTransform) {
+            // (Local -> Initial)
+            @Suppress("DEPRECATION") canvas.getMatrix(this)
+            // (Local -> Screen) = (Initial -> Screen) * (Local -> Initial)
+            postConcat(initialCanvasToScreenTransform)
+        }
+
+        // Second, apply the inverse of (Local -> Screen) to the [Canvas], since the renderer will
+        // apply the provided transform to the [Canvas]. This cancels the two out.
+        // TODO: b/353302113 - Do not modify Canvas transform when new draw API is available.
+        canvas.save()
+        localToScreenTransform.invert(screenToLocalTransform)
+        canvas.concat(screenToLocalTransform)
+
+        renderer.draw(canvas, stroke, localToScreenTransform)
+
+        canvas.restore()
+    }
+}
diff --git a/ink/ink-rendering/src/androidMain/kotlin/androidx/ink/rendering/android/canvas/internal/BlendModeConversions.kt b/ink/ink-rendering/src/androidMain/kotlin/androidx/ink/rendering/android/canvas/internal/BlendModeConversions.kt
new file mode 100644
index 0000000..bac46ad
--- /dev/null
+++ b/ink/ink-rendering/src/androidMain/kotlin/androidx/ink/rendering/android/canvas/internal/BlendModeConversions.kt
@@ -0,0 +1,102 @@
+/*
+ * 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.
+ */
+
+@file:OptIn(ExperimentalInkCustomBrushApi::class)
+
+package androidx.ink.rendering.android.canvas.internal
+
+import android.graphics.BlendMode
+import android.graphics.PorterDuff
+import android.os.Build
+import android.util.Log
+import androidx.annotation.RequiresApi
+import androidx.ink.brush.BrushPaint
+import androidx.ink.brush.ExperimentalInkCustomBrushApi
+
+/** Returns the Android [PorterDuff.Mode] that is equivalent to this Ink [BrushPaint.BlendMode]. */
+internal fun BrushPaint.BlendMode.toPorterDuffMode() =
+    when (this) {
+        // Note that the MODULATE behavior is incorrectly called MULTIPLY in [PorterDuff.Mode].
+        BrushPaint.BlendMode.MODULATE -> PorterDuff.Mode.MULTIPLY
+        BrushPaint.BlendMode.DST_IN -> PorterDuff.Mode.DST_IN
+        BrushPaint.BlendMode.DST_OUT -> PorterDuff.Mode.DST_OUT
+        BrushPaint.BlendMode.SRC_ATOP -> PorterDuff.Mode.SRC_ATOP
+        BrushPaint.BlendMode.SRC_IN -> PorterDuff.Mode.SRC_IN
+        BrushPaint.BlendMode.SRC_OVER -> PorterDuff.Mode.SRC_OVER
+        BrushPaint.BlendMode.DST_OVER -> PorterDuff.Mode.DST_OVER
+        BrushPaint.BlendMode.SRC -> PorterDuff.Mode.SRC
+        BrushPaint.BlendMode.DST -> PorterDuff.Mode.DST
+        BrushPaint.BlendMode.SRC_OUT -> PorterDuff.Mode.SRC_OUT
+        BrushPaint.BlendMode.DST_ATOP -> PorterDuff.Mode.DST_ATOP
+        BrushPaint.BlendMode.XOR -> PorterDuff.Mode.XOR
+        else -> {
+            Log.e(
+                "BlendModeConversion",
+                "Unsupported BlendMode: $this. Using PorterDuff.Mode.MULTIPLY instead.",
+            )
+            PorterDuff.Mode.MULTIPLY
+        }
+    }
+
+/** Like [toPorterDuffMode], but with SRC and DST swapped. */
+internal fun BrushPaint.BlendMode.toReversePorterDuffMode() =
+    when (this) {
+        // Note that the MODULATE behavior is incorrectly called MULTIPLY in [PorterDuff.Mode].
+        BrushPaint.BlendMode.MODULATE -> PorterDuff.Mode.MULTIPLY
+        BrushPaint.BlendMode.DST_IN -> PorterDuff.Mode.SRC_IN
+        BrushPaint.BlendMode.DST_OUT -> PorterDuff.Mode.SRC_OUT
+        BrushPaint.BlendMode.SRC_ATOP -> PorterDuff.Mode.DST_ATOP
+        BrushPaint.BlendMode.SRC_IN -> PorterDuff.Mode.DST_IN
+        BrushPaint.BlendMode.SRC_OVER -> PorterDuff.Mode.DST_OVER
+        BrushPaint.BlendMode.DST_OVER -> PorterDuff.Mode.SRC_OVER
+        BrushPaint.BlendMode.SRC -> PorterDuff.Mode.DST
+        BrushPaint.BlendMode.DST -> PorterDuff.Mode.SRC
+        BrushPaint.BlendMode.SRC_OUT -> PorterDuff.Mode.DST_OUT
+        BrushPaint.BlendMode.DST_ATOP -> PorterDuff.Mode.SRC_ATOP
+        BrushPaint.BlendMode.XOR -> PorterDuff.Mode.XOR
+        else -> {
+            Log.e(
+                "BlendModeConversion",
+                "Unsupported TextureBlendMode: $this. Using PorterDuff.Mode.MULTIPLY instead.",
+            )
+            PorterDuff.Mode.MULTIPLY
+        }
+    }
+
+/** Returns the Android [BlendMode] that is equivalent to this Ink [BrushPaint.BlendMode]. */
+@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
+internal fun BrushPaint.BlendMode.toBlendMode() =
+    when (this) {
+        BrushPaint.BlendMode.MODULATE -> BlendMode.MODULATE
+        BrushPaint.BlendMode.DST_IN -> BlendMode.DST_IN
+        BrushPaint.BlendMode.DST_OUT -> BlendMode.DST_OUT
+        BrushPaint.BlendMode.SRC_ATOP -> BlendMode.SRC_ATOP
+        BrushPaint.BlendMode.SRC_IN -> BlendMode.SRC_IN
+        BrushPaint.BlendMode.SRC_OVER -> BlendMode.SRC_OVER
+        BrushPaint.BlendMode.DST_OVER -> BlendMode.DST_OVER
+        BrushPaint.BlendMode.SRC -> BlendMode.SRC
+        BrushPaint.BlendMode.DST -> BlendMode.DST
+        BrushPaint.BlendMode.SRC_OUT -> BlendMode.SRC_OUT
+        BrushPaint.BlendMode.DST_ATOP -> BlendMode.DST_ATOP
+        BrushPaint.BlendMode.XOR -> BlendMode.XOR
+        else -> {
+            Log.e(
+                "BlendModeConversion",
+                "Unsupported BlendMode: $this. Using BlendMode.MODULATE instead.",
+            )
+            BlendMode.MODULATE
+        }
+    }
diff --git a/ink/ink-rendering/src/androidMain/kotlin/androidx/ink/rendering/android/canvas/internal/BrushPaintCache.kt b/ink/ink-rendering/src/androidMain/kotlin/androidx/ink/rendering/android/canvas/internal/BrushPaintCache.kt
new file mode 100644
index 0000000..fd15111
--- /dev/null
+++ b/ink/ink-rendering/src/androidMain/kotlin/androidx/ink/rendering/android/canvas/internal/BrushPaintCache.kt
@@ -0,0 +1,361 @@
+/*
+ * 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.ink.rendering.android.canvas.internal
+
+import android.graphics.Bitmap
+import android.graphics.BitmapShader
+import android.graphics.Color
+import android.graphics.ComposeShader
+import android.graphics.Matrix
+import android.graphics.Paint
+import android.graphics.PorterDuffColorFilter
+import android.graphics.Shader
+import android.os.Build
+import androidx.annotation.ColorInt
+import androidx.annotation.FloatRange
+import androidx.ink.brush.BrushPaint
+import androidx.ink.brush.ExperimentalInkCustomBrushApi
+import androidx.ink.rendering.android.TextureBitmapStore
+import androidx.ink.strokes.StrokeInput
+import java.util.WeakHashMap
+
+/**
+ * Helper class for obtaining [Paint] from [BrushPaint].
+ *
+ * @param paintFlags Used to set [Paint.flags] for all [Paint] objects it creates.
+ * @param applyColorFilterToTexture If true, the [BrushPaint] and the provided color are used to
+ *   configure [Paint.colorFilter] to apply a color to the paint's shader. This should generally be
+ *   set when using an API that expects a color to be uniformly applied by the Paint, instead of
+ *   providing per-vertex-modified colors to the draw call.
+ */
+@OptIn(ExperimentalInkCustomBrushApi::class)
+internal class BrushPaintCache(
+    val textureStore: TextureBitmapStore,
+    val additionalPaintFlags: Int = 0,
+    val applyColorFilterToTexture: Boolean = false,
+) {
+
+    /** Holds onto the [Paint] for each [BrushPaint] for efficiency. */
+    private val paintCache = WeakHashMap<BrushPaint, PaintCacheData>()
+
+    /** Used to construct and update a shader, holding on to data that's needed for later update. */
+    private inner class ShaderHelper(
+        private val textureLayers: List<BrushPaint.TextureLayer>,
+        private val bitmaps: List<Bitmap?>,
+        private val bitmapShaders: List<Shader?>,
+    ) {
+        private val scratchMatrix = Matrix()
+
+        private val bitmapShaderLocalMatrices: List<Matrix?>? =
+            if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
+                // `Shader.setLocalMatrix` saves the `Matrix` instance rather than copying its data
+                // to an
+                // internal instance before API 26, so allocate dedicated `Matrix` instances for
+                // those
+                // shaders to avoid accidentally clobbering data. After API 26, `scratchMatrix` can
+                // be used
+                // for all layers to save allocations.
+                bitmapShaders.map { if (it == null) null else Matrix() }
+            } else {
+                null
+            }
+
+        init {
+            require(
+                bitmaps.size == textureLayers.size && bitmapShaders.size == textureLayers.size
+            ) {
+                "textureLayers, bitmaps, and bitmapShaders should be parallel lists."
+            }
+            for (i in 0 until textureLayers.size) {
+                require(bitmapShaders[i] == null || bitmaps[i] != null) {
+                    "bitmap[$i] should be non-null if bitmapShaders[$i] is non-null."
+                }
+            }
+        }
+
+        fun updateInternalToStrokeTransform(
+            @FloatRange(from = 0.0) brushSize: Float,
+            firstInput: StrokeInput,
+            lastInput: StrokeInput,
+            internalToStrokeTransform: Matrix?,
+        ) {
+            for (i in 0 until textureLayers.size) {
+                val bitmapShader = bitmapShaders[i] ?: continue
+                val textureLayer = textureLayers[i]
+                val bitmap =
+                    checkNotNull(bitmaps[i]) {
+                        "bitmap[$i] should be non-null if bitmapShaders[$i] is non-null."
+                    }
+                val scratchShaderLocalMatrix =
+                    if (bitmapShaderLocalMatrices != null) {
+                        checkNotNull(bitmapShaderLocalMatrices[i]) {
+                            "bitmapShaderLocalMatrices[$i] shouldbe non-null if bitmapShader[$i] is non-null."
+                        }
+                    } else {
+                        scratchMatrix
+                    }
+                // The texture coordinates being drawn are in the mesh's "internal" coordinate space
+                // (which
+                // for legacy strokes may be different than the publicly facing stroke coordinate
+                // space). However, [BitmapShader] assumes we're working with texel coordinates, so
+                // we need
+                // to compute the combined chain of transforms from that coordinate space to
+                // "internal" mesh
+                // space.
+                val texelToInternalTransform =
+                    scratchShaderLocalMatrix.also {
+                        // At the end of this chain of transforms, we'll need to go from stroke
+                        // space to
+                        // "internal" mesh space. Start by computing that, then we'll work
+                        // backwards.
+                        //
+                        // Compute (stroke -> internal) = (internal -> stroke)^-1
+                        //
+                        // Note that internalToStrokeTransform is nullable; if null, we treat it as
+                        // an identity
+                        // matrix, but skip the needless call to [invert].
+                        it.reset()
+                        internalToStrokeTransform?.invert(it)
+
+                        // While we're in stroke space, shift the origin to the position specified
+                        // by the
+                        // [TextureLayer].
+                        when (textureLayer.origin) {
+                            BrushPaint.TextureOrigin.STROKE_SPACE_ORIGIN -> {}
+                            BrushPaint.TextureOrigin.FIRST_STROKE_INPUT -> {
+                                it.preTranslate(firstInput.x, firstInput.y)
+                            }
+                            BrushPaint.TextureOrigin.LAST_STROKE_INPUT -> {
+                                it.preTranslate(lastInput.x, lastInput.y)
+                            }
+                        }
+
+                        // To get to stroke space, we first need to scale from the coordinate space
+                        // where
+                        // distance is measured in the chosen SizeUnit for this particular texture
+                        // layer.
+                        //
+                        // Compute (SizeUnit -> internal) = (stroke -> internal) * (SizeUnit ->
+                        // stroke)
+                        when (textureLayer.sizeUnit) {
+                            BrushPaint.TextureSizeUnit.BRUSH_SIZE ->
+                                it.preScale(brushSize, brushSize)
+                            BrushPaint.TextureSizeUnit.STROKE_SIZE -> {
+                                // TODO: b/336835642 - Implement BrushPaintCache support for
+                                // TextureSizeUnit.STROKE_SIZE.
+                            }
+                            BrushPaint.TextureSizeUnit.STROKE_COORDINATES -> {
+                                // Nothing to do, since stroke space and SizeUnit space are
+                                // identical.
+                            }
+                        }
+
+                        // To get to SizeUnit space, we first need to scale from the texture UV
+                        // coordinate
+                        // space; that is, the coordinate space where the texture image is a unit
+                        // square.
+                        //
+                        // Compute (UV -> internal) = (SizeUnit -> internal) * (UV -> SizeUnit)
+                        it.preScale(textureLayer.sizeX, textureLayer.sizeY)
+
+                        // The texture offset is specified as fractions of the texture size; in
+                        // other words, it
+                        // should be applied within texture UV space.
+                        it.preTranslate(textureLayer.offsetX, textureLayer.offsetY)
+
+                        // To get to texture UV space, we first need to scale from the coordinate
+                        // space where
+                        // distance is measured in texels; that is, where each texel is a unit
+                        // square.
+                        //
+                        // Compute (texel -> internal) = (UV -> internal) * (texel -> UV)
+                        it.preScale(1f / bitmap.width, 1f / bitmap.height)
+                    }
+                // Do not use Matrix.isIdentity - it returns false for the identity matrix on
+                // earlier API
+                // levels.
+                val localMatrix =
+                    if (texelToInternalTransform == IDENTITY_MATRIX) {
+                        null
+                    } else {
+                        texelToInternalTransform
+                    }
+                bitmapShader.setLocalMatrix(localMatrix)
+            }
+        }
+    }
+
+    private class ColorFilterHelper {
+        private @ColorInt var colorFilterColor: Int = 0
+
+        fun updateColorFilterColor(
+            paint: Paint,
+            brushPaint: BrushPaint,
+            @ColorInt paintColor: Int
+        ) {
+            if (paint.colorFilter != null && colorFilterColor == paintColor) return
+            val lastTextureLayer =
+                requireNotNull(brushPaint.textureLayers.lastOrNull()) {
+                    "Paint.colorFilter should only be used when Paint.shader is set, which should only " +
+                        "happen when there is at least one item in BrushPaint.textureLayers."
+                }
+            // In [CanvasMeshRenderer], when we call [Canvas.drawMesh] with the last texture layer's
+            // blend
+            // mode, that method treats the mesh color as the DST, and the shader texture as the SRC
+            // (which matches how we've specified the meaning of [BrushPaint.BlendMode]). Here, we
+            // are
+            // using a color filter to emulate that behavior for the sake of [CanvasPathRenderer],
+            // but the
+            // color filter treats [paintColor] as the SRC, and the path texture as the DST.  So we
+            // need
+            // to use [toReversePorterDuffMode] here so as to swap SRC and DST from what the
+            // [BrushPaint.BlendMode] says.
+            val colorBlendMode = lastTextureLayer.blendMode.toReversePorterDuffMode()
+            paint.colorFilter = PorterDuffColorFilter(paintColor, colorBlendMode)
+            colorFilterColor = paintColor
+        }
+    }
+
+    private fun createCacheData(brushPaint: BrushPaint): PaintCacheData {
+        val paint =
+            Paint(additionalPaintFlags).apply {
+                // This sets Paint.FILTER_BITMAP_FLAG for consistency. For Android versions <= O,
+                // bilinear
+                // sampling is always used on scaled bitmaps when hardware acceleration is available
+                // and
+                // the behavior depends on this flag otherwise. Starting at Android Q, this flag is
+                // set by
+                // default. So setting it results in consistent behavior for Android P and for <= O
+                // when
+                // hardware acceleration is not available.
+                setFilterBitmap(true)
+            }
+        val textureLayers = brushPaint.textureLayers
+        if (textureLayers.isEmpty()) {
+            // Early exit for efficiency.
+            return PaintCacheData(paint)
+        }
+        val bitmaps = textureLayers.map { textureStore.get(it.colorTextureUri) }
+        val bitmapShaders =
+            bitmaps.map { bitmap ->
+                if (bitmap == null) return@map null
+                BitmapShader(bitmap, Shader.TileMode.REPEAT, Shader.TileMode.REPEAT)
+            }
+        // Each layer is combined with the result of combining all of the previous layers, using the
+        // immediately previous layer's blend mode. (Effectively, ComposeShader acts as the non-leaf
+        // nodes in a binary tree; more like a linked-list in this case because the destination side
+        // is
+        // always a leaf.) No layers reduce to null, a single layer reduces to the single
+        // BitmapShader.
+        paint.shader =
+            bitmapShaders.reduceIndexedOrNull<Shader?, Shader?> { i, acc, shader ->
+                when {
+                    // TextureLayers that fail to resolve to a texture Bitmap are ignored. This
+                    // seems like
+                    // clearer behavior than refusing to apply the whole texture, and a more gentle
+                    // fallback
+                    // than crashing. It also allows textures to be disabled with a
+                    // TextureBitmapStore whose
+                    // load method returns null.
+                    acc == null -> shader
+                    shader == null -> acc
+                    // The constructor arguments are destination, source, blend mode.
+                    else ->
+                        ComposeShader(
+                            shader,
+                            acc,
+                            textureLayers[i - 1].blendMode.toPorterDuffMode()
+                        )
+                }
+            }
+        return PaintCacheData(
+            paint,
+            // Only construct the ShaderHelper if we actually loaded some texture bitmaps and
+            // generated
+            // a shader.
+            if (paint.shader != null) ShaderHelper(textureLayers, bitmaps, bitmapShaders) else null,
+            if (applyColorFilterToTexture && paint.shader != null) ColorFilterHelper() else null,
+        )
+    }
+
+    private fun PaintCacheData.update(
+        brushPaint: BrushPaint,
+        @ColorInt paintColor: Int,
+        @FloatRange(from = 0.0) brushSize: Float,
+        firstInput: StrokeInput,
+        lastInput: StrokeInput,
+        internalToStrokeTransform: Matrix?,
+    ) {
+        shaderHelper?.updateInternalToStrokeTransform(
+            brushSize,
+            firstInput,
+            lastInput,
+            internalToStrokeTransform,
+        )
+        if (colorFilterHelper != null) {
+            colorFilterHelper.updateColorFilterColor(paint, brushPaint, paintColor)
+            paint.color = Color.WHITE
+        } else {
+            paint.color = paintColor
+        }
+    }
+
+    /**
+     * Obtains a [Paint] for the [BrushPaint] from the cache, creating it if necessary and updating
+     * it with the current [internalToStrokeTransform]. If [BrushPaint.TextureLayer.colorTextureUri]
+     * can't be resolved to a bitmap for any layer, that layer is ignored.
+     *
+     * @param brushPaint Used to configure [Paint.shader].
+     * @param paintColor Used to set [Paint.color].
+     * @param brushSize Used for supporting [BrushPaint.TextureSizeUnit.BRUSH_SIZE].
+     * @param firstInput Used for supporting [BrushPaint.TextureOrigin.FIRST_STROKE_INPUT].
+     * @param lastInput Used for supporting [BrushPaint.TextureOrigin.LAST_STROKE_INPUT].
+     * @param internalToStrokeTransform Used to update the local matrix of [Paint.shader] if
+     *   applicable. Defaults to null, which is treated equivalently to the identity matrix.
+     */
+    fun obtain(
+        brushPaint: BrushPaint,
+        @ColorInt paintColor: Int,
+        @FloatRange(from = 0.0) brushSize: Float,
+        firstInput: StrokeInput,
+        lastInput: StrokeInput,
+        internalToStrokeTransform: Matrix? = null,
+    ): Paint {
+        val cached = paintCache.getOrPut(brushPaint) { createCacheData(brushPaint) }
+        cached.update(
+            brushPaint,
+            paintColor,
+            brushSize,
+            firstInput,
+            lastInput,
+            internalToStrokeTransform,
+        )
+        return cached.paint
+    }
+
+    private class PaintCacheData(
+        val paint: Paint,
+        val shaderHelper: ShaderHelper? = null,
+        val colorFilterHelper: ColorFilterHelper? = null,
+    )
+
+    private companion object {
+        // Would be better to use the immutable [Matrix.IDENTITY_MATRIX], but that's in API version
+        // 31.
+        val IDENTITY_MATRIX = Matrix()
+    }
+}
diff --git a/ink/ink-rendering/src/androidMain/kotlin/androidx/ink/rendering/android/canvas/internal/CanvasMeshRenderer.kt b/ink/ink-rendering/src/androidMain/kotlin/androidx/ink/rendering/android/canvas/internal/CanvasMeshRenderer.kt
new file mode 100644
index 0000000..ebd2194
--- /dev/null
+++ b/ink/ink-rendering/src/androidMain/kotlin/androidx/ink/rendering/android/canvas/internal/CanvasMeshRenderer.kt
@@ -0,0 +1,995 @@
+/*
+ * 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.ink.rendering.android.canvas.internal
+
+import android.graphics.BlendMode
+import android.graphics.Canvas
+import android.graphics.Color as AndroidColor
+import android.graphics.ColorSpace as AndroidColorSpace
+import android.graphics.Matrix
+import android.graphics.Mesh as AndroidMesh
+import android.graphics.MeshSpecification
+import android.graphics.Paint
+import android.graphics.RectF
+import android.os.Build
+import android.os.SystemClock
+import androidx.annotation.RequiresApi
+import androidx.annotation.Size
+import androidx.annotation.VisibleForTesting
+import androidx.collection.MutableObjectLongMap
+import androidx.core.os.BuildCompat
+import androidx.ink.brush.BrushPaint
+import androidx.ink.brush.ExperimentalInkCustomBrushApi
+import androidx.ink.brush.color.Color as ComposeColor
+import androidx.ink.brush.color.colorspace.ColorSpaces as ComposeColorSpaces
+import androidx.ink.geometry.AffineTransform
+import androidx.ink.geometry.BoxAccumulator
+import androidx.ink.geometry.Mesh as InkMesh
+import androidx.ink.geometry.MeshAttributeUnpackingParams
+import androidx.ink.geometry.MeshFormat
+import androidx.ink.geometry.populateMatrix
+import androidx.ink.nativeloader.NativeLoader
+import androidx.ink.rendering.android.TextureBitmapStore
+import androidx.ink.rendering.android.canvas.CanvasStrokeRenderer
+import androidx.ink.strokes.InProgressStroke
+import androidx.ink.strokes.Stroke
+import androidx.ink.strokes.StrokeInput
+import java.util.WeakHashMap
+
+/**
+ * Renders Ink objects using [Canvas.drawMesh]. This is the most fully-featured and performant
+ * [Canvas] Ink renderer.
+ *
+ * This is not thread safe, so if it must be used from multiple threads, the caller is responsible
+ * for synchronizing access. If it is being used in two very different contexts where there are
+ * unlikely to be cached mesh data in common, the easiest solution to thread safety is to have two
+ * different instances of this object.
+ */
+@OptIn(ExperimentalInkCustomBrushApi::class)
+@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
+internal class CanvasMeshRenderer(
+    textureStore: TextureBitmapStore = TextureBitmapStore { null },
+    /** Monotonic time with a non-epoch zero time. */
+    private val getDurationTimeMillis: () -> Long = SystemClock::elapsedRealtime,
+) : CanvasStrokeRenderer {
+
+    /** Caches [Paint] objects so these can be reused between strokes with the same [BrushPaint]. */
+    private val paintCache = BrushPaintCache(textureStore)
+
+    /**
+     * Caches [android.graphics.Mesh] instances so that they can be reused between calls to
+     * [Canvas.drawMesh], greatly improving performance.
+     *
+     * On Android U, a bug in [android.graphics.Mesh] uniform handling causes a mesh rendered twice
+     * with different uniform values to overwrite the first draw's uniform values with the second
+     * draw's values. Therefore, [MeshData] tracks the most recent uniform data that a mesh has been
+     * drawn with, and if the next draw differs from the previous draw, a new
+     * [android.graphics.Mesh] will be created to satisfy it. This allows the typical use case,
+     * where a stroke is drawn the same way frame to frame, to remain fast and reuse cached
+     * [android.graphics.Mesh] instances. But less typical use cases, like animations that change
+     * the color or scale/rotation of strokes, will still work but will be a little slower.
+     *
+     * On Android V+, this bug has been fixed, so the same [android.graphics.Mesh] can be reused
+     * with different uniform values even within the same frame. Therefore, the extra data in
+     * [MeshData] is ignored, and it will just contain the values that were first used when
+     * rendering the associated [InkMesh].
+     */
+    private val inkMeshToAndroidMesh = WeakHashMap<InkMesh, MeshData>()
+
+    /**
+     * On Android U, this holds strong references to [android.graphics.Mesh] instances that were
+     * recently used in [draw], so that they aren't garbage collected too soon causing their
+     * underlying memory to be freed before the render thread can use it. Otherwise, the render
+     * thread may use the memory after it is freed, leading to undefined behavior (typically a
+     * crash). The values of this map are the last time (according to [getDurationTimeMillis]) that
+     * the corresponding mesh has been drawn, so that its contents can be periodically evicted to
+     * keep memory usage under control.
+     *
+     * On Android V+, this bug has been fixed, so this map will remain empty.
+     */
+    private val recentlyDrawnMeshesToLastDrawTimeMillis = MutableObjectLongMap<AndroidMesh>()
+
+    /**
+     * The last time that [recentlyDrawnMeshesToLastDrawTimeMillis] was checked for old meshes that
+     * can be cleaned up.
+     */
+    private var recentlyDrawnMeshesLastCleanupTimeMillis = Long.MIN_VALUE
+
+    /**
+     * Cached [android.graphics.Mesh]es so that they can be reused between calls to
+     * [Canvas.drawMesh], greatly improving performance. Each [InProgressStroke] maps to a list of
+     * [InProgressMeshData] objects, one for each brush coat. Because [InProgressStroke] is mutable,
+     * this cache is based not just on the existence of data, but whether that data's version number
+     * matches that of the [InProgressStroke].
+     */
+    private val inProgressStrokeToAndroidMeshes =
+        WeakHashMap<InProgressStroke, List<InProgressMeshData>>()
+
+    /**
+     * Caches [ShaderMetadata]s so that when two [MeshFormat] objects have equivalent packed
+     * representations (see [MeshFormat.isPackedEquivalent]), the same [ShaderMetadata] object can
+     * be used instead of reconstructed. This is a list instead of a map because:
+     * 1. There should never be more than ~9 unique values of [MeshFormat], so a linear scan is not
+     *    an undue cost when constructing a new [android.graphics.Mesh].
+     * 2. [MeshFormat] does not implement `hashCode` and `equals` in a way that would be relevant
+     *    here, since we only care about the packed representation for this use case. This could be
+     *    worked around with a wrapper type of [MeshFormat] that is specific to the packed
+     *    representation, but it didn't seem worth the extra effort.
+     *
+     * @See [obtainShaderMetadata] for the management of this cache.
+     */
+    private val meshFormatToPackedShaderMetadata = ArrayList<Pair<MeshFormat, ShaderMetadata>>()
+
+    /**
+     * Holds a mapping from [MeshFormat] to [ShaderMetadata], so that when two [MeshFormat] objects
+     * are equivalent when it comes to their unpacked representation (see
+     * [MeshFormat.isUnpackedEquivalent]), then the same [MeshSpecification] object can be used
+     * instead of reconstructed. This is a list instead of a map because
+     * 1. There should never be more than ~9 unique values of [MeshFormat], so a linear scan is not
+     *    an undue cost when constructing a new [android.graphics.Mesh].
+     * 2. [MeshFormat] does not implement `hashCode` and `equals` in a way that would be relevant
+     *    here, since we only care about the unpacked representation for this use case.
+     *
+     * @See [obtainShaderMetadata] for the management of this cache.
+     */
+    private val meshFormatToUnpackedShaderMetadata = ArrayList<Pair<MeshFormat, ShaderMetadata>>()
+
+    /** Scratch [Matrix] used for draw calls taking an [AffineTransform]. */
+    private val scratchMatrix = Matrix()
+
+    /** Scratch space used as the argument to [Matrix.getValues]. */
+    private val matrixValuesScratchArray = FloatArray(9)
+
+    /** Scratch space used to hold the scale/skew components of a [Matrix] in column-major order. */
+    @Size(4) private val objectToCanvasLinearComponentScratch = FloatArray(4)
+
+    /** Allocated once and reused for performance, passed to [AndroidMesh.setFloatUniform]. */
+    private val colorRgbaScratchArray = FloatArray(4)
+
+    // First and last inputs for the stroke being rendered, reused so that we don't need to allocate
+    // new ones for every stroke.
+    private val scratchFirstInput = StrokeInput()
+    private val scratchLastInput = StrokeInput()
+
+    override fun draw(canvas: Canvas, stroke: Stroke, strokeToCanvasTransform: AffineTransform) {
+        strokeToCanvasTransform.populateMatrix(scratchMatrix)
+        draw(canvas, stroke, scratchMatrix)
+    }
+
+    /**
+     * Draw a [Stroke] to the [Canvas].
+     *
+     * @param canvas The [Canvas] to draw to.
+     * @param stroke The [Stroke] to draw.
+     * @param strokeToCanvasTransform The transform [Matrix] to convert from [Stroke] actual
+     *   coordinates to the coordinates of [canvas]. It is important to pass this here to be applied
+     *   internally rather than applying it to [canvas] in calling code, to ensure anti-aliasing has
+     *   the information it needs to render properly. In addition, any transforms previously applied
+     *   to [canvas] must only be translations, or rotations in multiples of 90 degrees. If you are
+     *   not transforming [canvas] yourself then this will be correct, as the [android.view.View]
+     *   hierarchy applies only translations by default. If you are rendering in a
+     *   [android.view.View] where it (or one of its ancestors) is rotated or scaled within its
+     *   parent, or if you are applying rotation or scaling transforms to [canvas] yourself, then
+     *   care must be taken to undo those transforms before calling this method, and calling this
+     *   method with a full stroke-to-screen (modulo translation or multi-90 degree rotation)
+     *   transform. Without this, anti-aliasing at the edge of strokes will not render properly.
+     */
+    override fun draw(canvas: Canvas, stroke: Stroke, strokeToCanvasTransform: Matrix) {
+        require(strokeToCanvasTransform.isAffine) { "strokeToCanvasTransform must be affine" }
+        if (stroke.inputs.isEmpty()) return // nothing to draw
+        stroke.inputs.populate(0, scratchFirstInput)
+        stroke.inputs.populate(stroke.inputs.size - 1, scratchLastInput)
+        for (coatIndex in 0 until stroke.brush.family.coats.size) {
+            val meshes = stroke.shape.renderGroupMeshes(coatIndex)
+            if (meshes.isEmpty()) continue
+            val brushPaint = stroke.brush.family.coats[coatIndex].paint
+            val blendMode = finalBlendMode(brushPaint)
+            // A white paint color ensures that the paint color doesn't affect how the paint texture
+            // is blended with the mesh coloring.
+            val androidPaint =
+                paintCache.obtain(
+                    brushPaint,
+                    AndroidColor.WHITE,
+                    stroke.brush.size,
+                    scratchFirstInput,
+                    scratchLastInput,
+                )
+            for (mesh in meshes) {
+                drawFromStroke(
+                    canvas,
+                    mesh,
+                    strokeToCanvasTransform,
+                    stroke.brush.composeColor,
+                    blendMode,
+                    androidPaint,
+                )
+            }
+        }
+    }
+
+    /** Draw an [InkMesh] as if it is part of a stroke. */
+    private fun drawFromStroke(
+        canvas: Canvas,
+        inkMesh: InkMesh,
+        meshToCanvasTransform: Matrix,
+        brushColor: ComposeColor,
+        blendMode: BlendMode,
+        paint: Paint,
+    ) {
+        fillObjectToCanvasLinearComponent(
+            meshToCanvasTransform,
+            objectToCanvasLinearComponentScratch
+        )
+        val cachedMeshData = inkMeshToAndroidMesh[inkMesh]
+        @OptIn(BuildCompat.PrereleaseSdkCheck::class) val uniformBugFixed = BuildCompat.isAtLeastV()
+        val androidMesh =
+            if (
+                cachedMeshData == null ||
+                    (!uniformBugFixed &&
+                        !cachedMeshData.areUniformsEquivalent(
+                            brushColor,
+                            objectToCanvasLinearComponentScratch
+                        ))
+            ) {
+                val newMesh =
+                    createAndroidMesh(inkMesh) ?: return // Nothing to draw if the mesh is empty.
+                updateAndroidMesh(
+                    newMesh,
+                    inkMesh.format,
+                    objectToCanvasLinearComponentScratch,
+                    brushColor,
+                    inkMesh.vertexAttributeUnpackingParams,
+                )
+                inkMeshToAndroidMesh[inkMesh] =
+                    MeshData.create(newMesh, brushColor, objectToCanvasLinearComponentScratch)
+                newMesh
+            } else {
+                if (uniformBugFixed) {
+                    // Update the uniform values unconditionally because it's inexpensive after the
+                    // bug fix.
+                    // Before the bug fix, there's no need to update the uniforms since changed
+                    // uniform values
+                    // could have caused the mesh to be recreated above.
+                    updateAndroidMesh(
+                        cachedMeshData.androidMesh,
+                        inkMesh.format,
+                        objectToCanvasLinearComponentScratch,
+                        brushColor,
+                        inkMesh.vertexAttributeUnpackingParams,
+                    )
+                }
+                cachedMeshData.androidMesh
+            }
+
+        canvas.save()
+        try {
+            canvas.concat(meshToCanvasTransform)
+            canvas.drawMesh(androidMesh, blendMode, paint)
+        } finally {
+            // If any exceptions occur while drawing, restore the canvas so that restore is always
+            // called
+            // after canvas.save().
+            canvas.restore()
+        }
+
+        if (!uniformBugFixed) {
+            val currentTimeMillis = getDurationTimeMillis()
+            // Before the `androidMesh` variable goes out of scope, save it as a hard reference
+            // (temporarily) as a workaround for the Android U bug where drawMesh would not hand off
+            // or
+            // share ownership of the mesh data properly so data could be used by the render thread
+            // after
+            // being freed and cause a crash.
+            saveRecentlyDrawnAndroidMesh(androidMesh, currentTimeMillis)
+            // Clean up meshes that were previously saved as hard references, but shouldn't be saved
+            // forever otherwise we'll run out of memory. Anything purged by this will only be kept
+            // around
+            // if its associated InkMesh is still referenced, due to their presence in
+            // `inkMeshToAndroidMesh`.
+            cleanUpRecentlyDrawnAndroidMeshes(currentTimeMillis)
+        }
+    }
+
+    override fun draw(
+        canvas: Canvas,
+        inProgressStroke: InProgressStroke,
+        strokeToCanvasTransform: AffineTransform,
+    ) {
+        strokeToCanvasTransform.populateMatrix(scratchMatrix)
+        draw(canvas, inProgressStroke, scratchMatrix)
+    }
+
+    override fun draw(
+        canvas: Canvas,
+        inProgressStroke: InProgressStroke,
+        strokeToCanvasTransform: Matrix,
+    ) {
+        val brush =
+            checkNotNull(inProgressStroke.brush) {
+                "Attempting to draw an InProgressStroke that has not been started."
+            }
+        require(strokeToCanvasTransform.isAffine) { "strokeToCanvasTransform must be affine" }
+        val inputCount = inProgressStroke.getInputCount()
+        if (inputCount == 0) return // nothing to draw
+        inProgressStroke.populateInput(scratchFirstInput, 0)
+        inProgressStroke.populateInput(scratchLastInput, inputCount - 1)
+        fillObjectToCanvasLinearComponent(
+            strokeToCanvasTransform,
+            objectToCanvasLinearComponentScratch
+        )
+        val brushCoatCount = inProgressStroke.getBrushCoatCount()
+        canvas.save()
+        try {
+            canvas.concat(strokeToCanvasTransform)
+            for (coatIndex in 0 until brushCoatCount) {
+                val brushPaint = brush.family.coats[coatIndex].paint
+                val blendMode = finalBlendMode(brushPaint)
+                val androidPaint =
+                    paintCache.obtain(
+                        brushPaint,
+                        AndroidColor.WHITE,
+                        brush.size,
+                        scratchFirstInput,
+                        scratchLastInput,
+                    )
+                val inProgressMeshData = obtainInProgressMeshData(inProgressStroke, coatIndex)
+                for (meshIndex in 0 until inProgressMeshData.androidMeshes.size) {
+                    val androidMesh = inProgressMeshData.androidMeshes[meshIndex] ?: continue
+                    updateAndroidMesh(
+                        androidMesh,
+                        inProgressStroke.getMeshFormat(coatIndex, meshIndex),
+                        objectToCanvasLinearComponentScratch,
+                        brush.composeColor,
+                        attributeUnpackingParams = null,
+                    )
+                    canvas.drawMesh(androidMesh, blendMode, androidPaint)
+                }
+            }
+        } finally {
+            // If any exceptions occur while drawing, restore the canvas so that restore is always
+            // called
+            // after canvas.save().
+            canvas.restore()
+        }
+    }
+
+    /** Create a new [AndroidMesh] for the given [InkMesh]. */
+    private fun createAndroidMesh(inkMesh: InkMesh): AndroidMesh? {
+        val bounds = inkMesh.bounds ?: return null // Nothing to render with an empty mesh.
+        val meshSpec = obtainShaderMetadata(inkMesh.format, isPacked = true).meshSpecification
+        return AndroidMesh(
+            meshSpec,
+            AndroidMesh.TRIANGLES,
+            inkMesh.rawVertexData,
+            inkMesh.vertexCount,
+            inkMesh.rawTriangleIndexData,
+            RectF(bounds.xMin, bounds.yMin, bounds.xMax, bounds.yMax),
+        )
+    }
+
+    /**
+     * Update [androidMesh] with the information that might have changed since the previous call to
+     * [drawFromStroke] with the [InkMesh]. This is intended to be so low cost that it can be called
+     * on every draw call.
+     */
+    private fun updateAndroidMesh(
+        androidMesh: AndroidMesh,
+        meshFormat: MeshFormat,
+        @Size(min = 4) meshToCanvasLinearComponent: FloatArray,
+        brushColor: ComposeColor,
+        attributeUnpackingParams: List<MeshAttributeUnpackingParams>?,
+    ) {
+        val isPacked = attributeUnpackingParams != null
+        var colorUniformName = INVALID_NAME
+        var positionUnpackingParamsUniformName = INVALID_NAME
+        var positionAttributeIndex = INVALID_ATTRIBUTE_INDEX
+        var sideDerivativeUnpackingParamsUniformName = INVALID_NAME
+        var sideDerivativeAttributeIndex = INVALID_ATTRIBUTE_INDEX
+        var forwardDerivativeUnpackingParamsUniformName = INVALID_NAME
+        var forwardDerivativeAttributeIndex = INVALID_ATTRIBUTE_INDEX
+        var objectToCanvasLinearComponentUniformName = INVALID_NAME
+
+        for ((id, _, name, unpackingIndex) in
+            obtainShaderMetadata(meshFormat, isPacked).uniformMetadata) {
+            when (id) {
+                UniformId.OBJECT_TO_CANVAS_LINEAR_COMPONENT ->
+                    objectToCanvasLinearComponentUniformName = name
+                UniformId.BRUSH_COLOR -> colorUniformName = name
+                UniformId.POSITION_UNPACKING_TRANSFORM -> {
+                    check(isPacked) {
+                        "Unpacking transform uniform is only supported for packed meshes."
+                    }
+                    positionUnpackingParamsUniformName = name
+                    positionAttributeIndex = unpackingIndex
+                }
+                UniformId.SIDE_DERIVATIVE_UNPACKING_TRANSFORM -> {
+                    check(isPacked) {
+                        "Unpacking transform uniform is only supported for packed meshes."
+                    }
+                    sideDerivativeUnpackingParamsUniformName = name
+                    sideDerivativeAttributeIndex = unpackingIndex
+                }
+                UniformId.FORWARD_DERIVATIVE_UNPACKING_TRANSFORM -> {
+                    check(isPacked) {
+                        "Unpacking transform uniform is only supported for packed meshes."
+                    }
+                    forwardDerivativeUnpackingParamsUniformName = name
+                    forwardDerivativeAttributeIndex = unpackingIndex
+                }
+            }
+        }
+        // Color and object-to-canvas uniforms are required for all meshes.
+        check(objectToCanvasLinearComponentUniformName != INVALID_NAME)
+        check(colorUniformName != INVALID_NAME)
+        // Unpacking transform uniforms are required for and only for packed meshes.
+        check(
+            !isPacked ||
+                (positionUnpackingParamsUniformName != INVALID_NAME &&
+                    sideDerivativeUnpackingParamsUniformName != INVALID_NAME &&
+                    forwardDerivativeUnpackingParamsUniformName != INVALID_NAME)
+        )
+
+        androidMesh.setFloatUniform(
+            objectToCanvasLinearComponentUniformName,
+            meshToCanvasLinearComponent[0],
+            meshToCanvasLinearComponent[1],
+            meshToCanvasLinearComponent[2],
+            meshToCanvasLinearComponent[3],
+        )
+
+        // Don't use setColorUniform because it does some color space conversion that we don't want.
+        // Instead, set the uniform as an array of 4 floats, but ensure that the color is in the
+        // same
+        // color space that the MeshSpecification is configured to operate in. In
+        // LinearExtendedSrgb,
+        // "linear" refers to the format, "extended" means that the channel values are not clamped
+        // to
+        // [0, 1], and "sRGB" is the color space itself.
+        androidMesh.setFloatUniform(
+            colorUniformName,
+            colorRgbaScratchArray.also {
+                brushColor
+                    .convert(ComposeColorSpaces.LinearExtendedSrgb)
+                    .fillFloatArray(colorRgbaScratchArray)
+            },
+        )
+
+        if (!isPacked) return
+
+        attributeUnpackingParams!!.let {
+            val positionParams = it[positionAttributeIndex]
+            androidMesh.setFloatUniform(
+                positionUnpackingParamsUniformName,
+                positionParams.xOffset,
+                positionParams.xScale,
+                positionParams.yOffset,
+                positionParams.yScale,
+            )
+
+            val sideDerivativeParams = it[sideDerivativeAttributeIndex]
+            androidMesh.setFloatUniform(
+                sideDerivativeUnpackingParamsUniformName,
+                sideDerivativeParams.xOffset,
+                sideDerivativeParams.xScale,
+                sideDerivativeParams.yOffset,
+                sideDerivativeParams.yScale,
+            )
+
+            val forwardDerivativeParams = it[forwardDerivativeAttributeIndex]
+            androidMesh.setFloatUniform(
+                forwardDerivativeUnpackingParamsUniformName,
+                forwardDerivativeParams.xOffset,
+                forwardDerivativeParams.xScale,
+                forwardDerivativeParams.yOffset,
+                forwardDerivativeParams.yScale,
+            )
+        }
+    }
+
+    private fun fillObjectToCanvasLinearComponent(
+        objectToCanvasTransform: Matrix,
+        @Size(min = 4) objectToCanvasLinearComponent: FloatArray,
+    ) {
+        require(objectToCanvasTransform.isAffine) { "objectToCanvasTransform must be affine" }
+        objectToCanvasTransform.getValues(matrixValuesScratchArray)
+        objectToCanvasLinearComponent.let {
+            it[0] = matrixValuesScratchArray[Matrix.MSCALE_X]
+            it[1] = matrixValuesScratchArray[Matrix.MSKEW_Y]
+            it[2] = matrixValuesScratchArray[Matrix.MSKEW_X]
+            it[3] = matrixValuesScratchArray[Matrix.MSCALE_Y]
+        }
+    }
+
+    private fun obtainInProgressMeshData(
+        inProgressStroke: InProgressStroke,
+        coatIndex: Int,
+    ): InProgressMeshData {
+        val cachedMeshDatas = inProgressStrokeToAndroidMeshes[inProgressStroke]
+        if (
+            cachedMeshDatas != null && cachedMeshDatas.size == inProgressStroke.getBrushCoatCount()
+        ) {
+            val inProgressMeshData = cachedMeshDatas[coatIndex]
+            if (inProgressMeshData.version == inProgressStroke.version) {
+                return inProgressMeshData
+            }
+        }
+        val inProgressMeshDatas = computeInProgressMeshDatas(inProgressStroke)
+        inProgressStrokeToAndroidMeshes[inProgressStroke] = inProgressMeshDatas
+        return inProgressMeshDatas[coatIndex]
+    }
+
+    private fun computeInProgressMeshDatas(
+        inProgressStroke: InProgressStroke
+    ): List<InProgressMeshData> =
+        buildList() {
+            for (coatIndex in 0 until inProgressStroke.getBrushCoatCount()) {
+                val androidMeshes =
+                    buildList() {
+                        for (meshIndex in
+                            0 until inProgressStroke.getMeshPartitionCount(coatIndex)) {
+                            add(createAndroidMesh(inProgressStroke, coatIndex, meshIndex))
+                        }
+                    }
+                add(InProgressMeshData(inProgressStroke.version, androidMeshes))
+            }
+        }
+
+    /**
+     * Create a new [AndroidMesh] for the unpacked mesh at [meshIndex] in brush coat [coatIndex] of
+     * the given [inProgressStroke].
+     */
+    @VisibleForTesting
+    internal fun createAndroidMesh(
+        inProgressStroke: InProgressStroke,
+        coatIndex: Int,
+        meshIndex: Int,
+    ): AndroidMesh? {
+        val vertexCount = inProgressStroke.getVertexCount(coatIndex, meshIndex)
+        if (vertexCount < 3) {
+            // Fail gracefully when mesh doesn't contain enough vertices for a full triangle.
+            return null
+        }
+        val bounds = BoxAccumulator().apply { inProgressStroke.populateMeshBounds(coatIndex, this) }
+        if (bounds.isEmpty()) return null // Empty mesh; nothing to render.
+        return AndroidMesh(
+            obtainShaderMetadata(
+                    inProgressStroke.getMeshFormat(coatIndex, meshIndex),
+                    isPacked = false
+                )
+                .meshSpecification,
+            AndroidMesh.TRIANGLES,
+            inProgressStroke.getRawVertexBuffer(coatIndex, meshIndex),
+            vertexCount,
+            inProgressStroke.getRawTriangleIndexBuffer(coatIndex, meshIndex),
+            bounds.box?.let { RectF(it.xMin, it.yMin, it.xMax, it.yMax) } ?: return null,
+        )
+    }
+
+    /**
+     * Returns a [ShaderMetadata] compatible with the [isPacked] state of the given [MeshFormat]'s
+     * vertex format. This may be newly created, or an internally cached value.
+     *
+     * This method manages read and write access to both [meshFormatToPackedShaderMetadata] and
+     * [meshFormatToUnpackedShaderMetadata]
+     */
+    @VisibleForTesting
+    internal fun obtainShaderMetadata(meshFormat: MeshFormat, isPacked: Boolean): ShaderMetadata {
+        val meshFromatToShaderMetaData =
+            if (isPacked) meshFormatToPackedShaderMetadata else meshFormatToUnpackedShaderMetadata
+        // Check the cache first.
+        return getCachedValue(meshFormat, meshFromatToShaderMetaData, isPacked)
+            ?: createShaderMetadata(meshFormat, isPacked).also {
+                // Populate the cache before returning the newly-created ShaderMetadata.
+                meshFromatToShaderMetaData.add(Pair(meshFormat, it))
+            }
+    }
+
+    /**
+     * Returns true when the [stroke]'s [inputs] are empty, or [MeshFormat] is compatible with the
+     * native Skia `MeshSpecificationData`.
+     */
+    internal fun canDraw(stroke: Stroke): Boolean {
+        for (groupIndex in 0 until stroke.shape.getRenderGroupCount()) {
+            if (stroke.shape.renderGroupMeshes(groupIndex).isEmpty()) continue
+            val format = stroke.shape.renderGroupFormat(groupIndex)
+            if (!nativeIsMeshFormatRenderable(format.getNativeAddress(), isPacked = true)) {
+                return false
+            }
+        }
+        return true
+    }
+
+    private fun createShaderMetadata(meshFormat: MeshFormat, isPacked: Boolean): ShaderMetadata {
+        // Fill "out" parameter arrays with invalid data, to fail fast in case anything goes wrong.
+        val attributeTypesOut = IntArray(MAX_ATTRIBUTES) { Type.INVALID_NATIVE_VALUE }
+        val attributeOffsetsBytesOut = IntArray(MAX_ATTRIBUTES) { INVALID_OFFSET }
+        val attributeNamesOut = Array(MAX_ATTRIBUTES) { INVALID_NAME }
+        val vertexStrideBytesOut = intArrayOf(INVALID_VERTEX_STRIDE)
+        val varyingTypesOut = IntArray(MAX_VARYINGS) { Type.INVALID_NATIVE_VALUE }
+        val varyingNamesOut = Array(MAX_VARYINGS) { INVALID_NAME }
+        val uniformIdsOut = IntArray(MAX_UNIFORMS) { UniformId.INVALID_NATIVE_VALUE }
+        val uniformTypesOut = IntArray(MAX_UNIFORMS) { Type.INVALID_NATIVE_VALUE }
+        val uniformUnpackingIndicesOut = IntArray(MAX_UNIFORMS) { INVALID_ATTRIBUTE_INDEX }
+        val uniformNamesOut = Array(MAX_UNIFORMS) { INVALID_NAME }
+        val vertexShaderOut = arrayOf("unset vertex shader")
+        val fragmentShaderOut = arrayOf("unset fragment shader")
+        fillSkiaMeshSpecData(
+            meshFormat.getNativeAddress(),
+            isPacked,
+            attributeTypesOut,
+            attributeOffsetsBytesOut,
+            attributeNamesOut,
+            vertexStrideBytesOut,
+            varyingTypesOut,
+            varyingNamesOut,
+            uniformIdsOut,
+            uniformTypesOut,
+            uniformUnpackingIndicesOut,
+            uniformNamesOut,
+            vertexShaderOut,
+            fragmentShaderOut,
+        )
+        val attributes = mutableListOf<MeshSpecification.Attribute>()
+        for (attrIndex in 0 until MAX_ATTRIBUTES) {
+            val type = Type.fromNativeValue(attributeTypesOut[attrIndex]) ?: break
+            val offset = attributeOffsetsBytesOut[attrIndex]
+            val name = attributeNamesOut[attrIndex]
+            attributes.add(MeshSpecification.Attribute(type.meshSpecValue, offset, name))
+        }
+        val varyings = mutableListOf<MeshSpecification.Varying>()
+        for (varyingIndex in 0 until MAX_VARYINGS) {
+            val type = Type.fromNativeValue(varyingTypesOut[varyingIndex]) ?: break
+            val name = varyingNamesOut[varyingIndex]
+            varyings.add(MeshSpecification.Varying(type.meshSpecValue, name))
+        }
+        val uniforms = mutableListOf<UniformMetadata>()
+        for (uniformIndex in 0 until MAX_UNIFORMS) {
+            val id = UniformId.fromNativeValue(uniformIdsOut[uniformIndex]) ?: break
+            val type = Type.fromNativeValue(uniformTypesOut[uniformIndex]) ?: break
+            val name = uniformNamesOut[uniformIndex]
+            val attributeIndex = uniformUnpackingIndicesOut[uniformIndex]
+            uniforms.add(UniformMetadata(id, type, name, attributeIndex))
+        }
+
+        return ShaderMetadata(
+            meshSpecification =
+                MeshSpecification.make(
+                    attributes.toTypedArray(),
+                    validVertexStrideBytes(vertexStrideBytesOut[0]),
+                    varyings.toTypedArray(),
+                    vertexShaderOut[0],
+                    fragmentShaderOut[0],
+                    // The shaders output linear, premultiplied, non-clamped sRGB colors.
+                    AndroidColorSpace.get(AndroidColorSpace.Named.LINEAR_EXTENDED_SRGB),
+                    MeshSpecification.ALPHA_TYPE_PREMULTIPLIED,
+                ),
+            uniformMetadata = uniforms,
+        )
+    }
+
+    private fun validVertexStrideBytes(vertexStride: Int): Int {
+        // MeshSpecification.make is documented to accept a vertex stride between 1 and 1024 bytes
+        // (inclusive), but its only supported vertex attribute types are in multiples of 4 bytes,
+        // so
+        // its true lower bound is 4 bytes.
+        require(vertexStride in 4..1024)
+        return vertexStride
+    }
+
+    /**
+     * Retrieves data analogous to [MeshSpecification] from native code. It makes use of "out"
+     * parameters to return this data, as it is tedious (and therefore error-prone) to construct and
+     * return complex objects from JNI. These "out" parameters are all arrays, as those are well
+     * supported by JNI, especially primitive arrays.
+     *
+     * @param meshFormatNativeAddress The raw pointer address of a [MeshFormat].
+     * @param isPacked Whether to fill the mesh spec with properties describing a packed format (as
+     *   in ink::Mesh) or an unpacked format (as in ink::MutableMesh).
+     * @param attributeTypesOut An array that can hold at least [MAX_ATTRIBUTES] values. It will
+     *   contain the resulting attribute types aligning with [Type.nativeValue]. The number of
+     *   attributes will be determined by the first index of this array with an invalid value, and
+     *   that attribute count will determine the number of entries to look at in
+     *   [attributeOffsetsBytesOut] and [attributeNamesOut]. See
+     *   [MeshSpecification.Attribute.getType].
+     * @param attributeOffsetsBytesOut An array that can hold at least [MAX_ATTRIBUTES] values.
+     *   Specifies the layout of each vertex of the raw data for a mesh, where each vertex is a
+     *   contiguous chunk of memory and each attribute is located at a particular number of bytes
+     *   (offset) from the beginning of that vertex's chunk of memory.
+     * @param attributeNamesOut The names of each attribute, referenced in the shader code.
+     * @param vertexStrideBytesOut In the raw data of the mesh vertices, the number of bytes between
+     *   the start of each vertex. See [attributeOffsetsBytesOut] for how each attribute is laid
+     *   out.
+     * @param varyingTypesOut An array that can hold at least [MAX_VARYINGS] values. It will contain
+     *   the resulting varying types aligning with [Type.nativeValue]. The number of varyings will
+     *   be determined by the first index of this array with an invalid value, and that varying
+     *   count will determine the number of entries to look at in [varyingNamesOut]. See
+     *   [MeshSpecification.Varying.getType].
+     * @param varyingNamesOut The names of each varying, referenced in the shader code.
+     * @param vertexShaderOut An array with at least one element that will be filled in by the
+     *   string vertex shader code.
+     * @param fragmentShaderOut An array with at least one element that will be filled in by the
+     *   string fragment shader code.
+     * @throws IllegalArgumentException If an unrecognized format was passed in, i.e. when
+     *   [nativeIsMeshFormatRenderable] returns false.
+     */
+    // TODO: b/355248266 - @Keep must go in Proguard config file instead.
+    private external fun fillSkiaMeshSpecData(
+        meshFormatNativeAddress: Long,
+        isPacked: Boolean,
+        attributeTypesOut: IntArray,
+        attributeOffsetsBytesOut: IntArray,
+        attributeNamesOut: Array<String>,
+        vertexStrideBytesOut: IntArray,
+        varyingTypesOut: IntArray,
+        varyingNamesOut: Array<String>,
+        uniformIdsOut: IntArray,
+        uniformTypesOut: IntArray,
+        uniformUnpackingIndicesOut: IntArray,
+        uniformNamesOut: Array<String>,
+        vertexShaderOut: Array<String>,
+        fragmentShaderOut: Array<String>,
+    )
+
+    /**
+     * Constructs native [MeshFormat] from [meshFormatNativeAddress] and checks whether it is
+     * compatible with the native Skia `MeshSpecificationData`.
+     *
+     * @param isPacked checks whether [meshFormat] describes a packed format (as in native
+     *   ink::Mesh) or an unpacked format (as in native ink::MutableMesh).
+     *
+     * [fillSkiaMeshSpecData] throws IllegalArgumentException when this method returns false.
+     */
+    // TODO: b/355248266 - @Keep must go in Proguard config file instead.
+    private external fun nativeIsMeshFormatRenderable(
+        meshFormatNativeAddress: Long,
+        isPacked: Boolean,
+    ): Boolean
+
+    private fun saveRecentlyDrawnAndroidMesh(androidMesh: AndroidMesh, currentTimeMillis: Long) {
+        recentlyDrawnMeshesToLastDrawTimeMillis[androidMesh] = currentTimeMillis
+    }
+
+    private fun cleanUpRecentlyDrawnAndroidMeshes(currentTimeMillis: Long) {
+        if (
+            recentlyDrawnMeshesLastCleanupTimeMillis + EVICTION_SCAN_PERIOD_MS > currentTimeMillis
+        ) {
+            return
+        }
+        recentlyDrawnMeshesToLastDrawTimeMillis.removeIf { _, lastDrawTimeMillis ->
+            lastDrawTimeMillis + MESH_STRONG_REFERENCE_DURATION_MS < currentTimeMillis
+        }
+        recentlyDrawnMeshesLastCleanupTimeMillis = currentTimeMillis
+    }
+
+    @VisibleForTesting
+    internal fun getRecentlyDrawnAndroidMeshesCount(): Int {
+        return recentlyDrawnMeshesToLastDrawTimeMillis.size
+    }
+
+    private fun ComposeColor.fillFloatArray(@Size(min = 4) outRgba: FloatArray) {
+        outRgba[0] = this.red
+        outRgba[1] = this.green
+        outRgba[2] = this.blue
+        outRgba[3] = this.alpha
+    }
+
+    private class MeshData
+    private constructor(
+        val androidMesh: AndroidMesh,
+        val brushColor: ComposeColor,
+        /** Do not modify! */
+        @Size(4) val objectToCanvasLinearComponent: FloatArray,
+    ) {
+
+        fun areUniformsEquivalent(
+            otherBrushColor: ComposeColor,
+            @Size(4) otherObjectToCanvasLinearComponent: FloatArray,
+        ): Boolean =
+            otherBrushColor == brushColor &&
+                otherObjectToCanvasLinearComponent.contentEquals(objectToCanvasLinearComponent)
+
+        companion object {
+            fun create(
+                androidMesh: AndroidMesh,
+                brushColor: ComposeColor,
+                @Size(4) objectToCanvasLinearComponent: FloatArray,
+            ): MeshData {
+                val copied = FloatArray(4)
+                System.arraycopy(
+                    /* src = */ objectToCanvasLinearComponent,
+                    /* srcPos = */ 0,
+                    /* dest = */ copied,
+                    /* destPos = */ 0,
+                    /* length = */ 4,
+                )
+                return MeshData(androidMesh, brushColor, copied)
+            }
+        }
+    }
+
+    /**
+     * Contains the [android.graphics.Mesh] data for an [InProgressStroke], along with metadata used
+     * to verify if that data is still valid.
+     */
+    private data class InProgressMeshData(
+        /** If this does not match [InProgressStroke.version], the data is invalid. */
+        val version: Long,
+        /**
+         * At each index, the [android.graphics.Mesh] for the corresponding partition index of the
+         * [InProgressStroke], or `null` if that partition is empty.
+         */
+        val androidMeshes: List<AndroidMesh?>,
+    )
+
+    companion object {
+        init {
+            NativeLoader.load()
+        }
+
+        /**
+         * On Android U, how long to hold a reference to an [android.graphics.Mesh] after it has
+         * been drawn with [Canvas.drawMesh]. This is an imperfect workaround for a bug in the
+         * native layer where the render thread is not given ownership of the mesh data to prevent
+         * it from being freed before the render thread uses it for drawing.
+         */
+        private const val MESH_STRONG_REFERENCE_DURATION_MS = 5000
+        private const val EVICTION_SCAN_PERIOD_MS = 2000
+
+        /** All the metadata about values sent to the shader for a given mesh. Used for caching. */
+        internal data class ShaderMetadata(
+            val meshSpecification: MeshSpecification,
+            val uniformMetadata: List<UniformMetadata>,
+        )
+
+        internal data class UniformMetadata(
+            val id: UniformId,
+            val type: Type,
+            val name: String,
+            val unpackingAttributeIndex: Int,
+        )
+
+        internal enum class UniformId(val nativeValue: Int) {
+            /**
+             * The 2x2 linear component of the affine transformation from mesh / "object"
+             * coordinates to the canvas. This requires that the [meshToCanvasTransform] matrix used
+             * during drawing is an affine transform. Set it with [AndroidMesh.setFloatUniform]. It
+             * is a `float4` with the following expected entries:
+             * - `[0]`: `matrixValues[Matrix.MSCALE_X]`
+             * - `[1]`: `matrixValues[Matrix.MSKEW_X]`
+             * - `[2]`: `matrixValues[Matrix.MSKEW_Y]`
+             * - `[3]`: `matrixValues[Matrix.MSCALE_Y]`
+             */
+            OBJECT_TO_CANVAS_LINEAR_COMPONENT(0),
+
+            /**
+             * The [Color] of the Stroke's brush, which will be combined with per-vertex color
+             * shifts in the shaders. Set it with [AndroidMesh.setColorUniform]. Must be specified
+             * for every format.
+             */
+            BRUSH_COLOR(1),
+
+            /**
+             * The transform parameters to convert packed [InkMesh] coordinates into actual
+             * ("object") coordinates. Set it with [AndroidMesh.setFloatUniform]. Must be specified
+             * for packed meshes only. It is a `float4` with the following entries:
+             * - `[0]`: x offset
+             * - `[1]`: x scale
+             * - `[2]`: y offset
+             * - `[3]`: y scale
+             */
+            POSITION_UNPACKING_TRANSFORM(2),
+
+            /**
+             * The transform parameters to convert packed [InkMesh] side-derivative attribute values
+             * into their unpacked values. Set it with [AndroidMesh.setFloatUniform]. Must be
+             * specified for packed meshes only. It is a `float4` with the following entries:
+             * - `[0]`: x offset
+             * - `[1]`: x scale
+             * - `[2]`: y offset
+             * - `[3]`: y scale
+             */
+            SIDE_DERIVATIVE_UNPACKING_TRANSFORM(3),
+
+            /**
+             * The transform parameters to convert packed [InkMesh] forward-derivative attribute
+             * values into their unpacked values. Set it with [AndroidMesh.setFloatUniform]. Must be
+             * specified for packed meshes only. It is a `float4` with the following entries:
+             * - `[0]`: x offset
+             * - `[1]`: x scale
+             * - `[2]`: y offset
+             * - `[3]`: y scale
+             */
+            FORWARD_DERIVATIVE_UNPACKING_TRANSFORM(4);
+
+            companion object {
+                const val INVALID_NATIVE_VALUE = -1
+
+                fun fromNativeValue(nativeValue: Int): UniformId? {
+                    for (type in UniformId.values()) {
+                        if (type.nativeValue == nativeValue) return type
+                    }
+                    return null
+                }
+            }
+        }
+
+        private const val MAX_ATTRIBUTES = 8
+        private const val MAX_VARYINGS = 6
+        private const val MAX_UNIFORMS = 6
+
+        private const val INVALID_OFFSET = -1
+        private const val INVALID_VERTEX_STRIDE = -1
+        private const val INVALID_NAME = ")"
+        private const val INVALID_ATTRIBUTE_INDEX = -1
+
+        internal enum class Type(val nativeValue: Int, val meshSpecValue: Int) {
+            FLOAT(0, MeshSpecification.TYPE_FLOAT),
+            FLOAT2(1, MeshSpecification.TYPE_FLOAT2),
+            FLOAT3(2, MeshSpecification.TYPE_FLOAT3),
+            FLOAT4(3, MeshSpecification.TYPE_FLOAT4),
+            UBYTE4(4, MeshSpecification.TYPE_UBYTE4);
+
+            companion object {
+                const val INVALID_NATIVE_VALUE = -1
+
+                fun fromNativeValue(nativeValue: Int): Type? {
+                    for (type in Type.values()) {
+                        if (type.nativeValue == nativeValue) return type
+                    }
+                    return null
+                }
+            }
+        }
+
+        /**
+         * Returns the [T] associated with [key] in [cache]. If [isPacked] is false, keys are
+         * considered equivalent if their unpacked format is the same; if true, if their packed
+         * format is the same. This provides a map-like getter interface for a cache implemented as
+         * a list of key-value pairs. Returns `null` if no equivalent key is found.
+         */
+        private fun <T> getCachedValue(
+            key: MeshFormat,
+            cache: ArrayList<Pair<MeshFormat, T>>,
+            isPacked: Boolean,
+        ): T? {
+            for ((format, item) in cache) {
+                if (isPacked && format.isPackedEquivalent(key)) {
+                    return item
+                } else if (!isPacked && format.isUnpackedEquivalent(key)) {
+                    return item
+                }
+            }
+            return null
+        }
+
+        private fun finalBlendMode(brushPaint: BrushPaint): BlendMode =
+            brushPaint.textureLayers.lastOrNull()?.let { it.blendMode.toBlendMode() }
+                ?: BlendMode.MODULATE
+
+        private val MeshAttributeUnpackingParams.xOffset
+            get() = components[0].offset
+
+        private val MeshAttributeUnpackingParams.xScale
+            get() = components[0].scale
+
+        private val MeshAttributeUnpackingParams.yOffset
+            get() = components[1].offset
+
+        private val MeshAttributeUnpackingParams.yScale
+            get() = components[1].scale
+    }
+}
diff --git a/ink/ink-rendering/src/androidMain/kotlin/androidx/ink/rendering/android/canvas/internal/CanvasPathRenderer.kt b/ink/ink-rendering/src/androidMain/kotlin/androidx/ink/rendering/android/canvas/internal/CanvasPathRenderer.kt
new file mode 100644
index 0000000..7f185a0
--- /dev/null
+++ b/ink/ink-rendering/src/androidMain/kotlin/androidx/ink/rendering/android/canvas/internal/CanvasPathRenderer.kt
@@ -0,0 +1,268 @@
+/*
+ * 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.ink.rendering.android.canvas.internal
+
+import android.graphics.Canvas
+import android.graphics.Matrix
+import android.graphics.Paint
+import android.graphics.Path
+import androidx.annotation.FloatRange
+import androidx.ink.brush.BrushPaint
+import androidx.ink.brush.ExperimentalInkCustomBrushApi
+import androidx.ink.brush.color.Color as ComposeColor
+import androidx.ink.brush.color.toArgb
+import androidx.ink.geometry.AffineTransform
+import androidx.ink.geometry.MutableVec
+import androidx.ink.geometry.PartitionedMesh
+import androidx.ink.geometry.populateMatrix
+import androidx.ink.rendering.android.TextureBitmapStore
+import androidx.ink.rendering.android.canvas.CanvasStrokeRenderer
+import androidx.ink.strokes.InProgressStroke
+import androidx.ink.strokes.Stroke
+import androidx.ink.strokes.StrokeInput
+import java.util.WeakHashMap
+
+/**
+ * Renders Ink objects using [Canvas.drawPath]. This is the best [Canvas] Ink renderer to use before
+ * [android.os.Build.VERSION_CODES.UPSIDE_DOWN_CAKE] for both quality (anti-aliasing) and
+ * performance compared to a solution built on [Canvas.drawVertices], and even on higher OS versions
+ * when the desired behavior for self-intersection of translucent strokes is to discard the extra
+ * layers.
+ *
+ * This is not thread safe, so if it must be used from multiple threads, the caller is responsible
+ * for synchronizing access. If it is being used in two very different contexts where there are
+ * unlikely to be cached mesh data in common, the easiest solution to thread safety is to have two
+ * different instances of this object.
+ */
+@OptIn(ExperimentalInkCustomBrushApi::class)
+internal class CanvasPathRenderer(
+    private val textureStore: TextureBitmapStore = TextureBitmapStore { null }
+) : CanvasStrokeRenderer {
+
+    /**
+     * Holds onto rendering data for each [PartitionedMesh] (the shape of a [Stroke]) so the data
+     * can be created once and then reused on each call to [draw]. The [WeakHashMap] ensures that
+     * this renderer does not hold onto [PartitionedMesh] instances that would otherwise be garbage
+     * collected.
+     */
+    private val strokePathCache = WeakHashMap<PartitionedMesh, List<Path>>()
+
+    /**
+     * Holds onto rendering data for each [InProgressStroke], so the data can be created once and
+     * then reused on each call to [draw]. Because [InProgressStroke] is mutable, this cache is
+     * based not just on the existence of data, but whether that data's version number matches that
+     * of the [InProgressStroke]. The [WeakHashMap] ensures that this renderer does not hold onto
+     * [InProgressStroke] instances that would otherwise be garbage collected.
+     */
+    private val inProgressStrokePathCache = WeakHashMap<InProgressStroke, InProgressPathData>()
+
+    private val paintCache =
+        BrushPaintCache(
+            textureStore,
+            additionalPaintFlags = Paint.ANTI_ALIAS_FLAG,
+            applyColorFilterToTexture = true,
+        )
+
+    private val scratchPoint = MutableVec()
+
+    /** Scratch [Matrix] used for draw calls taking an [AffineTransform]. */
+    private val scratchMatrix = Matrix()
+
+    // First and last inputs for the stroke being rendered, reused so that we don't need to allocate
+    // new ones for every stroke.
+    private val scratchFirstInput = StrokeInput()
+    private val scratchLastInput = StrokeInput()
+
+    private fun draw(
+        canvas: Canvas,
+        path: Path,
+        brushPaint: BrushPaint,
+        color: ComposeColor,
+        @FloatRange(from = 0.0) brushSize: Float,
+        firstInput: StrokeInput,
+        lastInput: StrokeInput,
+        strokeToCanvasTransform: Matrix,
+    ) {
+        val paint = paintCache.obtain(brushPaint, color.toArgb(), brushSize, firstInput, lastInput)
+        canvas.save()
+        try {
+            canvas.concat(strokeToCanvasTransform)
+            canvas.drawPath(path, paint)
+        } finally {
+            canvas.restore()
+        }
+    }
+
+    override fun draw(canvas: Canvas, stroke: Stroke, strokeToCanvasTransform: AffineTransform) {
+        strokeToCanvasTransform.populateMatrix(scratchMatrix)
+        draw(canvas, stroke, scratchMatrix)
+    }
+
+    override fun draw(canvas: Canvas, stroke: Stroke, strokeToCanvasTransform: Matrix) {
+        if (stroke.inputs.isEmpty()) return // nothing to draw
+        stroke.inputs.populate(0, scratchFirstInput)
+        stroke.inputs.populate(stroke.inputs.size - 1, scratchLastInput)
+        for (groupIndex in 0 until stroke.shape.getRenderGroupCount()) {
+            draw(
+                canvas,
+                obtainPath(stroke.shape, groupIndex),
+                stroke.brush.family.coats[groupIndex].paint,
+                stroke.brush.composeColor,
+                stroke.brush.size,
+                scratchFirstInput,
+                scratchLastInput,
+                strokeToCanvasTransform,
+            )
+        }
+    }
+
+    override fun draw(
+        canvas: Canvas,
+        inProgressStroke: InProgressStroke,
+        strokeToCanvasTransform: AffineTransform,
+    ) {
+        strokeToCanvasTransform.populateMatrix(scratchMatrix)
+        draw(canvas, inProgressStroke, scratchMatrix)
+    }
+
+    override fun draw(
+        canvas: Canvas,
+        inProgressStroke: InProgressStroke,
+        strokeToCanvasTransform: Matrix,
+    ) {
+        val brush =
+            checkNotNull(inProgressStroke.brush) {
+                "Attempting to draw an InProgressStroke that has not been started."
+            }
+        val inputCount = inProgressStroke.getInputCount()
+        if (inputCount == 0) return // nothing to draw
+        inProgressStroke.populateInput(scratchFirstInput, 0)
+        inProgressStroke.populateInput(scratchLastInput, inputCount - 1)
+        for (coatIndex in 0 until inProgressStroke.getBrushCoatCount()) {
+            draw(
+                canvas,
+                obtainPath(inProgressStroke, coatIndex),
+                brush.family.coats[coatIndex].paint,
+                brush.composeColor,
+                brush.size,
+                scratchFirstInput,
+                scratchLastInput,
+                strokeToCanvasTransform,
+            )
+        }
+    }
+
+    /**
+     * Obtain a [Path] for the specified render group of the given [PartitionedMesh], which may be
+     * cached or new.
+     */
+    private fun obtainPath(shape: PartitionedMesh, groupIndex: Int): Path {
+        val paths =
+            strokePathCache[shape] ?: createPaths(shape).also { strokePathCache[shape] = it }
+        return paths[groupIndex]
+    }
+
+    /** Create new [Path]s for the given [PartitionedMesh], one for each render group. */
+    private fun createPaths(shape: PartitionedMesh): List<Path> =
+        buildList() {
+            val point = MutableVec()
+            for (groupIndex in 0 until shape.getRenderGroupCount()) {
+                val path = Path()
+                for (outlineIndex in 0 until shape.getOutlineCount(groupIndex)) {
+                    val outlineVertexCount = shape.getOutlineVertexCount(groupIndex, outlineIndex)
+                    if (outlineVertexCount == 0) continue
+
+                    shape.populateOutlinePosition(groupIndex, outlineIndex, 0, point)
+                    path.moveTo(point.x, point.y)
+
+                    for (outlineVertexIndex in 1 until outlineVertexCount) {
+                        shape.populateOutlinePosition(
+                            groupIndex,
+                            outlineIndex,
+                            outlineVertexIndex,
+                            point
+                        )
+                        path.lineTo(point.x, point.y)
+                    }
+
+                    path.close()
+                }
+                add(path)
+            }
+        }
+
+    /**
+     * Obtain a [Path] for brush coat [coatIndex] of the given [InProgressStroke], which may be
+     * cached or new.
+     */
+    private fun obtainPath(inProgressStroke: InProgressStroke, coatIndex: Int): Path {
+        val cachedPathData = inProgressStrokePathCache[inProgressStroke]
+        if (cachedPathData != null && cachedPathData.version == inProgressStroke.version) {
+            return cachedPathData.paths[coatIndex]
+        }
+        val inProgressPathData = computeInProgressPathData(inProgressStroke)
+        inProgressStrokePathCache[inProgressStroke] = inProgressPathData
+        return inProgressPathData.paths[coatIndex]
+    }
+
+    private fun computeInProgressPathData(inProgressStroke: InProgressStroke): InProgressPathData {
+        val paths =
+            buildList() {
+                for (coatIndex in 0 until inProgressStroke.getBrushCoatCount()) {
+                    val path = Path()
+                    path.fillFrom(inProgressStroke, coatIndex)
+                    add(path)
+                }
+            }
+        return InProgressPathData(inProgressStroke.version, paths)
+    }
+
+    /** Create a new [Path] for the given [InProgressStroke]. */
+    private fun Path.fillFrom(inProgressStroke: InProgressStroke, coatIndex: Int) {
+        rewind()
+        for (outlineIndex in 0 until inProgressStroke.getOutlineCount(coatIndex)) {
+            val outlineVertexCount = inProgressStroke.getOutlineVertexCount(coatIndex, outlineIndex)
+            if (outlineVertexCount == 0) continue
+
+            inProgressStroke.populateOutlinePosition(
+                coatIndex,
+                outlineIndex,
+                outlineVertexIndex = 0,
+                scratchPoint,
+            )
+            moveTo(scratchPoint.x, scratchPoint.y)
+
+            for (outlineVertexIndex in 1 until outlineVertexCount) {
+                inProgressStroke.populateOutlinePosition(
+                    coatIndex,
+                    outlineIndex,
+                    outlineVertexIndex,
+                    scratchPoint,
+                )
+                lineTo(scratchPoint.x, scratchPoint.y)
+            }
+
+            close()
+        }
+    }
+
+    /**
+     * A snapshot of the outline(s) of [InProgressStroke] at a particular
+     * [InProgressStroke.version], with one [Path] object for each brush coat.
+     */
+    private class InProgressPathData(val version: Long, val paths: List<Path>)
+}
diff --git a/ink/ink-rendering/src/androidMain/kotlin/androidx/ink/rendering/android/canvas/internal/CanvasStrokeUnifiedRenderer.kt b/ink/ink-rendering/src/androidMain/kotlin/androidx/ink/rendering/android/canvas/internal/CanvasStrokeUnifiedRenderer.kt
new file mode 100644
index 0000000..62fc86c
--- /dev/null
+++ b/ink/ink-rendering/src/androidMain/kotlin/androidx/ink/rendering/android/canvas/internal/CanvasStrokeUnifiedRenderer.kt
@@ -0,0 +1,87 @@
+/*
+ * 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.ink.rendering.android.canvas.internal
+
+import android.graphics.Canvas
+import android.graphics.Matrix
+import android.os.Build
+import androidx.ink.brush.ExperimentalInkCustomBrushApi
+import androidx.ink.geometry.AffineTransform
+import androidx.ink.rendering.android.TextureBitmapStore
+import androidx.ink.rendering.android.canvas.CanvasStrokeRenderer
+import androidx.ink.strokes.InProgressStroke
+import androidx.ink.strokes.Stroke
+
+/**
+ * Renders Ink objects using [CanvasMeshRenderer], but falls back to using [CanvasPathRenderer] when
+ * mesh rendering is not possible.
+ */
+@OptIn(ExperimentalInkCustomBrushApi::class)
+internal class CanvasStrokeUnifiedRenderer(
+    private val textureStore: TextureBitmapStore = TextureBitmapStore { null }
+) : CanvasStrokeRenderer {
+
+    private val meshRenderer by lazy {
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+            CanvasMeshRenderer(textureStore)
+        } else {
+            null
+        }
+    }
+    private val pathRenderer by lazy { CanvasPathRenderer(textureStore) }
+
+    private fun getDelegateRendererOrThrow(stroke: Stroke): CanvasStrokeRenderer {
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+            val renderer = checkNotNull(meshRenderer)
+            if (renderer.canDraw(stroke)) {
+                return renderer
+            }
+        }
+        for (groupIndex in 0 until stroke.shape.getRenderGroupCount()) {
+            if (stroke.shape.getOutlineCount(groupIndex) > 0) {
+                return pathRenderer
+            }
+        }
+        throw IllegalArgumentException("Cannot draw $stroke")
+    }
+
+    override fun draw(canvas: Canvas, stroke: Stroke, strokeToCanvasTransform: AffineTransform) {
+        getDelegateRendererOrThrow(stroke).draw(canvas, stroke, strokeToCanvasTransform)
+    }
+
+    override fun draw(canvas: Canvas, stroke: Stroke, strokeToCanvasTransform: Matrix) {
+        getDelegateRendererOrThrow(stroke).draw(canvas, stroke, strokeToCanvasTransform)
+    }
+
+    override fun draw(
+        canvas: Canvas,
+        inProgressStroke: InProgressStroke,
+        strokeToCanvasTransform: AffineTransform,
+    ) {
+        val delegateRenderer = meshRenderer ?: pathRenderer
+        delegateRenderer.draw(canvas, inProgressStroke, strokeToCanvasTransform)
+    }
+
+    override fun draw(
+        canvas: Canvas,
+        inProgressStroke: InProgressStroke,
+        strokeToCanvasTransform: Matrix,
+    ) {
+        val delegateRenderer = meshRenderer ?: pathRenderer
+        delegateRenderer.draw(canvas, inProgressStroke, strokeToCanvasTransform)
+    }
+}
diff --git a/ink/ink-rendering/src/androidMain/kotlin/androidx/ink/rendering/android/view/ViewStrokeRenderer.kt b/ink/ink-rendering/src/androidMain/kotlin/androidx/ink/rendering/android/view/ViewStrokeRenderer.kt
new file mode 100644
index 0000000..091e51f
--- /dev/null
+++ b/ink/ink-rendering/src/androidMain/kotlin/androidx/ink/rendering/android/view/ViewStrokeRenderer.kt
@@ -0,0 +1,136 @@
+/*
+ * 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.ink.rendering.android.view
+
+import android.graphics.Canvas
+import android.graphics.Matrix
+import android.os.Build
+import android.view.View
+import androidx.annotation.RestrictTo
+import androidx.ink.rendering.android.canvas.CanvasStrokeRenderer
+import androidx.ink.rendering.android.canvas.StrokeDrawScope
+
+/**
+ * Helps developers using Android Views to draw [Stroke] objects in their UI, in an easier way than
+ * using [CanvasStrokeRenderer] directly. Construct this once for your [View] and reuse it during
+ * each [View.onDraw] call.
+ *
+ * This utility is valid as long as [View.onDraw]
+ * 1. Does not call [Canvas.setMatrix].
+ * 2. Does not modify [Canvas] transform state prior to calling [drawWithStrokes].
+ * 3. Does not use [android.graphics.RenderEffect], either setting it on this [View] or a subview
+ *    using [View.setRenderEffect], or by calling [Canvas.drawRenderNode] using a
+ *    [android.graphics.RenderNode] that has been configured with
+ *    [android.graphics.RenderNode.setRenderEffect]. Developers who want to use
+ *    [android.graphics.RenderEffect] in conjunction with [Stroke] rendering must use
+ *    [CanvasStrokeRenderer.draw] directly.
+ *
+ * Example:
+ * ```
+ * class MyView(context: Context) : View(context) {
+ *   private val viewStrokeRenderer = ViewStrokeRenderer(myCanvasStrokeRenderer, this)
+ *
+ *   override fun onDraw(canvas: Canvas) {
+ *     viewStrokeRenderer.drawWithStrokes(canvas) { scope ->
+ *       canvas.scale(myZoomLevel)
+ *       canvas.rotate(myRotation)
+ *       canvas.translate(myPanX, myPanY)
+ *       scope.drawStroke(myStroke)
+ *       // Draw other objects including more strokes, apply more transformations, etc.
+ *     }
+ *   }
+ * }
+ * ```
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) // PublicApiNotReadyForJetpackReview
+public class ViewStrokeRenderer(
+    private val canvasStrokeRenderer: CanvasStrokeRenderer,
+    private val view: View,
+) {
+
+    private val scratchMatrix = Matrix()
+    private val recycledDrawScopes = mutableListOf<StrokeDrawScope>()
+
+    /**
+     * Call this at the beginning of [View.onDraw] and perform your Canvas manipulations within its
+     * scope. For example:
+     * ```
+     * viewStrokeRenderer.drawWithStrokes(canvas) { scope ->
+     *   scope.drawStroke(stroke)
+     *   // Repeat with other strokes, draw other things to the canvas, etc.
+     * }
+     * ```
+     *
+     * This is the preferred equivalent of:
+     * ```
+     * val scope = viewStrokeRenderer.obtainDrawScope(canvas)
+     * scope.drawStroke(stroke)
+     * viewStrokeRenderer.recycleDrawScope(scope)
+     * ```
+     */
+    public inline fun drawWithStrokes(canvas: Canvas, block: (StrokeDrawScope) -> Unit) {
+        val scope = obtainDrawScope(canvas)
+        block(scope)
+        recycleDrawScope(scope)
+    }
+
+    /**
+     * Manually obtain a scope to draw into the given [canvas].
+     *
+     * Prefer to use [drawWithStrokes] instead. This function is only public as a requirement for
+     * [drawWithStrokes] to be an inline function. If you do use this, be sure to call
+     * [recycleDrawScope] when finished drawing.
+     */
+    public fun obtainDrawScope(canvas: Canvas): StrokeDrawScope {
+        val viewToScreenTransform =
+            scratchMatrix.also {
+                it.reset()
+                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+                    view.transformMatrixToGlobal(it)
+                } else {
+                    transformMatrixToGlobalFallback(view, it)
+                }
+            }
+        require(viewToScreenTransform.isAffine) { "View to screen transform must be affine." }
+        val scope = recycledDrawScopes.removeFirstOrNull() ?: StrokeDrawScope(canvasStrokeRenderer)
+        scope.onDrawStart(viewToScreenTransform, canvas)
+        return scope
+    }
+
+    /**
+     * Recycle a [scope] for future use.
+     *
+     * This function should only be called by users if [scope] was obtained by directly calling
+     * [obtainDrawScope]. Prefer to use [drawWithStrokes] instead.
+     */
+    public fun recycleDrawScope(scope: StrokeDrawScope) {
+        recycledDrawScopes.add(scope)
+    }
+}
+
+/**
+ * Modify [matrix] such that it maps from view-local to on-screen coordinates when
+ * [View.transformMatrixToGlobal] is not available.
+ */
+private fun transformMatrixToGlobalFallback(view: View, matrix: Matrix) {
+    (view.parent as? View)?.let {
+        transformMatrixToGlobalFallback(it, matrix)
+        matrix.preTranslate(-it.scrollX.toFloat(), -it.scrollY.toFloat())
+    }
+    matrix.preTranslate(view.left.toFloat(), view.top.toFloat())
+    matrix.preConcat(view.matrix)
+}
diff --git a/ink/ink-strokes/src/jvmAndroidMain/kotlin/androidx/ink/strokes/InProgressStroke.kt b/ink/ink-strokes/src/jvmAndroidMain/kotlin/androidx/ink/strokes/InProgressStroke.kt
index 08b1649..7af48ff 100644
--- a/ink/ink-strokes/src/jvmAndroidMain/kotlin/androidx/ink/strokes/InProgressStroke.kt
+++ b/ink/ink-strokes/src/jvmAndroidMain/kotlin/androidx/ink/strokes/InProgressStroke.kt
@@ -207,6 +207,8 @@
 
     /**
      * Add the specified range of inputs from this stroke to the output [MutableStrokeInputBatch].
+     *
+     * @return [out]
      */
     @JvmOverloads
     public fun populateInputs(
@@ -224,6 +226,8 @@
     /**
      * Gets the value of the i-th input and overwrites [out]. Requires that [index] is positive and
      * less than [getInputCount].
+     *
+     * @return [out]
      */
     public fun populateInput(out: StrokeInput, @IntRange(from = 0) index: Int): StrokeInput {
         val size = getInputCount()
@@ -252,10 +256,11 @@
      *
      * @param coatIndex The index of the coat to obtain the bounding box from.
      * @param outBoxAccumulator The pre-allocated [BoxAccumulator] to be filled with the result.
+     * @return [outBoxAccumulator]
      */
     public fun populateMeshBounds(
         @IntRange(from = 0) coatIndex: Int,
-        outBoxAccumulator: BoxAccumulator
+        outBoxAccumulator: BoxAccumulator,
     ): BoxAccumulator {
         require(coatIndex >= 0 && coatIndex < getBrushCoatCount()) {
             "coatIndex=$coatIndex must be between 0 and brushCoatCount=${getBrushCoatCount()}"
@@ -269,6 +274,7 @@
      * [updateShape] since the most recent call to [start] or [resetUpdatedRegion].
      *
      * @param outBoxAccumulator The pre-allocated [BoxAccumulator] to be filled with the result.
+     * @return [outBoxAccumulator]
      */
     public fun populateUpdatedRegion(outBoxAccumulator: BoxAccumulator): BoxAccumulator {
         nativeFillUpdatedRegion(nativePointer, outBoxAccumulator)
diff --git a/ink/ink-strokes/src/jvmAndroidMain/kotlin/androidx/ink/strokes/StrokeInput.kt b/ink/ink-strokes/src/jvmAndroidMain/kotlin/androidx/ink/strokes/StrokeInput.kt
index 67f0e3d..e3cd9ad 100644
--- a/ink/ink-strokes/src/jvmAndroidMain/kotlin/androidx/ink/strokes/StrokeInput.kt
+++ b/ink/ink-strokes/src/jvmAndroidMain/kotlin/androidx/ink/strokes/StrokeInput.kt
@@ -17,7 +17,6 @@
 package androidx.ink.strokes
 
 import androidx.annotation.IntRange
-import androidx.annotation.RestrictTo
 import androidx.annotation.VisibleForTesting
 import androidx.ink.brush.InputToolType
 
@@ -135,7 +134,8 @@
         get() = orientationRadians != NO_ORIENTATION
 
     /**
-     * Overwrite this instance with new values.
+     * Set new values on this instance, clearing values corresponding to optional parameters that
+     * are not specified.
      *
      * @param x The `x` position coordinate of the input in the stroke's coordinate space.
      * @param y The `y` position coordinate of the input in the stroke's coordinate space.
@@ -144,7 +144,7 @@
      * @param toolType The type of tool used to create this input data.
      * @param strokeUnitLengthCm The physical distance in centimeters that the pointer must travel
      *   in order to produce an input motion of one stroke unit. For stylus/touch, this is the
-     *   real-world distance that the stylus/ fingertip must move in physical space; for mouse, this
+     *   real-world distance that the stylus/fingertip must move in physical space; for mouse, this
      *   is the visual distance that the mouse pointer must travel along the surface of the display.
      *   A value of [NO_STROKE_UNIT_LENGTH] indicates that the relationship between stroke space and
      *   physical space is unknown or ill-defined.
@@ -158,6 +158,8 @@
      *   end is along positive x and values increase towards the positive y-axis. Absence of
      *   [orientationRadians] data is represented with [NO_ORIENTATION].
      */
+    // TODO: b/355248266 - @UsedByNative("stroke_input_jni_helper.cc") must go in Proguard config
+    // file instead.
     @JvmOverloads
     public fun update(
         x: Float,
@@ -179,35 +181,6 @@
         this.orientationRadians = orientationRadians
     }
 
-    /** @see update */
-    // TODO: b/362469375 - Change JNI to use `update` and delete `overwrite`
-    // TODO: b/355248266 - @UsedByNative("stroke_input_jni_helper.cc") must go in Proguard config
-    // file instead.
-    @JvmOverloads
-    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) // NonPublicApi
-    @Deprecated("Renaming to update")
-    public fun overwrite(
-        x: Float,
-        y: Float,
-        @IntRange(from = 0) elapsedTimeMillis: Long,
-        toolType: InputToolType = InputToolType.UNKNOWN,
-        strokeUnitLengthCm: Float = NO_STROKE_UNIT_LENGTH,
-        pressure: Float = NO_PRESSURE,
-        tiltRadians: Float = NO_TILT,
-        orientationRadians: Float = NO_ORIENTATION,
-    ) {
-        update(
-            x,
-            y,
-            elapsedTimeMillis,
-            toolType,
-            strokeUnitLengthCm,
-            pressure,
-            tiltRadians,
-            orientationRadians
-        )
-    }
-
     public override fun equals(other: Any?): Boolean {
         // NOMUTANTS -- Check the instance first to short circuit faster.
         if (this === other) return true
diff --git a/ink/ink-strokes/src/jvmAndroidMain/kotlin/androidx/ink/strokes/StrokeInputBatch.kt b/ink/ink-strokes/src/jvmAndroidMain/kotlin/androidx/ink/strokes/StrokeInputBatch.kt
index f35fe70..5af5cc5 100644
--- a/ink/ink-strokes/src/jvmAndroidMain/kotlin/androidx/ink/strokes/StrokeInputBatch.kt
+++ b/ink/ink-strokes/src/jvmAndroidMain/kotlin/androidx/ink/strokes/StrokeInputBatch.kt
@@ -99,8 +99,8 @@
     public operator fun get(index: Int): StrokeInput = populate(index, StrokeInput())
 
     /**
-     * Gets the value of the i-th input and overwrites [outStrokeInput]. Requires that [index] is
-     * positive and less than [size]. Returns [outStrokeInput].
+     * Gets the value of the i-th input and overwrites [outStrokeInput], which it then returns.
+     * Requires that [index] is positive and less than [size].
      */
     public fun populate(index: Int, outStrokeInput: StrokeInput): StrokeInput {
         require(index < size && index >= 0) { "index ($index) must be in [0, size=$size)" }
diff --git a/ink/ink-strokes/src/jvmAndroidTest/kotlin/androidx/ink/strokes/InProgressStrokeTest.kt b/ink/ink-strokes/src/jvmAndroidTest/kotlin/androidx/ink/strokes/InProgressStrokeTest.kt
index 0592f8c..ca3a16e 100644
--- a/ink/ink-strokes/src/jvmAndroidTest/kotlin/androidx/ink/strokes/InProgressStrokeTest.kt
+++ b/ink/ink-strokes/src/jvmAndroidTest/kotlin/androidx/ink/strokes/InProgressStrokeTest.kt
@@ -517,10 +517,6 @@
 
         val triangleIndexBuffer = stroke.getRawTriangleIndexBuffer(0, 0)
 
-        // TODO: b/302535371 - Make this buffer read only
-        // assertThat(triangleIndexBuffer.isDirect).isTrue()
-        // assertThat(triangleIndexBuffer.isReadOnly).isTrue()
-        // assertFailsWith<ReadOnlyBufferException> { triangleIndexBuffer.put(5) }
         assertThat(triangleIndexBuffer.limit()).isEqualTo(0)
         assertThat(triangleIndexBuffer.capacity()).isEqualTo(0)
     }
@@ -533,10 +529,6 @@
 
         val triangleIndexBuffer = stroke.getRawTriangleIndexBuffer(0, 0)
 
-        // TODO: b/302535371 - Make this buffer read only
-        // assertThat(triangleIndexBuffer.isDirect).isTrue()
-        // assertThat(triangleIndexBuffer.isReadOnly).isTrue()
-        // assertFailsWith<ReadOnlyBufferException> { triangleIndexBuffer.put(5) }
         assertThat(triangleIndexBuffer.limit()).isNotEqualTo(0)
         assertThat(triangleIndexBuffer.capacity()).isNotEqualTo(0)
     }
diff --git a/ink/ink-strokes/src/jvmAndroidTest/kotlin/androidx/ink/strokes/StrokeInputTest.kt b/ink/ink-strokes/src/jvmAndroidTest/kotlin/androidx/ink/strokes/StrokeInputTest.kt
index 15a2f7e..1a1be43 100644
--- a/ink/ink-strokes/src/jvmAndroidTest/kotlin/androidx/ink/strokes/StrokeInputTest.kt
+++ b/ink/ink-strokes/src/jvmAndroidTest/kotlin/androidx/ink/strokes/StrokeInputTest.kt
@@ -45,7 +45,7 @@
     }
 
     @Test
-    fun overwrite_shouldReassignValues() {
+    fun update_shouldReassignValues() {
         val input = StrokeInput()
         input.update(1f, 2f, 3L, InputToolType.TOUCH)
         input.update(2f, 3f, 4L, InputToolType.STYLUS, 0.1f, 0.2f, 0.3f, 0.4f)
@@ -61,7 +61,7 @@
     }
 
     @Test
-    fun overwrite_withDefaultValues_shouldReassignValues() {
+    fun update_withDefaultValues_shouldReassignValues() {
         val input = StrokeInput()
         input.update(
             x = 1f,
diff --git a/libraryversions.toml b/libraryversions.toml
index c578bc6..7a9e5a0 100644
--- a/libraryversions.toml
+++ b/libraryversions.toml
@@ -29,7 +29,7 @@
 CONSTRAINTLAYOUT_CORE = "1.1.0-beta01"
 CONTENTPAGER = "1.1.0-alpha01"
 COORDINATORLAYOUT = "1.3.0-alpha02"
-CORE = "1.15.0-alpha02"
+CORE = "1.15.0-alpha03"
 CORE_ANIMATION = "1.0.0"
 CORE_ANIMATION_TESTING = "1.0.0"
 CORE_APPDIGEST = "1.0.0-alpha01"
@@ -69,7 +69,7 @@
 GRAPHICS_PATH = "1.0.0-rc01"
 GRAPHICS_SHAPES = "1.0.0-rc01"
 GRIDLAYOUT = "1.1.0-beta02"
-HEALTH_CONNECT = "1.1.0-alpha08"
+HEALTH_CONNECT = "1.1.0-alpha09"
 HEALTH_CONNECT_TESTING_QUARANTINE = "1.0.0-alpha01"
 HEALTH_SERVICES_CLIENT = "1.1.0-alpha03"
 HEIFWRITER = "1.1.0-alpha03"
@@ -89,7 +89,7 @@
 LEANBACK_TAB = "1.1.0-beta01"
 LEGACY = "1.1.0-alpha01"
 LIBYUV = "0.1.0-dev01"
-LIFECYCLE = "2.9.0-alpha02"
+LIFECYCLE = "2.9.0-alpha03"
 LIFECYCLE_EXTENSIONS = "2.2.0"
 LINT = "1.0.0-alpha02"
 LOADER = "1.2.0-alpha01"
@@ -99,7 +99,7 @@
 NAVIGATION = "2.9.0-alpha01"
 PAGING = "3.4.0-alpha01"
 PALETTE = "1.1.0-alpha01"
-PDF = "1.0.0-alpha02"
+PDF = "1.0.0-alpha03"
 PERCENTLAYOUT = "1.1.0-alpha01"
 PREFERENCE = "1.3.0-alpha01"
 PRINT = "1.1.0-beta01"
@@ -108,7 +108,7 @@
 PRIVACYSANDBOX_PLUGINS = "1.0.0-alpha03"
 PRIVACYSANDBOX_SDKRUNTIME = "1.0.0-alpha14"
 PRIVACYSANDBOX_TOOLS = "1.0.0-alpha09"
-PRIVACYSANDBOX_UI = "1.0.0-alpha09"
+PRIVACYSANDBOX_UI = "1.0.0-alpha10"
 PROFILEINSTALLER = "1.4.0-rc01"
 RECOMMENDATION = "1.1.0-alpha01"
 RECYCLERVIEW = "1.4.0-rc01"
@@ -153,8 +153,8 @@
 VIEWPAGER = "1.1.0-alpha02"
 VIEWPAGER2 = "1.2.0-alpha01"
 WEAR = "1.4.0-alpha01"
-WEAR_COMPOSE = "1.5.0-alpha01"
-WEAR_COMPOSE_MATERIAL3 = "1.0.0-alpha24"
+WEAR_COMPOSE = "1.5.0-alpha02"
+WEAR_COMPOSE_MATERIAL3 = "1.0.0-alpha25"
 WEAR_CORE = "1.0.0-alpha01"
 WEAR_INPUT = "1.2.0-alpha03"
 WEAR_INPUT_TESTING = "1.2.0-alpha03"
diff --git a/lifecycle/lifecycle-runtime-compose/build.gradle b/lifecycle/lifecycle-runtime-compose/build.gradle
index f15ee49..a802e2f 100644
--- a/lifecycle/lifecycle-runtime-compose/build.gradle
+++ b/lifecycle/lifecycle-runtime-compose/build.gradle
@@ -43,7 +43,7 @@
             dependencies {
                 api(project(":lifecycle:lifecycle-runtime"))
                 api("androidx.annotation:annotation:1.8.1")
-                api(project(":compose:runtime:runtime"))
+                api("androidx.compose.runtime:runtime:1.7.1")
             }
         }
 
diff --git a/lifecycle/lifecycle-runtime/build.gradle b/lifecycle/lifecycle-runtime/build.gradle
index 7f3a53e..6f21e36 100644
--- a/lifecycle/lifecycle-runtime/build.gradle
+++ b/lifecycle/lifecycle-runtime/build.gradle
@@ -71,7 +71,7 @@
             dependencies {
                 api(libs.kotlinCoroutinesAndroid)
                 implementation("androidx.arch.core:core-runtime:2.2.0")
-                implementation("androidx.profileinstaller:profileinstaller:1.3.1")
+                implementation("androidx.profileinstaller:profileinstaller:1.4.0")
             }
         }
 
diff --git a/navigation/navigation-common/build.gradle b/navigation/navigation-common/build.gradle
index cc8206c..e76f7a7 100644
--- a/navigation/navigation-common/build.gradle
+++ b/navigation/navigation-common/build.gradle
@@ -48,7 +48,7 @@
     api("androidx.lifecycle:lifecycle-viewmodel-savedstate:2.6.2")
     implementation("androidx.core:core-ktx:1.1.0")
     implementation("androidx.collection:collection-ktx:1.4.2")
-    implementation("androidx.profileinstaller:profileinstaller:1.3.1")
+    implementation("androidx.profileinstaller:profileinstaller:1.4.0")
     implementation(libs.kotlinSerializationCore)
 
     api(libs.kotlinStdlib)
diff --git a/pdf/pdf-viewer-fragment/src/main/java/androidx/pdf/viewer/fragment/PdfViewerFragment.kt b/pdf/pdf-viewer-fragment/src/main/java/androidx/pdf/viewer/fragment/PdfViewerFragment.kt
index b8b5e3e..b8d85ac 100644
--- a/pdf/pdf-viewer-fragment/src/main/java/androidx/pdf/viewer/fragment/PdfViewerFragment.kt
+++ b/pdf/pdf-viewer-fragment/src/main/java/androidx/pdf/viewer/fragment/PdfViewerFragment.kt
@@ -27,7 +27,6 @@
 import android.view.WindowManager
 import android.widget.FrameLayout
 import androidx.core.os.BundleCompat
-import androidx.core.view.ViewCompat
 import androidx.core.view.WindowInsetsCompat
 import androidx.core.view.updateLayoutParams
 import androidx.fragment.app.Fragment
@@ -151,6 +150,7 @@
     private var shouldRedrawOnDocumentLoaded = false
     private var isAnnotationIntentResolvable = false
     private var documentLoaded = false
+    private var isSearchMenuAdjusted = false
 
     /**
      * The URI of the PDF document to display defaulting to `null`.
@@ -308,13 +308,20 @@
             paginatedView?.isConfigurationChanged = true
         }
 
-        // Use ViewCompat.setOnApplyWindowInsetsListener to listen for window insets changes
-        findInFileView?.let { findInFileViewInstance ->
-            activity?.let {
-                ViewCompat.setOnApplyWindowInsetsListener(it.window.decorView) { _, insets ->
-                    adjustInsetsForSearchMenu(findInFileViewInstance, it, insets)
-                    // Return insets to continue the default behavior
-                    insets
+        /**
+         * Need to adjust the view only after the layout phase is completed for the views to
+         * accurately calculate the height of the view. The condition for visibility and
+         * [isSearchMenuAdjusted] guarantees that the listener is only invoked once after layout
+         * change.
+         */
+        findInFileView?.let { view ->
+            view.viewTreeObserver?.addOnGlobalLayoutListener {
+                if (view.visibility == View.VISIBLE) {
+                    if (!isSearchMenuAdjusted) {
+                        activity?.let { adjustInsetsForSearchMenu(view, it) }
+                    } else {
+                        isSearchMenuAdjusted = false
+                    }
                 }
             }
         }
@@ -356,18 +363,15 @@
     }
 
     /** Adjusts the [FindInFileView] to be displayed on top of the keyboard. */
-    private fun adjustInsetsForSearchMenu(
-        findInFileView: FindInFileView,
-        activity: Activity,
-        insets: WindowInsetsCompat
-    ) {
+    private fun adjustInsetsForSearchMenu(findInFileView: FindInFileView, activity: Activity) {
         val containerLocation = IntArray(2)
         container!!.getLocationInWindow(containerLocation)
 
         val windowManager = activity.getSystemService(Context.WINDOW_SERVICE) as WindowManager
         val screenHeight = windowManager.currentWindowMetrics.bounds.height()
 
-        val imeInsets = insets.getInsets(WindowInsetsCompat.Type.ime())
+        val imeInsets =
+            activity.window.decorView.rootWindowInsets.getInsets(WindowInsetsCompat.Type.ime())
 
         val keyboardTop = screenHeight - imeInsets.bottom
         val absoluteContainerBottom = container!!.height + containerLocation[1]
@@ -379,6 +383,7 @@
         findInFileView.updateLayoutParams<ViewGroup.MarginLayoutParams> {
             bottomMargin = menuMargin
         }
+        isSearchMenuAdjusted = true
     }
 
     /** Called after this viewer enters the screen and becomes visible. */
diff --git a/pdf/pdf-viewer/src/main/java/androidx/pdf/viewer/PaginatedView.java b/pdf/pdf-viewer/src/main/java/androidx/pdf/viewer/PaginatedView.java
index ea847f0..f3d3afd 100644
--- a/pdf/pdf-viewer/src/main/java/androidx/pdf/viewer/PaginatedView.java
+++ b/pdf/pdf-viewer/src/main/java/androidx/pdf/viewer/PaginatedView.java
@@ -97,9 +97,6 @@
     @Override
     protected void onDetachedFromWindow() {
         super.onDetachedFromWindow();
-        if (mPageRangeHandler != null) {
-            mPageRangeHandler.setVisiblePages(null);
-        }
         mModel.removeObserver(this);
     }
 
@@ -333,6 +330,10 @@
 
     @Override
     public void removeAllViews() {
+        if (mPageRangeHandler != null) {
+            mPageRangeHandler.setVisiblePages(null);
+        }
+
         for (int i = 0; i < mPageViews.size(); i++) {
             mPageViews.valueAt(i).clearAll();
         }
diff --git a/pdf/pdf-viewer/src/main/res/layout/find_in_file.xml b/pdf/pdf-viewer/src/main/res/layout/find_in_file.xml
index 176e323..2f655f7 100644
--- a/pdf/pdf-viewer/src/main/res/layout/find_in_file.xml
+++ b/pdf/pdf-viewer/src/main/res/layout/find_in_file.xml
@@ -34,14 +34,15 @@
             android:hint="@string/hint_find"
             android:textColor="?attr/colorOnSurface"
             android:textColorHint="?attr/colorOutline"
-            android:paddingLeft="16dp"
+            android:paddingLeft="14dp"
             android:imeOptions="actionSearch"
             android:inputType="textFilter"
-            android:textSize="20sp"
+            android:textSize="16sp"
             android:clickable="true"
             android:focusable="true"
             android:background="@null"
-            style="@style/TextAppearance.Material3.TitleMedium">
+            style="@style/TextAppearance.Material3.TitleSmall"
+            android:layout_gravity="center_vertical">
         </androidx.pdf.widget.SearchEditText>
 
         <TextView android:id="@+id/match_status_textview"
@@ -49,7 +50,8 @@
             android:layout_height="wrap_content"
             android:layout_alignEnd="@+id/query_box"
             android:paddingRight="10dp"
-            android:textColor="?attr/colorOnSurfaceVariant">
+            android:textColor="?attr/colorOnSurfaceVariant"
+            android:layout_gravity="center_vertical">
         </TextView>
     </LinearLayout>
 
diff --git a/privacysandbox/ui/integration-tests/mediateesdkprovider/build.gradle b/privacysandbox/ui/integration-tests/mediateesdkprovider/build.gradle
index 4ac1624..2073543 100644
--- a/privacysandbox/ui/integration-tests/mediateesdkprovider/build.gradle
+++ b/privacysandbox/ui/integration-tests/mediateesdkprovider/build.gradle
@@ -26,6 +26,7 @@
     defaultConfig {
         applicationId "androidx.privacysandbox.ui.integration.mediateesdkprovider"
         minSdk 33
+        compileSdk 35
     }
 
     buildTypes {
diff --git a/privacysandbox/ui/integration-tests/sdkproviderutils/build.gradle b/privacysandbox/ui/integration-tests/sdkproviderutils/build.gradle
index 323d9a0..c136012 100644
--- a/privacysandbox/ui/integration-tests/sdkproviderutils/build.gradle
+++ b/privacysandbox/ui/integration-tests/sdkproviderutils/build.gradle
@@ -22,9 +22,11 @@
 
 android {
     namespace "androidx.privacysandbox.ui.integration.sdkproviderutils"
+    compileSdk 35
 }
 
 dependencies {
+    implementation project(':privacysandbox:ui:ui-client')
     implementation project(':privacysandbox:ui:ui-core')
     implementation project(':privacysandbox:ui:ui-provider')
     implementation project(':privacysandbox:ui:integration-tests:testaidl')
diff --git a/privacysandbox/ui/integration-tests/sdkproviderutils/src/main/java/androidx/privacysandbox/ui/integration/sdkproviderutils/SdkApiConstants.kt b/privacysandbox/ui/integration-tests/sdkproviderutils/src/main/java/androidx/privacysandbox/ui/integration/sdkproviderutils/SdkApiConstants.kt
index 66da2c5..d466ef1 100644
--- a/privacysandbox/ui/integration-tests/sdkproviderutils/src/main/java/androidx/privacysandbox/ui/integration/sdkproviderutils/SdkApiConstants.kt
+++ b/privacysandbox/ui/integration-tests/sdkproviderutils/src/main/java/androidx/privacysandbox/ui/integration/sdkproviderutils/SdkApiConstants.kt
@@ -32,7 +32,8 @@
             companion object {
                 const val NON_MEDIATED = 0
                 const val SDK_RUNTIME_MEDIATEE = 1
-                const val IN_APP_MEDIATEE = 2
+                const val SDK_RUNTIME_MEDIATEE_WITH_OVERLAY = 2
+                const val IN_APP_MEDIATEE = 3
             }
         }
     }
diff --git a/privacysandbox/ui/integration-tests/sdkproviderutils/src/main/java/androidx/privacysandbox/ui/integration/sdkproviderutils/TestAdapters.kt b/privacysandbox/ui/integration-tests/sdkproviderutils/src/main/java/androidx/privacysandbox/ui/integration/sdkproviderutils/TestAdapters.kt
index b613689..1d05403 100644
--- a/privacysandbox/ui/integration-tests/sdkproviderutils/src/main/java/androidx/privacysandbox/ui/integration/sdkproviderutils/TestAdapters.kt
+++ b/privacysandbox/ui/integration-tests/sdkproviderutils/src/main/java/androidx/privacysandbox/ui/integration/sdkproviderutils/TestAdapters.kt
@@ -25,6 +25,7 @@
 import android.graphics.Paint
 import android.graphics.Path
 import android.net.Uri
+import android.os.Bundle
 import android.os.Handler
 import android.os.IBinder
 import android.os.Looper
@@ -37,6 +38,10 @@
 import android.webkit.WebResourceResponse
 import android.webkit.WebSettings
 import android.webkit.WebView
+import android.widget.LinearLayout
+import android.widget.TextView
+import androidx.privacysandbox.ui.client.SandboxedUiAdapterFactory
+import androidx.privacysandbox.ui.client.view.SandboxedSdkView
 import androidx.privacysandbox.ui.core.SandboxedUiAdapter
 import androidx.privacysandbox.ui.provider.AbstractSandboxedUiAdapter
 import androidx.webkit.WebViewAssetLoader
@@ -46,7 +51,7 @@
 class TestAdapters(private val sdkContext: Context) {
     inner class TestBannerAd(private val text: String, private val withSlowDraw: Boolean) :
         BannerAd() {
-        override fun buildAdView(sessionContext: Context): View {
+        override fun buildAdView(sessionContext: Context, width: Int, height: Int): View? {
             return TestView(sessionContext, withSlowDraw, text)
         }
     }
@@ -55,7 +60,7 @@
         lateinit var sessionClientExecutor: Executor
         lateinit var sessionClient: SandboxedUiAdapter.SessionClient
 
-        abstract fun buildAdView(sessionContext: Context): View?
+        abstract fun buildAdView(sessionContext: Context, width: Int, height: Int): View?
 
         override fun openSession(
             context: Context,
@@ -72,7 +77,8 @@
                 .post(
                     Runnable lambda@{
                         Log.d(TAG, "Session requested")
-                        val adView: View = buildAdView(context) ?: return@lambda
+                        val adView: View =
+                            buildAdView(context, initialWidth, initialHeight) ?: return@lambda
                         adView.layoutParams = ViewGroup.LayoutParams(initialWidth, initialHeight)
                         clientExecutor.execute { client.onSessionOpened(BannerAdSession(adView)) }
                     }
@@ -112,7 +118,7 @@
             ) != 0
         }
 
-        override fun buildAdView(sessionContext: Context): View? {
+        override fun buildAdView(sessionContext: Context, width: Int, height: Int): View? {
             if (isAirplaneModeOn()) {
                 sessionClientExecutor.execute {
                     sessionClient.onSessionError(Throwable("Cannot load WebView in airplane mode."))
@@ -128,7 +134,7 @@
 
     inner class VideoBannerAd(private val playerViewProvider: PlayerViewProvider) : BannerAd() {
 
-        override fun buildAdView(sessionContext: Context): View {
+        override fun buildAdView(sessionContext: Context, width: Int, height: Int): View? {
             return playerViewProvider.createPlayerView(
                 sessionContext,
                 "https://html5demos.com/assets/dizzy.mp4"
@@ -137,7 +143,7 @@
     }
 
     inner class WebViewAdFromLocalAssets : BannerAd() {
-        override fun buildAdView(sessionContext: Context): View {
+        override fun buildAdView(sessionContext: Context, width: Int, height: Int): View? {
             val webView = WebView(sessionContext)
             val assetLoader =
                 WebViewAssetLoader.Builder()
@@ -151,6 +157,35 @@
         }
     }
 
+    inner class OverlaidAd(private val mediateeBundle: Bundle) : BannerAd() {
+        override fun buildAdView(sessionContext: Context, width: Int, height: Int): View {
+            val adapter = SandboxedUiAdapterFactory.createFromCoreLibInfo(mediateeBundle)
+            val linearLayout = LinearLayout(sessionContext)
+            linearLayout.orientation = LinearLayout.VERTICAL
+            linearLayout.layoutParams = LinearLayout.LayoutParams(width, height)
+            // The SandboxedSdkView will take up 90% of the parent height, with the overlay taking
+            // the other 10%
+            val ssvParams =
+                LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 0, 0.9f)
+            val overlayParams =
+                LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 0, 0.1f)
+            val sandboxedSdkView = SandboxedSdkView(sessionContext)
+            sandboxedSdkView.setAdapter(adapter)
+            sandboxedSdkView.layoutParams = ssvParams
+            linearLayout.addView(sandboxedSdkView)
+            val textView =
+                TextView(sessionContext).also {
+                    it.setBackgroundColor(Color.GRAY)
+                    it.text = "Mediator Overlay"
+                    it.textSize = 20f
+                    it.setTextColor(Color.BLACK)
+                    it.layoutParams = overlayParams
+                }
+            linearLayout.addView(textView)
+            return linearLayout
+        }
+    }
+
     private inner class TestView(
         context: Context,
         private val withSlowDraw: Boolean,
@@ -251,7 +286,6 @@
         settings.javaScriptEnabled = true
         settings.setGeolocationEnabled(true)
         settings.setSupportZoom(true)
-        settings.databaseEnabled = true
         settings.domStorageEnabled = true
         settings.allowFileAccess = true
         settings.allowContentAccess = true
diff --git a/privacysandbox/ui/integration-tests/testapp/src/main/java/androidx/privacysandbox/ui/integration/testapp/MainActivity.kt b/privacysandbox/ui/integration-tests/testapp/src/main/java/androidx/privacysandbox/ui/integration/testapp/MainActivity.kt
index b64294d..c929aaf 100644
--- a/privacysandbox/ui/integration-tests/testapp/src/main/java/androidx/privacysandbox/ui/integration/testapp/MainActivity.kt
+++ b/privacysandbox/ui/integration-tests/testapp/src/main/java/androidx/privacysandbox/ui/integration/testapp/MainActivity.kt
@@ -192,26 +192,8 @@
                         isCalledOnStartingApp = false
                         return
                     }
-                    // Mediation is enabled if Runtime-Runtime Mediation option or Runtime-App
-                    // Mediation
-                    // option is selected.
-                    val appOwnedMediationEnabled =
-                        selectedMediationOptionId == MediationOption.IN_APP_MEDIATEE.toLong()
-                    val mediationEnabled =
-                        (selectedMediationOptionId ==
-                            MediationOption.SDK_RUNTIME_MEDIATEE.toLong() ||
-                            appOwnedMediationEnabled)
 
-                    mediationOption =
-                        if (mediationEnabled) {
-                            if (appOwnedMediationEnabled) {
-                                MediationOption.IN_APP_MEDIATEE
-                            } else {
-                                MediationOption.SDK_RUNTIME_MEDIATEE
-                            }
-                        } else {
-                            MediationOption.NON_MEDIATED
-                        }
+                    mediationOption = selectedMediationOptionId.toInt()
                     loadAllAds()
                 }
 
diff --git a/privacysandbox/ui/integration-tests/testapp/src/main/res/values/mediation_options.xml b/privacysandbox/ui/integration-tests/testapp/src/main/res/values/mediation_options.xml
index 54ce3c3..6b526be 100644
--- a/privacysandbox/ui/integration-tests/testapp/src/main/res/values/mediation_options.xml
+++ b/privacysandbox/ui/integration-tests/testapp/src/main/res/values/mediation_options.xml
@@ -18,7 +18,8 @@
 <resources>
     <string-array name="mediation_dropdown_menu_array">
         <item>Mediated Ad > None (default)</item>
-        <item>Runtime-Runtime Mediation</item>
+        <item>Runtime-Runtime Mediation without Overlay</item>
+        <item>Runtime-Runtime Mediation with Overlay</item>
         <item>Runtime-App Mediation</item>
     </string-array>
 </resources>
diff --git a/privacysandbox/ui/integration-tests/testsdkprovider/build.gradle b/privacysandbox/ui/integration-tests/testsdkprovider/build.gradle
index 3c96f20..441c562 100644
--- a/privacysandbox/ui/integration-tests/testsdkprovider/build.gradle
+++ b/privacysandbox/ui/integration-tests/testsdkprovider/build.gradle
@@ -26,6 +26,7 @@
     defaultConfig {
         applicationId "androidx.privacysandbox.ui.integration.testsdkprovider"
         minSdk 33
+        compileSdk 35
     }
 
     buildTypes {
diff --git a/privacysandbox/ui/integration-tests/testsdkprovider/src/main/java/androidx/privacysandbox/ui/integration/testsdkprovider/SdkApi.kt b/privacysandbox/ui/integration-tests/testsdkprovider/src/main/java/androidx/privacysandbox/ui/integration/testsdkprovider/SdkApi.kt
index 53534f9..c5370e1 100644
--- a/privacysandbox/ui/integration-tests/testsdkprovider/src/main/java/androidx/privacysandbox/ui/integration/testsdkprovider/SdkApi.kt
+++ b/privacysandbox/ui/integration-tests/testsdkprovider/src/main/java/androidx/privacysandbox/ui/integration/testsdkprovider/SdkApi.kt
@@ -40,17 +40,19 @@
         waitInsideOnDraw: Boolean,
         drawViewability: Boolean
     ): Bundle {
-        val isMediation =
-            (mediationOption == MediationOption.SDK_RUNTIME_MEDIATEE ||
-                mediationOption == MediationOption.IN_APP_MEDIATEE)
+        val isMediation = mediationOption != MediationOption.NON_MEDIATED
         val isAppOwnedMediation = (mediationOption == MediationOption.IN_APP_MEDIATEE)
         if (isMediation) {
-            return maybeGetMediateeBannerAdBundle(
-                isAppOwnedMediation,
-                adType,
-                waitInsideOnDraw,
-                drawViewability
-            )
+            val mediateeBundle =
+                maybeGetMediateeBannerAdBundle(
+                    isAppOwnedMediation,
+                    adType,
+                    waitInsideOnDraw,
+                    drawViewability
+                )
+            return if (mediationOption == MediationOption.SDK_RUNTIME_MEDIATEE_WITH_OVERLAY) {
+                testAdapters.OverlaidAd(mediateeBundle).toCoreLibInfo(sdkContext)
+            } else mediateeBundle
         }
         val adapter: SandboxedUiAdapter =
             when (adType) {
diff --git a/recyclerview/recyclerview/build.gradle b/recyclerview/recyclerview/build.gradle
index 395de30..75f30a7 100644
--- a/recyclerview/recyclerview/build.gradle
+++ b/recyclerview/recyclerview/build.gradle
@@ -19,7 +19,7 @@
     implementation("androidx.collection:collection:1.4.2")
     api("androidx.customview:customview:1.0.0")
     implementation("androidx.customview:customview-poolingcontainer:1.0.0")
-    implementation("androidx.profileinstaller:profileinstaller:1.3.1")
+    implementation("androidx.profileinstaller:profileinstaller:1.4.0")
 
     androidTestImplementation(libs.testExtJunit)
     androidTestImplementation(libs.testCore)
diff --git a/room/integration-tests/kotlintestapp/src/androidTest/java/androidx/room/integration/kotlintestapp/test/FlowQueryTest.kt b/room/integration-tests/kotlintestapp/src/androidTest/java/androidx/room/integration/kotlintestapp/test/FlowQueryTest.kt
index 9623b95..ee0cb6d 100644
--- a/room/integration-tests/kotlintestapp/src/androidTest/java/androidx/room/integration/kotlintestapp/test/FlowQueryTest.kt
+++ b/room/integration-tests/kotlintestapp/src/androidTest/java/androidx/room/integration/kotlintestapp/test/FlowQueryTest.kt
@@ -29,6 +29,7 @@
 import java.util.concurrent.TimeUnit
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.FlowPreview
 import kotlinx.coroutines.async
 import kotlinx.coroutines.cancelAndJoin
 import kotlinx.coroutines.channels.Channel
@@ -36,7 +37,6 @@
 import kotlinx.coroutines.flow.first
 import kotlinx.coroutines.flow.produceIn
 import kotlinx.coroutines.flow.take
-import kotlinx.coroutines.launch
 import kotlinx.coroutines.runBlocking
 import kotlinx.coroutines.yield
 import org.junit.After
@@ -44,7 +44,7 @@
 import org.junit.Test
 import org.junit.runner.RunWith
 
-@OptIn(ExperimentalCoroutinesApi::class)
+@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
 @MediumTest
 @RunWith(AndroidJUnit4::class)
 class FlowQueryTest : TestDatabaseTest() {
@@ -98,7 +98,7 @@
 
         val latch = CountDownLatch(1)
         val job =
-            launch(Dispatchers.IO) {
+            async(Dispatchers.IO) {
                 booksDao.getBooksFlow().collect {
                     assertThat(it).isEqualTo(listOf(TestUtil.BOOK_1, TestUtil.BOOK_2))
                     latch.countDown()
@@ -119,7 +119,7 @@
         val secondResultLatch = CountDownLatch(1)
         val results = mutableListOf<List<Book>>()
         val job =
-            launch(Dispatchers.IO) {
+            async(Dispatchers.IO) {
                 booksDao.getBooksFlow().collect {
                     when (results.size) {
                         0 -> {
diff --git a/room/integration-tests/kotlintestapp/src/androidTest/java/androidx/room/integration/kotlintestapp/test/InvalidationTrackerFlowTest.kt b/room/integration-tests/kotlintestapp/src/androidTest/java/androidx/room/integration/kotlintestapp/test/InvalidationTrackerFlowTest.kt
index d1e44b9..6d08d0f 100644
--- a/room/integration-tests/kotlintestapp/src/androidTest/java/androidx/room/integration/kotlintestapp/test/InvalidationTrackerFlowTest.kt
+++ b/room/integration-tests/kotlintestapp/src/androidTest/java/androidx/room/integration/kotlintestapp/test/InvalidationTrackerFlowTest.kt
@@ -17,88 +17,84 @@
 package androidx.room.integration.kotlintestapp.test
 
 import androidx.kruth.assertThat
-import androidx.kruth.assertThrows
-import androidx.room.Room
-import androidx.room.integration.kotlintestapp.TestDatabase
-import androidx.room.integration.kotlintestapp.dao.BooksDao
+import androidx.room.invalidationTrackerFlow
 import androidx.room.withTransaction
-import androidx.test.core.app.ApplicationProvider
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.MediumTest
-import kotlin.time.Duration.Companion.minutes
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.async
+import kotlinx.coroutines.cancelAndJoin
 import kotlinx.coroutines.channels.Channel
 import kotlinx.coroutines.flow.buffer
 import kotlinx.coroutines.flow.first
 import kotlinx.coroutines.flow.map
 import kotlinx.coroutines.flow.produceIn
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.test.TestScope
-import kotlinx.coroutines.test.advanceUntilIdle
-import kotlinx.coroutines.test.runTest
-import org.junit.Before
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.yield
+import org.junit.After
+import org.junit.Assert.fail
+import org.junit.Ignore
 import org.junit.Test
 import org.junit.runner.RunWith
 
-@OptIn(ExperimentalCoroutinesApi::class)
+@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
 @MediumTest
 @RunWith(AndroidJUnit4::class)
-class InvalidationTrackerFlowTest {
+class InvalidationTrackerFlowTest : TestDatabaseTest() {
 
-    private val testCoroutineScope = TestScope()
-
-    private lateinit var database: TestDatabase
-    private lateinit var booksDao: BooksDao
-
-    @Before
-    fun setup() {
-        database =
-            Room.inMemoryDatabaseBuilder(
-                    ApplicationProvider.getApplicationContext(),
-                    TestDatabase::class.java
-                )
-                .setQueryCoroutineContext(testCoroutineScope.coroutineContext)
-                .build()
-        booksDao = database.booksDao()
+    @After
+    fun teardown() {
+        // At the end of all tests, query executor should be idle (transaction thread released).
+        countingTaskExecutorRule.drainTasks(500, TimeUnit.MILLISECONDS)
+        assertThat(countingTaskExecutorRule.isIdle).isTrue()
     }
 
     @Test
-    fun initiallyEmitAllTableNames(): Unit = runTest {
-        val result = database.invalidationTracker.createFlow("author", "publisher", "book").first()
+    fun initiallyEmitAllTableNames(): Unit = runBlocking {
+        val result = database.invalidationTrackerFlow("author", "publisher", "book").first()
         assertThat(result).containsExactly("author", "publisher", "book")
     }
 
     @Test
-    fun initiallyEmitNothingWhenLazy(): Unit = runTest {
+    fun initiallyEmitNothingWhenLazy(): Unit = runBlocking {
         val channel =
-            database.invalidationTracker.createFlow("author", "publisher", "book").produceIn(this)
+            database
+                .invalidationTrackerFlow("author", "publisher", "book", emitInitialState = true)
+                .produceIn(this)
 
-        testScheduler.advanceUntilIdle()
+        drain()
+        yield()
 
         assertThat(channel.isEmpty)
 
         channel.cancel()
     }
 
+    @Ignore("b/295223748")
     @Test
-    fun invalidationEmitTableNames(): Unit = runTest {
+    fun invalidationEmitTableNames(): Unit = runBlocking {
         booksDao.addAuthors(TestUtil.AUTHOR_1)
         booksDao.addPublishers(TestUtil.PUBLISHER)
         booksDao.addBooks(TestUtil.BOOK_1)
 
         val channel =
-            database.invalidationTracker.createFlow("author", "publisher", "book").produceIn(this)
+            database.invalidationTrackerFlow("author", "publisher", "book").produceIn(this)
 
         assertThat(channel.receive()).isEqualTo(setOf("author", "publisher", "book"))
 
         booksDao.insertBookSuspend(TestUtil.BOOK_2)
-        testScheduler.advanceUntilIdle()
+        drain() // drain async invalidate
+        yield()
 
         assertThat(channel.receive()).containsExactly("book")
 
         booksDao.addPublisher(TestUtil.PUBLISHER2)
-        testScheduler.advanceUntilIdle()
+        drain() // drain async invalidate
+        yield()
 
         assertThat(channel.receive()).containsExactly("publisher")
 
@@ -107,50 +103,50 @@
         channel.cancel()
     }
 
+    @Ignore // b/268534919
     @Test
-    fun emitOnceForMultipleTablesInTransaction(): Unit = runTest {
-        val resultChannel = Channel<Set<String>>(capacity = 10)
+    fun emitOnceForMultipleTablesInTransaction(): Unit = runBlocking {
+        val results = mutableListOf<Set<String>>()
+        val latch = CountDownLatch(1)
         val job =
-            backgroundScope.launch(Dispatchers.IO) {
-                database.invalidationTracker.createFlow("author", "publisher", "book").collect {
-                    resultChannel.send(it)
+            async(Dispatchers.IO) {
+                database.invalidationTrackerFlow("author", "publisher", "book").collect {
+                    results.add(it)
+                    latch.countDown()
                 }
             }
 
-        testScheduler.advanceUntilIdle()
-
         database.withTransaction {
             booksDao.addAuthors(TestUtil.AUTHOR_1)
             booksDao.addPublishers(TestUtil.PUBLISHER)
             booksDao.addBooks(TestUtil.BOOK_1)
         }
+        latch.await()
+        job.cancelAndJoin()
 
-        advanceUntilIdle()
-
-        val result = resultChannel.receive()
-        assertThat(result).containsExactly("author", "publisher", "book")
-        assertThat(result.isEmpty())
-
-        resultChannel.close()
-        job.cancel()
+        assertThat(results.size).isEqualTo(1)
+        assertThat(results.first()).isEqualTo(setOf("author", "publisher", "book"))
     }
 
     @Test
-    fun dropInvalidationUsingConflated() = runTest {
+    fun dropInvalidationUsingConflated() = runBlocking {
         val channel =
-            database.invalidationTracker
-                .createFlow("author", "publisher", "book")
+            database
+                .invalidationTrackerFlow("author", "publisher", "book")
                 .buffer(Channel.CONFLATED)
                 .produceIn(this)
 
         booksDao.addAuthors(TestUtil.AUTHOR_1)
-        testScheduler.advanceUntilIdle()
+        drain() // drain async invalidate
+        yield()
 
         booksDao.addPublishers(TestUtil.PUBLISHER)
-        testScheduler.advanceUntilIdle()
+        drain() // drain async invalidate
+        yield()
 
         booksDao.addBooks(TestUtil.BOOK_1)
-        testScheduler.advanceUntilIdle()
+        drain() // drain async invalidate
+        yield()
 
         assertThat(channel.receive()).containsExactly("book")
         assertThat(channel.isEmpty).isTrue()
@@ -159,29 +155,28 @@
     }
 
     @Test
-    fun collectInTransaction(): Unit = runTest {
+    fun collectInTransaction(): Unit = runBlocking {
         database.withTransaction {
-            val result = database.invalidationTracker.createFlow("author").first()
+            val result = database.invalidationTrackerFlow("author").first()
             assertThat(result).containsExactly("author")
         }
     }
 
+    @Ignore("b/277764166")
     @Test
-    fun mapBlockingQuery() = runTest {
+    fun mapBlockingQuery() = runBlocking {
         booksDao.addAuthors(TestUtil.AUTHOR_1)
         booksDao.addPublishers(TestUtil.PUBLISHER)
         booksDao.addBooks(TestUtil.BOOK_1)
 
         val channel =
-            database.invalidationTracker
-                .createFlow("book")
-                .map { booksDao.getAllBooks() }
-                .produceIn(this)
+            database.invalidationTrackerFlow("book").map { booksDao.getAllBooks() }.produceIn(this)
 
         assertThat(channel.receive()).containsExactly(TestUtil.BOOK_1)
 
         booksDao.addBooks(TestUtil.BOOK_2)
-        testScheduler.advanceUntilIdle()
+        drain() // drain async invalidate
+        yield()
 
         assertThat(channel.receive()).containsExactly(TestUtil.BOOK_1, TestUtil.BOOK_2)
 
@@ -189,21 +184,23 @@
     }
 
     @Test
-    fun mapSuspendingQuery() = runTest {
+    @Ignore("b/295325379")
+    fun mapSuspendingQuery() = runBlocking {
         booksDao.addAuthors(TestUtil.AUTHOR_1)
         booksDao.addPublishers(TestUtil.PUBLISHER)
         booksDao.addBooks(TestUtil.BOOK_1)
 
         val channel =
-            database.invalidationTracker
-                .createFlow("book")
+            database
+                .invalidationTrackerFlow("book")
                 .map { booksDao.getBooksSuspend() }
                 .produceIn(this)
 
         assertThat(channel.receive()).containsExactly(TestUtil.BOOK_1)
 
         booksDao.addBooks(TestUtil.BOOK_2)
-        testScheduler.advanceUntilIdle()
+        drain() // drain async invalidate
+        yield()
 
         assertThat(channel.receive()).containsExactly(TestUtil.BOOK_1, TestUtil.BOOK_2)
 
@@ -211,43 +208,46 @@
     }
 
     @Test
-    fun mapFlowQuery() = runTest {
+    fun mapFlowQuery() = runBlocking {
         booksDao.addAuthors(TestUtil.AUTHOR_1)
         booksDao.addPublishers(TestUtil.PUBLISHER)
         booksDao.addBooks(TestUtil.BOOK_1)
 
         val channel =
-            database.invalidationTracker
-                .createFlow("book")
+            database
+                .invalidationTrackerFlow("book")
                 .map { booksDao.getBooksFlow().first() }
                 .produceIn(this)
 
         assertThat(channel.receive()).containsExactly(TestUtil.BOOK_1)
 
         booksDao.addBooks(TestUtil.BOOK_2)
-        testScheduler.advanceUntilIdle()
+        drain() // drain async invalidate
+        yield()
 
         assertThat(channel.receive()).containsExactly(TestUtil.BOOK_1, TestUtil.BOOK_2)
 
         channel.cancel()
     }
 
+    @Ignore("b/277764166")
     @Test
-    fun mapTransactionQuery() = runTest {
+    fun mapTransactionQuery() = runBlocking {
         booksDao.addAuthors(TestUtil.AUTHOR_1)
         booksDao.addPublishers(TestUtil.PUBLISHER)
         booksDao.addBooks(TestUtil.BOOK_1)
 
         val channel =
-            database.invalidationTracker
-                .createFlow("book")
+            database
+                .invalidationTrackerFlow("book")
                 .map { database.withTransaction { booksDao.getBooksSuspend() } }
                 .produceIn(this)
 
         assertThat(channel.receive()).containsExactly(TestUtil.BOOK_1)
 
         booksDao.addBooks(TestUtil.BOOK_2)
-        testScheduler.advanceUntilIdle()
+        drain() // drain async invalidate
+        yield()
 
         assertThat(channel.receive()).containsExactly(TestUtil.BOOK_1, TestUtil.BOOK_2)
 
@@ -255,16 +255,17 @@
     }
 
     @Test
-    fun transactionUpdateAndTransactionQuery() = runTest {
+    fun transactionUpdateAndTransactionQuery() = runBlocking {
         booksDao.addPublishers(TestUtil.PUBLISHER)
         booksDao.addBooks(TestUtil.BOOK_1)
 
-        val resultChannel = Channel<List<String>>(capacity = 2)
-
+        val results = mutableListOf<List<String>>()
+        val firstResultLatch = CountDownLatch(1)
+        val secondResultLatch = CountDownLatch(1)
         val job =
-            backgroundScope.launch(Dispatchers.IO) {
-                database.invalidationTracker
-                    .createFlow("author", "publisher")
+            async(Dispatchers.IO) {
+                database
+                    .invalidationTrackerFlow("author", "publisher")
                     .map {
                         val (books, publishers) =
                             database.withTransaction {
@@ -276,58 +277,61 @@
                             "${book.title} from $publisherName"
                         }
                     }
-                    .collect { resultChannel.send(it) }
+                    .collect {
+                        when (results.size) {
+                            0 -> {
+                                results.add(it)
+                                firstResultLatch.countDown()
+                            }
+                            1 -> {
+                                results.add(it)
+                                secondResultLatch.countDown()
+                            }
+                            else -> fail("Should have only collected 2 results.")
+                        }
+                    }
             }
 
-        testScheduler.advanceUntilIdle()
-
-        resultChannel.receive().let { result ->
-            assertThat(result).containsExactly("book title 1 from publisher 1")
-        }
-
+        firstResultLatch.await()
         database.withTransaction {
             booksDao.addPublishers(TestUtil.PUBLISHER2)
             booksDao.addBooks(TestUtil.BOOK_2)
         }
-        testScheduler.advanceUntilIdle()
 
-        resultChannel.receive().let { result ->
-            assertThat(result)
-                .containsExactly("book title 1 from publisher 1", "book title 2 from publisher 1")
+        secondResultLatch.await()
+        assertThat(results.size).isEqualTo(2)
+        assertThat(results[0]).containsExactly("book title 1 from publisher 1")
+        assertThat(results[1])
+            .containsExactly("book title 1 from publisher 1", "book title 2 from publisher 1")
+
+        job.cancelAndJoin()
+    }
+
+    @Test
+    fun invalidTable() = runBlocking {
+        val flow = database.invalidationTrackerFlow("foo")
+        try {
+            flow.first()
+            fail("An exception should have thrown")
+        } catch (ex: IllegalArgumentException) {
+            assertThat(ex.message).isEqualTo("There is no table with name foo")
         }
-
-        resultChannel.close()
-        job.cancel()
     }
 
     @Test
-    fun invalidTable() {
-        assertThrows<IllegalArgumentException> { database.invalidationTracker.createFlow("foo") }
-            .hasMessageThat()
-            .isEqualTo("There is no table with name foo")
-
-        database.close()
-    }
-
-    @Test
-    fun emptyTables() = runTest {
+    fun emptyTables() = runBlocking {
         booksDao.addAuthors(TestUtil.AUTHOR_1)
 
-        val channel = database.invalidationTracker.createFlow().produceIn(this)
+        val channel = database.invalidationTrackerFlow().produceIn(this)
 
         assertThat(channel.receive()).isEmpty()
 
         booksDao.addAuthorsSuspend(TestUtil.AUTHOR_2)
-        testScheduler.advanceUntilIdle()
+        drain() // drain async invalidate
+        yield()
 
         assertThat(channel.isEmpty).isTrue()
 
         channel.cancel()
     }
-
-    private fun runTest(testBody: suspend TestScope.() -> Unit) =
-        testCoroutineScope.runTest(timeout = 10.minutes) {
-            testBody.invoke(this)
-            database.close()
-        }
 }
diff --git a/room/integration-tests/kotlintestapp/src/androidTest/java/androidx/room/integration/kotlintestapp/test/MultiTypedPagingSourceTest.kt b/room/integration-tests/kotlintestapp/src/androidTest/java/androidx/room/integration/kotlintestapp/test/MultiTypedPagingSourceTest.kt
index 31fcc3c..dddae7e 100644
--- a/room/integration-tests/kotlintestapp/src/androidTest/java/androidx/room/integration/kotlintestapp/test/MultiTypedPagingSourceTest.kt
+++ b/room/integration-tests/kotlintestapp/src/androidTest/java/androidx/room/integration/kotlintestapp/test/MultiTypedPagingSourceTest.kt
@@ -27,18 +27,16 @@
 import androidx.room.integration.kotlintestapp.testutil.PagingDb
 import androidx.room.integration.kotlintestapp.testutil.PagingEntity
 import androidx.room.integration.kotlintestapp.testutil.PagingEntityDao
-import androidx.room.paging.LimitOffsetPagingSource
 import androidx.sqlite.db.SimpleSQLiteQuery
 import androidx.test.core.app.ApplicationProvider
+import androidx.test.filters.FlakyTest
 import androidx.test.filters.MediumTest
 import androidx.test.filters.SmallTest
-import androidx.testutils.FilteringCoroutineContext
 import androidx.testutils.FilteringExecutor
 import java.util.concurrent.Executors
 import kotlin.test.assertFailsWith
 import kotlin.test.assertFalse
 import kotlin.test.assertTrue
-import kotlinx.coroutines.CoroutineName
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.cancel
@@ -70,10 +68,7 @@
 
     // Multiple threads are necessary to prevent deadlock, since Room will acquire a thread to
     // dispatch on, when using the query / transaction dispatchers.
-    private val queryContext = FilteringCoroutineContext(Executors.newFixedThreadPool(2))
-    private val queryExecutor: FilteringExecutor
-        get() = queryContext.executor
-
+    private val queryExecutor = FilteringExecutor(Executors.newFixedThreadPool(2))
     private val mainThreadQueries = mutableListOf<Pair<String, String>>()
     private val pagingSources = mutableListOf<PagingSource<Int, PagingEntity>>()
 
@@ -81,7 +76,7 @@
     fun init() {
         coroutineScope = CoroutineScope(Dispatchers.Main)
         itemStore = ItemStore(coroutineScope)
-        db = buildAndReturnDb(queryContext, mainThreadQueries)
+        db = buildAndReturnDb(queryExecutor, mainThreadQueries)
     }
 
     @After
@@ -232,6 +227,7 @@
     }
 
     @Test
+    @Ignore // b/287517337, b/287477564, b/287366097, b/287085166
     fun prependWithDelayedInvalidation() {
         val items = createItems(startId = 0, count = 90)
         db.getDao().insert(items)
@@ -253,8 +249,9 @@
 
             // now do some changes in the database but don't let change notifications go through
             // to the data source. it should not crash :)
-            queryContext.filterFunction = { context, _ ->
-                context[CoroutineName]?.name?.contains("Room Invalidation Tracker Refresh") != true
+            queryExecutor.filterFunction = {
+                // TODO(b/): Avoid relying on function name, very brittle.
+                !it.toString().contains("refreshInvalidationAsync")
             }
 
             db.getDao().deleteItems(items.subList(0, 60).map { it.id })
@@ -271,10 +268,12 @@
             assertTrue(pagingSources[0].invalid)
             itemStore.awaitInitialLoad()
 
-            // The runnable of refreshVersionsAsync in the delete is getting filtered out but we
-            // need it to complete, so we execute it.
-            assertThat(queryExecutor.deferredSize()).isEqualTo(1)
+            // the initial load triggers a call to refreshVersionsAsync which calls
+            // mRefreshRunnable. The runnable is getting filtered out but we need this one to
+            // complete, so we executed the latest queued mRefreshRunnable.
+            assertThat(queryExecutor.deferredSize()).isEqualTo(2)
             queryExecutor.executeLatestDeferred()
+            assertThat(queryExecutor.deferredSize()).isEqualTo(1)
 
             // it might be reloaded in any range so just make sure everything is there
             // expects 30 items because items 60 - 89 left in database, so presenter should have
@@ -290,6 +289,11 @@
                 }
             }
 
+            // Runs the original invalidationTracker.refreshRunnable.
+            // Note that the second initial load's call to mRefreshRunnable resets the flag to
+            // false, so this mRefreshRunnable will not detect changes in the table anymore.
+            queryExecutor.executeAll()
+
             itemStore.awaitInitialLoad()
 
             // make sure only two generations of paging sources have been created
@@ -305,10 +309,8 @@
         }
     }
 
-    // This test is no longer valid since LimitOffsetPagingSource now uses invalidation via Flow
-    // and slow observers don't block others.
+    @FlakyTest(bugId = 260592924)
     @Test
-    @Ignore("b/329315924")
     fun prependWithBlockingObserver() {
         val items = createItems(startId = 0, count = 90)
         db.getDao().insert(items)
@@ -369,14 +371,12 @@
         }
     }
 
+    @Ignore // b/261205680
     @Test
     fun appendWithDelayedInvalidation() {
         val items = createItems(startId = 0, count = 90)
         db.getDao().insert(items)
         runTest {
-            val isBasePagingSourceFactory =
-                pagingSourceFactory.invoke(db.getDao()) is LimitOffsetPagingSource
-
             val initialLoad = itemStore.awaitInitialLoad()
             assertThat(initialLoad)
                 .containsExactlyElementsIn(
@@ -385,8 +385,9 @@
 
             // now do some changes in the database but don't let change notifications go through
             // to the data source. it should not crash :)
-            queryContext.filterFunction = { context, _ ->
-                context[CoroutineName]?.name?.contains("Room Invalidation Tracker Refresh") != true
+            queryExecutor.filterFunction = {
+                // TODO(b/): Avoid relying on function name, very brittle.
+                !it.toString().contains("refreshInvalidation")
             }
 
             db.getDao().deleteItems(items.subList(0, 80).map { it.id })
@@ -401,25 +402,15 @@
 
             itemStore.awaitGeneration(2)
             assertTrue(pagingSources[0].invalid)
-            // initial load is executed and calls refreshVersionsAsync due to runInTransaction
-            // and the refresh runnable is actually queued up here
+            // initial load is executed but refreshVersionsAsync's call to mRefreshRunnable is
+            // actually queued up here
             itemStore.awaitInitialLoad()
-
-            if (isBasePagingSourceFactory) {
-                // when test factory is for base paging source, the initial load does not enqueues
-                // a refresh, so we only expect the one from the deleteItems()
-                assertThat(queryExecutor.deferredSize()).isEqualTo(1)
-                queryExecutor.executeLatestDeferred()
-            } else {
-                // when test factory is not for base paging source (futures or rx) then the initial
-                // load triggers a call to refreshVersionsAsync due to runInTransaction
-                // and a refresh runnable is enqueue. The runnable is getting filtered out but we
-                // need the runnable from the initial load to complete, so we executed the latest
-                // queued runnable.
-                assertThat(queryExecutor.deferredSize()).isEqualTo(2)
-                queryExecutor.executeLatestDeferred()
-                assertThat(queryExecutor.deferredSize()).isEqualTo(1)
-            }
+            // the initial load triggers a call to refreshVersionsAsync which calls
+            // mRefreshRunnable. The runnable is getting filtered out but we need this one to
+            // complete, so we executed the latest queued mRefreshRunnable.
+            assertThat(queryExecutor.deferredSize()).isEqualTo(2)
+            queryExecutor.executeLatestDeferred()
+            assertThat(queryExecutor.deferredSize()).isEqualTo(1)
 
             // second paging source should be generated
             assertThat(pagingSources.size).isEqualTo(2)
@@ -434,12 +425,10 @@
                 }
             }
 
-            if (!isBasePagingSourceFactory) {
-                // Runs the refresh runnable fromm the invalidation tracker due to the deleteItems()
-                // Note that the second initial load's call to the refresh runnable resets the flag
-                // to false, so this runnable will not detect changes in the table anymore.
-                queryExecutor.executeAll()
-            }
+            // Runs the original invalidationTracker.refreshRunnable.
+            // Note that the second initial load's call to mRefreshRunnable resets the flag to
+            // false, so this mRefreshRunnable will not detect changes in the table anymore.
+            queryExecutor.executeAll()
 
             itemStore.awaitInitialLoad()
 
@@ -515,17 +504,14 @@
 
     // Multiple threads are necessary to prevent deadlock, since Room will acquire a thread to
     // dispatch on, when using the query / transaction dispatchers.
-    private val queryContext = FilteringCoroutineContext(Executors.newFixedThreadPool(2))
-    private val queryExecutor: FilteringExecutor
-        get() = queryContext.executor
-
+    private val queryExecutor = FilteringExecutor(Executors.newFixedThreadPool(2))
     private val mainThreadQueries = mutableListOf<Pair<String, String>>()
 
     @Before
     fun init() {
         coroutineScope = CoroutineScope(Dispatchers.Main)
         itemStore = ItemStore(coroutineScope)
-        db = buildAndReturnDb(queryContext, mainThreadQueries)
+        db = buildAndReturnDb(queryExecutor, mainThreadQueries)
     }
 
     @After
@@ -563,6 +549,7 @@
     }
 
     @Test
+    @Ignore // b/312434479
     fun loadEverythingRawQuery_inReverse() {
         // open db
         val items = createItems(startId = 0, count = 100)
@@ -699,7 +686,7 @@
 }
 
 private fun buildAndReturnDb(
-    queryContext: FilteringCoroutineContext,
+    queryExecutor: FilteringExecutor,
     mainThreadQueries: MutableList<Pair<String, String>>
 ): PagingDb {
     val mainThread: Thread = runBlocking(Dispatchers.Main) { Thread.currentThread() }
@@ -719,7 +706,7 @@
             // instantly execute the log callback so that we can check the thread.
             it.run()
         }
-        .setQueryCoroutineContext(queryContext)
+        .setQueryExecutor(queryExecutor)
         .build()
 }
 
diff --git a/room/integration-tests/kotlintestapp/src/androidTestWithKspGenKotlin/java/androidx/room/integration/kotlintestapp/test/PreKmpDatabase_TheDao_Impl.kt b/room/integration-tests/kotlintestapp/src/androidTestWithKspGenKotlin/java/androidx/room/integration/kotlintestapp/test/PreKmpDatabase_TheDao_Impl.kt
index f0f3883..7dbe317 100644
--- a/room/integration-tests/kotlintestapp/src/androidTestWithKspGenKotlin/java/androidx/room/integration/kotlintestapp/test/PreKmpDatabase_TheDao_Impl.kt
+++ b/room/integration-tests/kotlintestapp/src/androidTestWithKspGenKotlin/java/androidx/room/integration/kotlintestapp/test/PreKmpDatabase_TheDao_Impl.kt
@@ -1,3 +1,5 @@
+@file:Suppress("DEPRECATION", "ktlint") // Due to old entity adapter usage.
+
 package androidx.room.integration.kotlintestapp.test
 
 import android.database.Cursor
diff --git a/room/integration-tests/multiplatformtestapp/src/commonTest/kotlin/androidx/room/integration/multiplatformtestapp/test/BaseInvalidationTest.kt b/room/integration-tests/multiplatformtestapp/src/commonTest/kotlin/androidx/room/integration/multiplatformtestapp/test/BaseInvalidationTest.kt
index 17fef30..252489d 100644
--- a/room/integration-tests/multiplatformtestapp/src/commonTest/kotlin/androidx/room/integration/multiplatformtestapp/test/BaseInvalidationTest.kt
+++ b/room/integration-tests/multiplatformtestapp/src/commonTest/kotlin/androidx/room/integration/multiplatformtestapp/test/BaseInvalidationTest.kt
@@ -17,18 +17,16 @@
 package androidx.room.integration.multiplatformtestapp.test
 
 import androidx.kruth.assertThat
+import androidx.room.InvalidationTracker
 import kotlin.test.AfterTest
 import kotlin.test.BeforeTest
 import kotlin.test.Test
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.IO
-import kotlinx.coroutines.cancelAndJoin
-import kotlinx.coroutines.channels.Channel
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.TimeoutCancellationException
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withTimeout
 
-@OptIn(ExperimentalCoroutinesApi::class)
 abstract class BaseInvalidationTest {
 
     private lateinit var db: SampleDatabase
@@ -46,31 +44,52 @@
     }
 
     @Test
-    fun observeOneTable() = runTest {
+    fun observeOneTable(): Unit = runBlocking {
         val dao = db.dao()
 
         val tableName = SampleEntity::class.simpleName!!
-        val invalidations = Channel<Set<String>>(capacity = 10)
-        val collectJob =
-            backgroundScope.launch(Dispatchers.IO) {
-                db.invalidationTracker.createFlow(tableName).collect { invalidatedTables ->
-                    invalidations.send(invalidatedTables)
-                }
-            }
+        val observer = LatchObserver(tableName)
 
-        // Initial emission
-        assertThat(invalidations.receive()).containsExactly(tableName)
+        db.invalidationTracker.subscribe(observer)
 
         dao.insertItem(1)
 
-        // Emissions due to insert
-        assertThat(invalidations.receive()).containsExactly(tableName)
+        assertThat(observer.await()).isTrue()
+        assertThat(observer.invalidatedTables).containsExactly(tableName)
 
-        collectJob.cancelAndJoin()
+        observer.reset()
+        db.invalidationTracker.unsubscribe(observer)
 
         dao.insertItem(2)
 
-        // No emissions, flow collection canceled
-        assertThat(invalidations.isEmpty).isTrue()
+        assertThat(observer.await()).isFalse()
+        assertThat(observer.invalidatedTables).isNull()
+    }
+
+    private class LatchObserver(table: String) : InvalidationTracker.Observer(table) {
+
+        var invalidatedTables: Set<String>? = null
+            private set
+
+        private var latch = Mutex(locked = true)
+
+        override fun onInvalidated(tables: Set<String>) {
+            invalidatedTables = tables
+            latch.unlock()
+        }
+
+        suspend fun await(): Boolean {
+            try {
+                withTimeout(200) { latch.withLock {} }
+                return true
+            } catch (ex: TimeoutCancellationException) {
+                return false
+            }
+        }
+
+        fun reset() {
+            invalidatedTables = null
+            latch = Mutex(locked = true)
+        }
     }
 }
diff --git a/room/room-compiler/src/main/kotlin/androidx/room/writer/DaoWriter.kt b/room/room-compiler/src/main/kotlin/androidx/room/writer/DaoWriter.kt
index 45a0481..226ca2e 100644
--- a/room/room-compiler/src/main/kotlin/androidx/room/writer/DaoWriter.kt
+++ b/room/room-compiler/src/main/kotlin/androidx/room/writer/DaoWriter.kt
@@ -33,12 +33,13 @@
 import androidx.room.compiler.processing.XType
 import androidx.room.ext.CommonTypeNames
 import androidx.room.ext.RoomMemberNames
-import androidx.room.ext.RoomTypeNames
 import androidx.room.ext.RoomTypeNames.DELETE_OR_UPDATE_ADAPTER
 import androidx.room.ext.RoomTypeNames.DELETE_OR_UPDATE_ADAPTER_COMPAT
 import androidx.room.ext.RoomTypeNames.INSERT_ADAPTER
 import androidx.room.ext.RoomTypeNames.INSERT_ADAPTER_COMPAT
+import androidx.room.ext.RoomTypeNames.RAW_QUERY
 import androidx.room.ext.RoomTypeNames.ROOM_DB
+import androidx.room.ext.RoomTypeNames.ROOM_SQL_QUERY
 import androidx.room.ext.RoomTypeNames.UPSERT_ADAPTER
 import androidx.room.ext.RoomTypeNames.UPSERT_ADAPTER_COMPAT
 import androidx.room.ext.SupportDbTypeNames
@@ -333,21 +334,36 @@
     }
 
     private fun createRawQueryMethodBody(method: RawQueryMethod): XCodeBlock {
-        if (
-            method.runtimeQueryParam == null ||
-                !method.runtimeQueryParam.isRawQuery() ||
-                !method.queryResultBinder.isMigratedToDriver()
-        ) {
+        if (method.runtimeQueryParam == null || !method.queryResultBinder.isMigratedToDriver()) {
             return compatCreateRawQueryMethodBody(method)
         }
 
         val scope = CodeGenScope(this@DaoWriter, useDriverApi = true)
         val sqlQueryVar = scope.getTmpVar("_sql")
+        val rawQueryParamName =
+            if (method.runtimeQueryParam.isSupportQuery()) {
+                val rawQueryVar = scope.getTmpVar("_rawQuery")
+                scope.builder.addLocalVariable(
+                    name = rawQueryVar,
+                    typeName = RAW_QUERY,
+                    assignExpr =
+                        XCodeBlock.of(
+                            scope.language,
+                            format = "%T.copyFrom(%L).toRoomRawQuery()",
+                            ROOM_SQL_QUERY,
+                            method.runtimeQueryParam.paramName
+                        )
+                )
+                rawQueryVar
+            } else {
+                method.runtimeQueryParam.paramName
+            }
+
         scope.builder.addLocalVal(
             sqlQueryVar,
             CommonTypeNames.STRING,
             "%L.%L",
-            method.runtimeQueryParam.paramName,
+            rawQueryParamName,
             when (codeLanguage) {
                 CodeLanguage.JAVA -> "getSql()"
                 CodeLanguage.KOTLIN -> "sql"
@@ -360,7 +376,7 @@
                 bindStatement = { stmtVar ->
                     this.builder.addStatement(
                         "%L.getBindingFunction().invoke(%L)",
-                        method.runtimeQueryParam.paramName,
+                        rawQueryParamName,
                         stmtVar
                     )
                 },
@@ -387,7 +403,7 @@
                     shouldReleaseQuery = true
                     addLocalVariable(
                         name = roomSQLiteQueryVar,
-                        typeName = RoomTypeNames.ROOM_SQL_QUERY,
+                        typeName = ROOM_SQL_QUERY,
                         assignExpr =
                             XCodeBlock.of(
                                 codeLanguage,
@@ -402,7 +418,7 @@
                     shouldReleaseQuery = false
                     addLocalVariable(
                         name = roomSQLiteQueryVar,
-                        typeName = RoomTypeNames.ROOM_SQL_QUERY,
+                        typeName = ROOM_SQL_QUERY,
                         assignExpr =
                             XCodeBlock.of(
                                 codeLanguage,
diff --git a/room/room-compiler/src/test/test-data/daoWriter/output/javac/withLambda/ComplexDao.java b/room/room-compiler/src/test/test-data/daoWriter/output/javac/withLambda/ComplexDao.java
index 6821f7a..32f9284 100644
--- a/room/room-compiler/src/test/test-data/daoWriter/output/javac/withLambda/ComplexDao.java
+++ b/room/room-compiler/src/test/test-data/daoWriter/output/javac/withLambda/ComplexDao.java
@@ -1,14 +1,13 @@
 package foo.bar;
 
-import android.database.Cursor;
 import androidx.annotation.NonNull;
 import androidx.lifecycle.LiveData;
 import androidx.paging.PagingSource;
 import androidx.room.RoomDatabase;
 import androidx.room.RoomRawQuery;
+import androidx.room.RoomSQLiteQuery;
 import androidx.room.guava.GuavaRoom;
 import androidx.room.paging.LimitOffsetPagingSource;
-import androidx.room.util.CursorUtil;
 import androidx.room.util.DBUtil;
 import androidx.room.util.SQLiteStatementUtil;
 import androidx.room.util.StringUtil;
@@ -637,7 +636,7 @@
       @Override
       @NonNull
       protected List<Child1> convertRows(@NonNull final SQLiteStatement statement,
-              final int itemCount) {
+          final int itemCount) {
         _rawQuery.getBindingFunction().invoke(statement);
         final int _cursorIndexOfId = SQLiteStatementUtil.getColumnIndexOrThrow(statement, "id");
         final int _cursorIndexOfName = SQLiteStatementUtil.getColumnIndexOrThrow(statement, "name");
@@ -676,19 +675,23 @@
 
   @Override
   public User getUserViaRawQuery(final SupportSQLiteQuery rawQuery) {
-    __db.assertNotSuspendingTransaction();
-    final Cursor _cursor = DBUtil.query(__db, rawQuery, false, null);
-    try {
-      final User _result;
-      if (_cursor.moveToFirst()) {
-        _result = __entityCursorConverter_fooBarUser(_cursor);
-      } else {
-        _result = null;
+    final RoomRawQuery _rawQuery = RoomSQLiteQuery.copyFrom(rawQuery).toRoomRawQuery();
+    final String _sql = _rawQuery.getSql();
+    return DBUtil.performBlocking(__db, true, false, (_connection) -> {
+      final SQLiteStatement _stmt = _connection.prepare(_sql);
+      try {
+        _rawQuery.getBindingFunction().invoke(_stmt);
+        final User _result;
+        if (_stmt.step()) {
+          _result = __entityStatementConverter_fooBarUser(_stmt);
+        } else {
+          _result = null;
+        }
+        return _result;
+      } finally {
+        _stmt.close();
       }
-      return _result;
-    } finally {
-      _cursor.close();
-    }
+    });
   }
 
   @NonNull
@@ -696,34 +699,34 @@
     return Collections.emptyList();
   }
 
-  private User __entityCursorConverter_fooBarUser(@NonNull final Cursor cursor) {
+  private User __entityStatementConverter_fooBarUser(@NonNull final SQLiteStatement statement) {
     final User _entity;
-    final int _cursorIndexOfUid = CursorUtil.getColumnIndex(cursor, "uid");
-    final int _cursorIndexOfName = CursorUtil.getColumnIndex(cursor, "name");
-    final int _cursorIndexOfLastName = CursorUtil.getColumnIndex(cursor, "lastName");
-    final int _cursorIndexOfAge = CursorUtil.getColumnIndex(cursor, "ageColumn");
+    final int _cursorIndexOfUid = SQLiteStatementUtil.getColumnIndex(statement, "uid");
+    final int _cursorIndexOfName = SQLiteStatementUtil.getColumnIndex(statement, "name");
+    final int _cursorIndexOfLastName = SQLiteStatementUtil.getColumnIndex(statement, "lastName");
+    final int _cursorIndexOfAge = SQLiteStatementUtil.getColumnIndex(statement, "ageColumn");
     _entity = new User();
     if (_cursorIndexOfUid != -1) {
-      _entity.uid = cursor.getInt(_cursorIndexOfUid);
+      _entity.uid = (int) (statement.getLong(_cursorIndexOfUid));
     }
     if (_cursorIndexOfName != -1) {
-      if (cursor.isNull(_cursorIndexOfName)) {
+      if (statement.isNull(_cursorIndexOfName)) {
         _entity.name = null;
       } else {
-        _entity.name = cursor.getString(_cursorIndexOfName);
+        _entity.name = statement.getText(_cursorIndexOfName);
       }
     }
     if (_cursorIndexOfLastName != -1) {
       final String _tmpLastName;
-      if (cursor.isNull(_cursorIndexOfLastName)) {
+      if (statement.isNull(_cursorIndexOfLastName)) {
         _tmpLastName = null;
       } else {
-        _tmpLastName = cursor.getString(_cursorIndexOfLastName);
+        _tmpLastName = statement.getText(_cursorIndexOfLastName);
       }
       _entity.setLastName(_tmpLastName);
     }
     if (_cursorIndexOfAge != -1) {
-      _entity.age = cursor.getInt(_cursorIndexOfAge);
+      _entity.age = (int) (statement.getLong(_cursorIndexOfAge));
     }
     return _entity;
   }
diff --git a/room/room-compiler/src/test/test-data/daoWriter/output/javac/withoutLambda/ComplexDao.java b/room/room-compiler/src/test/test-data/daoWriter/output/javac/withoutLambda/ComplexDao.java
index b4dceb9..6d31a43 100644
--- a/room/room-compiler/src/test/test-data/daoWriter/output/javac/withoutLambda/ComplexDao.java
+++ b/room/room-compiler/src/test/test-data/daoWriter/output/javac/withoutLambda/ComplexDao.java
@@ -1,15 +1,14 @@
 package foo.bar;
 
-import android.database.Cursor;
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import androidx.lifecycle.LiveData;
 import androidx.paging.PagingSource;
 import androidx.room.RoomDatabase;
 import androidx.room.RoomRawQuery;
+import androidx.room.RoomSQLiteQuery;
 import androidx.room.guava.GuavaRoom;
 import androidx.room.paging.LimitOffsetPagingSource;
-import androidx.room.util.CursorUtil;
 import androidx.room.util.DBUtil;
 import androidx.room.util.SQLiteStatementUtil;
 import androidx.room.util.StringUtil;
@@ -700,7 +699,7 @@
       @Override
       @NonNull
       protected List<Child1> convertRows(@NonNull final SQLiteStatement statement,
-              final int itemCount) {
+          final int itemCount) {
         _rawQuery.getBindingFunction().invoke(statement);
         final int _cursorIndexOfId = SQLiteStatementUtil.getColumnIndexOrThrow(statement, "id");
         final int _cursorIndexOfName = SQLiteStatementUtil.getColumnIndexOrThrow(statement, "name");
@@ -739,19 +738,27 @@
 
   @Override
   public User getUserViaRawQuery(final SupportSQLiteQuery rawQuery) {
-    __db.assertNotSuspendingTransaction();
-    final Cursor _cursor = DBUtil.query(__db, rawQuery, false, null);
-    try {
-      final User _result;
-      if (_cursor.moveToFirst()) {
-        _result = __entityCursorConverter_fooBarUser(_cursor);
-      } else {
-        _result = null;
+    final RoomRawQuery _rawQuery = RoomSQLiteQuery.copyFrom(rawQuery).toRoomRawQuery();
+    final String _sql = _rawQuery.getSql();
+    return DBUtil.performBlocking(__db, true, false, new Function1<SQLiteConnection, User>() {
+      @Override
+      @NonNull
+      public User invoke(@NonNull final SQLiteConnection _connection) {
+        final SQLiteStatement _stmt = _connection.prepare(_sql);
+        try {
+          _rawQuery.getBindingFunction().invoke(_stmt);
+          final User _result;
+          if (_stmt.step()) {
+            _result = __entityStatementConverter_fooBarUser(_stmt);
+          } else {
+            _result = null;
+          }
+          return _result;
+        } finally {
+          _stmt.close();
+        }
       }
-      return _result;
-    } finally {
-      _cursor.close();
-    }
+    });
   }
 
   @NonNull
@@ -759,34 +766,34 @@
     return Collections.emptyList();
   }
 
-  private User __entityCursorConverter_fooBarUser(@NonNull final Cursor cursor) {
+  private User __entityStatementConverter_fooBarUser(@NonNull final SQLiteStatement statement) {
     final User _entity;
-    final int _cursorIndexOfUid = CursorUtil.getColumnIndex(cursor, "uid");
-    final int _cursorIndexOfName = CursorUtil.getColumnIndex(cursor, "name");
-    final int _cursorIndexOfLastName = CursorUtil.getColumnIndex(cursor, "lastName");
-    final int _cursorIndexOfAge = CursorUtil.getColumnIndex(cursor, "ageColumn");
+    final int _cursorIndexOfUid = SQLiteStatementUtil.getColumnIndex(statement, "uid");
+    final int _cursorIndexOfName = SQLiteStatementUtil.getColumnIndex(statement, "name");
+    final int _cursorIndexOfLastName = SQLiteStatementUtil.getColumnIndex(statement, "lastName");
+    final int _cursorIndexOfAge = SQLiteStatementUtil.getColumnIndex(statement, "ageColumn");
     _entity = new User();
     if (_cursorIndexOfUid != -1) {
-      _entity.uid = cursor.getInt(_cursorIndexOfUid);
+      _entity.uid = (int) (statement.getLong(_cursorIndexOfUid));
     }
     if (_cursorIndexOfName != -1) {
-      if (cursor.isNull(_cursorIndexOfName)) {
+      if (statement.isNull(_cursorIndexOfName)) {
         _entity.name = null;
       } else {
-        _entity.name = cursor.getString(_cursorIndexOfName);
+        _entity.name = statement.getText(_cursorIndexOfName);
       }
     }
     if (_cursorIndexOfLastName != -1) {
       final String _tmpLastName;
-      if (cursor.isNull(_cursorIndexOfLastName)) {
+      if (statement.isNull(_cursorIndexOfLastName)) {
         _tmpLastName = null;
       } else {
-        _tmpLastName = cursor.getString(_cursorIndexOfLastName);
+        _tmpLastName = statement.getText(_cursorIndexOfLastName);
       }
       _entity.setLastName(_tmpLastName);
     }
     if (_cursorIndexOfAge != -1) {
-      _entity.age = cursor.getInt(_cursorIndexOfAge);
+      _entity.age = (int) (statement.getLong(_cursorIndexOfAge));
     }
     return _entity;
   }
diff --git a/room/room-compiler/src/test/test-data/daoWriter/output/ksp/ComplexDao.java b/room/room-compiler/src/test/test-data/daoWriter/output/ksp/ComplexDao.java
index 9b4ca9b..41bd29f 100644
--- a/room/room-compiler/src/test/test-data/daoWriter/output/ksp/ComplexDao.java
+++ b/room/room-compiler/src/test/test-data/daoWriter/output/ksp/ComplexDao.java
@@ -1,14 +1,13 @@
 package foo.bar;
 
-import android.database.Cursor;
 import androidx.annotation.NonNull;
 import androidx.lifecycle.LiveData;
 import androidx.paging.PagingSource;
 import androidx.room.RoomDatabase;
 import androidx.room.RoomRawQuery;
+import androidx.room.RoomSQLiteQuery;
 import androidx.room.guava.GuavaRoom;
 import androidx.room.paging.LimitOffsetPagingSource;
-import androidx.room.util.CursorUtil;
 import androidx.room.util.DBUtil;
 import androidx.room.util.SQLiteStatementUtil;
 import androidx.room.util.StringUtil;
@@ -625,66 +624,70 @@
     });
   }
 
-    @Override
-    public PagingSource<Integer, Child1> loadItems() {
-        final String _sql = "SELECT * FROM Child1 ORDER BY id ASC";
-        final RoomRawQuery _rawQuery = new RoomRawQuery(_sql);
-        return new LimitOffsetPagingSource<Child1>(_rawQuery, __db, "Child1") {
-            @Override
-            @NonNull
-            protected List<Child1> convertRows(@NonNull final SQLiteStatement statement,
-                    final int itemCount) {
-                _rawQuery.getBindingFunction().invoke(statement);
-                final int _cursorIndexOfId = SQLiteStatementUtil.getColumnIndexOrThrow(statement, "id");
-                final int _cursorIndexOfName = SQLiteStatementUtil.getColumnIndexOrThrow(statement, "name");
-                final int _cursorIndexOfSerial = SQLiteStatementUtil.getColumnIndexOrThrow(statement, "serial");
-                final int _cursorIndexOfCode = SQLiteStatementUtil.getColumnIndexOrThrow(statement, "code");
-                final List<Child1> _result = new ArrayList<Child1>();
-                while (statement.step()) {
-                    final Child1 _item;
-                    final int _tmpId;
-                    _tmpId = (int) (statement.getLong(_cursorIndexOfId));
-                    final String _tmpName;
-                    if (statement.isNull(_cursorIndexOfName)) {
-                        _tmpName = null;
-                    } else {
-                        _tmpName = statement.getText(_cursorIndexOfName);
-                    }
-                    final Info _tmpInfo;
-                    if (!(statement.isNull(_cursorIndexOfSerial) && statement.isNull(_cursorIndexOfCode))) {
-                        _tmpInfo = new Info();
-                        _tmpInfo.serial = (int) (statement.getLong(_cursorIndexOfSerial));
-                        if (statement.isNull(_cursorIndexOfCode)) {
-                            _tmpInfo.code = null;
-                        } else {
-                            _tmpInfo.code = statement.getText(_cursorIndexOfCode);
-                        }
-                    } else {
-                        _tmpInfo = null;
-                    }
-                    _item = new Child1(_tmpId,_tmpName,_tmpInfo);
-                    _result.add(_item);
-                }
-                return _result;
+  @Override
+  public PagingSource<Integer, Child1> loadItems() {
+    final String _sql = "SELECT * FROM Child1 ORDER BY id ASC";
+    final RoomRawQuery _rawQuery = new RoomRawQuery(_sql);
+    return new LimitOffsetPagingSource<Child1>(_rawQuery, __db, "Child1") {
+      @Override
+      @NonNull
+      protected List<Child1> convertRows(@NonNull final SQLiteStatement statement,
+          final int itemCount) {
+        _rawQuery.getBindingFunction().invoke(statement);
+        final int _cursorIndexOfId = SQLiteStatementUtil.getColumnIndexOrThrow(statement, "id");
+        final int _cursorIndexOfName = SQLiteStatementUtil.getColumnIndexOrThrow(statement, "name");
+        final int _cursorIndexOfSerial = SQLiteStatementUtil.getColumnIndexOrThrow(statement, "serial");
+        final int _cursorIndexOfCode = SQLiteStatementUtil.getColumnIndexOrThrow(statement, "code");
+        final List<Child1> _result = new ArrayList<Child1>();
+        while (statement.step()) {
+          final Child1 _item;
+          final int _tmpId;
+          _tmpId = (int) (statement.getLong(_cursorIndexOfId));
+          final String _tmpName;
+          if (statement.isNull(_cursorIndexOfName)) {
+            _tmpName = null;
+          } else {
+            _tmpName = statement.getText(_cursorIndexOfName);
+          }
+          final Info _tmpInfo;
+          if (!(statement.isNull(_cursorIndexOfSerial) && statement.isNull(_cursorIndexOfCode))) {
+            _tmpInfo = new Info();
+            _tmpInfo.serial = (int) (statement.getLong(_cursorIndexOfSerial));
+            if (statement.isNull(_cursorIndexOfCode)) {
+              _tmpInfo.code = null;
+            } else {
+              _tmpInfo.code = statement.getText(_cursorIndexOfCode);
             }
-        };
-    }
+          } else {
+            _tmpInfo = null;
+          }
+          _item = new Child1(_tmpId,_tmpName,_tmpInfo);
+          _result.add(_item);
+        }
+        return _result;
+      }
+    };
+  }
 
   @Override
   public User getUserViaRawQuery(final SupportSQLiteQuery rawQuery) {
-    __db.assertNotSuspendingTransaction();
-    final Cursor _cursor = DBUtil.query(__db, rawQuery, false, null);
-    try {
-      final User _result;
-      if (_cursor.moveToFirst()) {
-        _result = __entityCursorConverter_fooBarUser(_cursor);
-      } else {
-        _result = null;
+    final RoomRawQuery _rawQuery = RoomSQLiteQuery.copyFrom(rawQuery).toRoomRawQuery();
+    final String _sql = _rawQuery.getSql();
+    return DBUtil.performBlocking(__db, true, false, (_connection) -> {
+      final SQLiteStatement _stmt = _connection.prepare(_sql);
+      try {
+        _rawQuery.getBindingFunction().invoke(_stmt);
+        final User _result;
+        if (_stmt.step()) {
+          _result = __entityStatementConverter_fooBarUser(_stmt);
+        } else {
+          _result = null;
+        }
+        return _result;
+      } finally {
+        _stmt.close();
       }
-      return _result;
-    } finally {
-      _cursor.close();
-    }
+    });
   }
 
   @NonNull
@@ -692,34 +695,34 @@
     return Collections.emptyList();
   }
 
-  private User __entityCursorConverter_fooBarUser(@NonNull final Cursor cursor) {
+  private User __entityStatementConverter_fooBarUser(@NonNull final SQLiteStatement statement) {
     final User _entity;
-    final int _cursorIndexOfUid = CursorUtil.getColumnIndex(cursor, "uid");
-    final int _cursorIndexOfName = CursorUtil.getColumnIndex(cursor, "name");
-    final int _cursorIndexOfLastName = CursorUtil.getColumnIndex(cursor, "lastName");
-    final int _cursorIndexOfAge = CursorUtil.getColumnIndex(cursor, "ageColumn");
+    final int _cursorIndexOfUid = SQLiteStatementUtil.getColumnIndex(statement, "uid");
+    final int _cursorIndexOfName = SQLiteStatementUtil.getColumnIndex(statement, "name");
+    final int _cursorIndexOfLastName = SQLiteStatementUtil.getColumnIndex(statement, "lastName");
+    final int _cursorIndexOfAge = SQLiteStatementUtil.getColumnIndex(statement, "ageColumn");
     _entity = new User();
     if (_cursorIndexOfUid != -1) {
-      _entity.uid = cursor.getInt(_cursorIndexOfUid);
+      _entity.uid = (int) (statement.getLong(_cursorIndexOfUid));
     }
     if (_cursorIndexOfName != -1) {
-      if (cursor.isNull(_cursorIndexOfName)) {
+      if (statement.isNull(_cursorIndexOfName)) {
         _entity.name = null;
       } else {
-        _entity.name = cursor.getString(_cursorIndexOfName);
+        _entity.name = statement.getText(_cursorIndexOfName);
       }
     }
     if (_cursorIndexOfLastName != -1) {
       final String _tmpLastName;
-      if (cursor.isNull(_cursorIndexOfLastName)) {
+      if (statement.isNull(_cursorIndexOfLastName)) {
         _tmpLastName = null;
       } else {
-        _tmpLastName = cursor.getString(_cursorIndexOfLastName);
+        _tmpLastName = statement.getText(_cursorIndexOfLastName);
       }
       _entity.setLastName(_tmpLastName);
     }
     if (_cursorIndexOfAge != -1) {
-      _entity.age = cursor.getInt(_cursorIndexOfAge);
+      _entity.age = (int) (statement.getLong(_cursorIndexOfAge));
     }
     return _entity;
   }
diff --git a/room/room-compiler/src/test/test-data/kotlinCodeGen/rawQuery.kt b/room/room-compiler/src/test/test-data/kotlinCodeGen/rawQuery.kt
index 0bb30fb..4e804c0 100644
--- a/room/room-compiler/src/test/test-data/kotlinCodeGen/rawQuery.kt
+++ b/room/room-compiler/src/test/test-data/kotlinCodeGen/rawQuery.kt
@@ -1,14 +1,11 @@
-import android.database.Cursor
-import androidx.room.CoroutinesRoom
 import androidx.room.RoomDatabase
 import androidx.room.RoomRawQuery
+import androidx.room.RoomSQLiteQuery
 import androidx.room.coroutines.createFlow
 import androidx.room.util.getColumnIndex
 import androidx.room.util.performBlocking
-import androidx.room.util.query
 import androidx.sqlite.SQLiteStatement
 import androidx.sqlite.db.SupportSQLiteQuery
-import java.util.concurrent.Callable
 import javax.`annotation`.processing.Generated
 import kotlin.Double
 import kotlin.Float
@@ -31,54 +28,64 @@
   }
 
   public override fun getEntitySupport(sql: SupportSQLiteQuery): MyEntity {
-    __db.assertNotSuspendingTransaction()
-    val _cursor: Cursor = query(__db, sql, false, null)
-    try {
-      val _result: MyEntity
-      if (_cursor.moveToFirst()) {
-        _result = __entityCursorConverter_MyEntity(_cursor)
-      } else {
-        error("The query result was empty, but expected a single row to return a NON-NULL object of type <MyEntity>.")
+    val _rawQuery: RoomRawQuery = RoomSQLiteQuery.copyFrom(sql).toRoomRawQuery()
+    val _sql: String = _rawQuery.sql
+    return performBlocking(__db, true, false) { _connection ->
+      val _stmt: SQLiteStatement = _connection.prepare(_sql)
+      try {
+        _rawQuery.getBindingFunction().invoke(_stmt)
+        val _result: MyEntity
+        if (_stmt.step()) {
+          _result = __entityStatementConverter_MyEntity(_stmt)
+        } else {
+          error("The query result was empty, but expected a single row to return a NON-NULL object of type <MyEntity>.")
+        }
+        _result
+      } finally {
+        _stmt.close()
       }
-      return _result
-    } finally {
-      _cursor.close()
     }
   }
 
   public override fun getNullableEntitySupport(sql: SupportSQLiteQuery): MyEntity? {
-    __db.assertNotSuspendingTransaction()
-    val _cursor: Cursor = query(__db, sql, false, null)
-    try {
-      val _result: MyEntity?
-      if (_cursor.moveToFirst()) {
-        _result = __entityCursorConverter_MyEntity(_cursor)
-      } else {
-        _result = null
+    val _rawQuery: RoomRawQuery = RoomSQLiteQuery.copyFrom(sql).toRoomRawQuery()
+    val _sql: String = _rawQuery.sql
+    return performBlocking(__db, true, false) { _connection ->
+      val _stmt: SQLiteStatement = _connection.prepare(_sql)
+      try {
+        _rawQuery.getBindingFunction().invoke(_stmt)
+        val _result: MyEntity?
+        if (_stmt.step()) {
+          _result = __entityStatementConverter_MyEntity(_stmt)
+        } else {
+          _result = null
+        }
+        _result
+      } finally {
+        _stmt.close()
       }
-      return _result
-    } finally {
-      _cursor.close()
     }
   }
 
-  public override fun getEntitySupportFlow(sql: SupportSQLiteQuery): Flow<MyEntity> =
-      CoroutinesRoom.createFlow(__db, false, arrayOf("MyEntity"), object : Callable<MyEntity> {
-    public override fun call(): MyEntity {
-      val _cursor: Cursor = query(__db, sql, false, null)
+  public override fun getEntitySupportFlow(sql: SupportSQLiteQuery): Flow<MyEntity> {
+    val _rawQuery: RoomRawQuery = RoomSQLiteQuery.copyFrom(sql).toRoomRawQuery()
+    val _sql: String = _rawQuery.sql
+    return createFlow(__db, false, arrayOf("MyEntity")) { _connection ->
+      val _stmt: SQLiteStatement = _connection.prepare(_sql)
       try {
+        _rawQuery.getBindingFunction().invoke(_stmt)
         val _result: MyEntity
-        if (_cursor.moveToFirst()) {
-          _result = __entityCursorConverter_MyEntity(_cursor)
+        if (_stmt.step()) {
+          _result = __entityStatementConverter_MyEntity(_stmt)
         } else {
           error("The query result was empty, but expected a single row to return a NON-NULL object of type <MyEntity>.")
         }
-        return _result
+        _result
       } finally {
-        _cursor.close()
+        _stmt.close()
       }
     }
-  })
+  }
 
   public override fun getEntity(query: RoomRawQuery): MyEntity {
     val _sql: String = query.sql
@@ -137,33 +144,6 @@
     }
   }
 
-  private fun __entityCursorConverter_MyEntity(cursor: Cursor): MyEntity {
-    val _entity: MyEntity
-    val _cursorIndexOfPk: Int = getColumnIndex(cursor, "pk")
-    val _cursorIndexOfDoubleColumn: Int = getColumnIndex(cursor, "doubleColumn")
-    val _cursorIndexOfFloatColumn: Int = getColumnIndex(cursor, "floatColumn")
-    val _tmpPk: Long
-    if (_cursorIndexOfPk == -1) {
-      _tmpPk = 0
-    } else {
-      _tmpPk = cursor.getLong(_cursorIndexOfPk)
-    }
-    val _tmpDoubleColumn: Double
-    if (_cursorIndexOfDoubleColumn == -1) {
-      _tmpDoubleColumn = 0.0
-    } else {
-      _tmpDoubleColumn = cursor.getDouble(_cursorIndexOfDoubleColumn)
-    }
-    val _tmpFloatColumn: Float
-    if (_cursorIndexOfFloatColumn == -1) {
-      _tmpFloatColumn = 0f
-    } else {
-      _tmpFloatColumn = cursor.getFloat(_cursorIndexOfFloatColumn)
-    }
-    _entity = MyEntity(_tmpPk,_tmpDoubleColumn,_tmpFloatColumn)
-    return _entity
-  }
-
   private fun __entityStatementConverter_MyEntity(statement: SQLiteStatement): MyEntity {
     val _entity: MyEntity
     val _cursorIndexOfPk: Int = getColumnIndex(statement, "pk")
diff --git a/room/room-paging/src/androidInstrumentedTest/kotlin/androidx/room/paging/LimitOffsetPagingSourceTest.kt b/room/room-paging/src/androidInstrumentedTest/kotlin/androidx/room/paging/LimitOffsetPagingSourceTest.kt
index 7ba8f2d..e2ff237 100644
--- a/room/room-paging/src/androidInstrumentedTest/kotlin/androidx/room/paging/LimitOffsetPagingSourceTest.kt
+++ b/room/room-paging/src/androidInstrumentedTest/kotlin/androidx/room/paging/LimitOffsetPagingSourceTest.kt
@@ -33,14 +33,12 @@
 import androidx.test.core.app.ApplicationProvider
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.SmallTest
-import androidx.testutils.FilteringCoroutineContext
 import androidx.testutils.FilteringExecutor
 import androidx.testutils.TestExecutor
 import java.util.concurrent.Executors
 import java.util.concurrent.TimeUnit
 import kotlin.test.assertFalse
 import kotlin.test.assertTrue
-import kotlinx.coroutines.CoroutineName
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.ExperimentalCoroutinesApi
 import kotlinx.coroutines.Job
@@ -660,17 +658,14 @@
 
 @RunWith(AndroidJUnit4::class)
 @SmallTest
-class LimitOffsetPagingSourceTestWithFilteringCoroutineDispatcher {
+class LimitOffsetPagingSourceTestWithFilteringExecutor {
 
     private lateinit var db: LimitOffsetTestDb
     private lateinit var dao: TestItemDao
 
     // Multiple threads are necessary to prevent deadlock, since Room will acquire a thread to
     // dispatch on, when using the query / transaction dispatchers.
-    private val queryContext = FilteringCoroutineContext(delegate = Executors.newFixedThreadPool(2))
-    private val queryExecutor: FilteringExecutor
-        get() = queryContext.executor
-
+    private val queryExecutor = FilteringExecutor(delegate = Executors.newFixedThreadPool(2))
     private val mainThreadQueries = mutableListOf<Pair<String, String>>()
 
     @Before
@@ -693,7 +688,7 @@
                     // instantly execute the log callback so that we can check the thread.
                     it.run()
                 }
-                .setQueryCoroutineContext(queryContext)
+                .setQueryExecutor(queryExecutor)
                 .build()
         dao = db.getDao()
     }
@@ -716,8 +711,9 @@
         assertThat(result.data).containsExactlyElementsIn(ITEMS_LIST.subList(0, 15))
 
         // blocks invalidation notification from Room
-        queryContext.filterFunction = { context, _ ->
-            context[CoroutineName]?.name?.contains("Room Invalidation Tracker Refresh") != true
+        queryExecutor.filterFunction = {
+            // TODO(b/): Avoid relying on function name, very brittle.
+            !it.toString().contains("refreshInvalidationAsync")
         }
 
         // now write to the database
@@ -744,9 +740,7 @@
         assertThat(result.data).containsExactlyElementsIn(ITEMS_LIST.subList(20, 35))
 
         // blocks invalidation notification from Room
-        queryContext.filterFunction = { context, _ ->
-            context[CoroutineName]?.name?.contains("Room Invalidation Tracker Refresh") != true
-        }
+        queryExecutor.filterFunction = { !it.toString().contains("refreshInvalidationAsync") }
 
         // now write to the database
         dao.deleteTestItem(ITEMS_LIST[30])
diff --git a/room/room-paging/src/androidMain/kotlin/androidx/room/paging/LimitOffsetPagingSource.android.kt b/room/room-paging/src/androidMain/kotlin/androidx/room/paging/LimitOffsetPagingSource.android.kt
index fb7d965..820c1d5 100644
--- a/room/room-paging/src/androidMain/kotlin/androidx/room/paging/LimitOffsetPagingSource.android.kt
+++ b/room/room-paging/src/androidMain/kotlin/androidx/room/paging/LimitOffsetPagingSource.android.kt
@@ -46,19 +46,14 @@
         sourceQuery: RoomSQLiteQuery,
         db: RoomDatabase,
         vararg tables: String,
-    ) : this(
-        sourceQuery =
-            RoomRawQuery(sql = sourceQuery.sql, onBindStatement = { sourceQuery.bindTo(it) }),
-        db = db,
-        tables = tables
-    )
+    ) : this(sourceQuery = sourceQuery.toRoomRawQuery(), db = db, tables = tables)
 
     constructor(
         supportSQLiteQuery: SupportSQLiteQuery,
         db: RoomDatabase,
         vararg tables: String,
     ) : this(
-        sourceQuery = RoomSQLiteQuery.copyFrom(supportSQLiteQuery),
+        sourceQuery = RoomSQLiteQuery.copyFrom(supportSQLiteQuery).toRoomRawQuery(),
         db = db,
         tables = tables,
     )
diff --git a/room/room-paging/src/commonMain/kotlin/androidx/room/paging/LimitOffsetPagingSource.kt b/room/room-paging/src/commonMain/kotlin/androidx/room/paging/LimitOffsetPagingSource.kt
index eb9e0c6..2e166bc5 100644
--- a/room/room-paging/src/commonMain/kotlin/androidx/room/paging/LimitOffsetPagingSource.kt
+++ b/room/room-paging/src/commonMain/kotlin/androidx/room/paging/LimitOffsetPagingSource.kt
@@ -20,6 +20,8 @@
 import androidx.paging.PagingSource
 import androidx.paging.PagingSource.LoadParams
 import androidx.paging.PagingSource.LoadResult
+import androidx.paging.PagingSource.LoadResult.Invalid
+import androidx.room.InvalidationTracker
 import androidx.room.RoomDatabase
 import androidx.room.RoomRawQuery
 import androidx.room.immediateTransaction
@@ -29,8 +31,6 @@
 import androidx.room.useReaderConnection
 import androidx.sqlite.SQLiteStatement
 import kotlinx.atomicfu.atomic
-import kotlinx.coroutines.CancellationException
-import kotlinx.coroutines.Job
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.withContext
 
@@ -56,45 +56,43 @@
 }
 
 internal class CommonLimitOffsetImpl<Value : Any>(
-    private val tables: Array<out String>,
-    private val pagingSource: LimitOffsetPagingSource<Value>,
+    tables: Array<out String>,
+    val pagingSource: LimitOffsetPagingSource<Value>,
     private val convertRows: (SQLiteStatement, Int) -> List<Value>
 ) {
     private val db = pagingSource.db
     private val sourceQuery = pagingSource.sourceQuery
-
     internal val itemCount = atomic(INITIAL_ITEM_COUNT)
-
-    private val invalidationFlowStarted = atomic(false)
-    private var invalidationFlowJob: Job? = null
+    private val registered = atomic(false)
+    private val observer =
+        object : InvalidationTracker.Observer(tables) {
+            override fun onInvalidated(tables: Set<String>) {
+                pagingSource.invalidate()
+            }
+        }
 
     init {
-        pagingSource.registerInvalidatedCallback { invalidationFlowJob?.cancel() }
+        pagingSource.registerInvalidatedCallback {
+            db.getCoroutineScope().launch { db.invalidationTracker.unsubscribe(observer) }
+        }
     }
 
     suspend fun load(params: LoadParams<Int>): LoadResult<Int, Value> {
-        if (invalidationFlowStarted.compareAndSet(expect = false, update = true)) {
-            invalidationFlowJob =
-                db.getCoroutineScope().launch {
-                    db.invalidationTracker.createFlow(*tables, emitInitialState = false).collect {
-                        if (pagingSource.invalid) {
-                            throw CancellationException("PagingSource is invalid")
-                        }
-                        pagingSource.invalidate()
-                    }
-                }
-        }
-
-        val tempCount = itemCount.value
-        // if itemCount is < 0, then it is initial load
-        return try {
-            if (tempCount == INITIAL_ITEM_COUNT) {
-                initialLoad(params)
-            } else {
-                nonInitialLoad(params, tempCount)
+        return withContext(db.getCoroutineScope().coroutineContext) {
+            if (!pagingSource.invalid && registered.compareAndSet(expect = false, update = true)) {
+                db.invalidationTracker.subscribe(observer)
             }
-        } catch (e: Exception) {
-            LoadResult.Error(e)
+            val tempCount = itemCount.value
+            // if itemCount is < 0, then it is initial load
+            try {
+                if (tempCount == INITIAL_ITEM_COUNT) {
+                    initialLoad(params)
+                } else {
+                    nonInitialLoad(params, tempCount)
+                }
+            } catch (e: Exception) {
+                LoadResult.Error(e)
+            }
         }
     }
 
@@ -136,16 +134,12 @@
                 itemCount = tempCount,
                 convertRows = convertRows
             )
-        // TODO(b/192269858): Create a better API to facilitate source invalidation.
-        // Manually check if database has been updated. If so, invalidate the source and the result.
-        withContext(db.getCoroutineScope().coroutineContext) {
-            if (db.invalidationTracker.refresh(*tables)) {
-                pagingSource.invalidate()
-            }
-        }
+        // manually check if database has been updated. If so, the observer's
+        // invalidation callback will invalidate this paging source
+        db.invalidationTracker.refreshInvalidation()
 
         @Suppress("UNCHECKED_CAST")
-        return if (pagingSource.invalid) INVALID as LoadResult.Invalid<Int, Value> else loadResult
+        return if (pagingSource.invalid) INVALID as Invalid<Int, Value> else loadResult
     }
 
     companion object {
@@ -154,7 +148,7 @@
          *
          * Any loaded data or queued loads prior to returning INVALID will be discarded
          */
-        val INVALID = LoadResult.Invalid<Any, Any>()
+        val INVALID = Invalid<Any, Any>()
 
         const val BUG_LINK =
             "https://issuetracker.google.com/issues/new?component=413107&template=1096568"
diff --git a/room/room-runtime/api/current.txt b/room/room-runtime/api/current.txt
index 7a0cb28..8f01a73 100644
--- a/room/room-runtime/api/current.txt
+++ b/room/room-runtime/api/current.txt
@@ -32,7 +32,6 @@
 
   public class InvalidationTracker {
     method @WorkerThread public void addObserver(androidx.room.InvalidationTracker.Observer observer);
-    method public final kotlinx.coroutines.flow.Flow<java.util.Set<java.lang.String>> createFlow(String[] tables, optional boolean emitInitialState);
     method public final void refreshAsync();
     method public void refreshVersionsAsync();
     method @WorkerThread public void removeObserver(androidx.room.InvalidationTracker.Observer observer);
@@ -176,7 +175,7 @@
   }
 
   public final class RoomDatabaseKt {
-    method @Deprecated public static kotlinx.coroutines.flow.Flow<java.util.Set<java.lang.String>> invalidationTrackerFlow(androidx.room.RoomDatabase, String[] tables, optional boolean emitInitialState);
+    method public static kotlinx.coroutines.flow.Flow<java.util.Set<java.lang.String>> invalidationTrackerFlow(androidx.room.RoomDatabase, String[] tables, optional boolean emitInitialState);
     method public static suspend <R> Object? useReaderConnection(androidx.room.RoomDatabase, kotlin.jvm.functions.Function2<? super androidx.room.Transactor,? super kotlin.coroutines.Continuation<? super R>,? extends java.lang.Object?> block, kotlin.coroutines.Continuation<? super R>);
     method public static suspend <R> Object? useWriterConnection(androidx.room.RoomDatabase, kotlin.jvm.functions.Function2<? super androidx.room.Transactor,? super kotlin.coroutines.Continuation<? super R>,? extends java.lang.Object?> block, kotlin.coroutines.Continuation<? super R>);
     method public static suspend <R> Object? withTransaction(androidx.room.RoomDatabase, kotlin.jvm.functions.Function1<? super kotlin.coroutines.Continuation<? super R>,? extends java.lang.Object?> block, kotlin.coroutines.Continuation<? super R>);
diff --git a/room/room-runtime/api/restricted_current.txt b/room/room-runtime/api/restricted_current.txt
index 5f3e3d7..0fb8a01 100644
--- a/room/room-runtime/api/restricted_current.txt
+++ b/room/room-runtime/api/restricted_current.txt
@@ -60,12 +60,12 @@
     method public final int handleMultiple(androidx.sqlite.SQLiteConnection connection, T?[]? entities);
   }
 
-  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public abstract class EntityDeletionOrUpdateAdapter<T> extends androidx.room.SharedSQLiteStatement {
-    ctor public EntityDeletionOrUpdateAdapter(androidx.room.RoomDatabase database);
-    method protected abstract void bind(androidx.sqlite.db.SupportSQLiteStatement statement, T entity);
-    method public final int handle(T entity);
-    method public final int handleMultiple(Iterable<? extends T> entities);
-    method public final int handleMultiple(T[] entities);
+  @Deprecated @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public abstract class EntityDeletionOrUpdateAdapter<T> extends androidx.room.SharedSQLiteStatement {
+    ctor @Deprecated public EntityDeletionOrUpdateAdapter(androidx.room.RoomDatabase database);
+    method @Deprecated protected abstract void bind(androidx.sqlite.db.SupportSQLiteStatement statement, T entity);
+    method @Deprecated public final int handle(T entity);
+    method @Deprecated public final int handleMultiple(Iterable<? extends T> entities);
+    method @Deprecated public final int handleMultiple(T[] entities);
   }
 
   @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public abstract class EntityInsertAdapter<T> {
@@ -84,19 +84,19 @@
     method public final java.util.List<java.lang.Long> insertAndReturnIdsList(androidx.sqlite.SQLiteConnection connection, T?[]? entities);
   }
 
-  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public abstract class EntityInsertionAdapter<T> extends androidx.room.SharedSQLiteStatement {
-    ctor public EntityInsertionAdapter(androidx.room.RoomDatabase database);
-    method protected abstract void bind(androidx.sqlite.db.SupportSQLiteStatement statement, T entity);
-    method public final void insert(Iterable<? extends T> entities);
-    method public final void insert(T entity);
-    method public final void insert(T[] entities);
-    method public final long insertAndReturnId(T entity);
-    method public final long[] insertAndReturnIdsArray(java.util.Collection<? extends T> entities);
-    method public final long[] insertAndReturnIdsArray(T[] entities);
-    method public final Long[] insertAndReturnIdsArrayBox(java.util.Collection<? extends T> entities);
-    method public final Long[] insertAndReturnIdsArrayBox(T[] entities);
-    method public final java.util.List<java.lang.Long> insertAndReturnIdsList(java.util.Collection<? extends T> entities);
-    method public final java.util.List<java.lang.Long> insertAndReturnIdsList(T[] entities);
+  @Deprecated @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public abstract class EntityInsertionAdapter<T> extends androidx.room.SharedSQLiteStatement {
+    ctor @Deprecated public EntityInsertionAdapter(androidx.room.RoomDatabase database);
+    method @Deprecated protected abstract void bind(androidx.sqlite.db.SupportSQLiteStatement statement, T entity);
+    method @Deprecated public final void insert(Iterable<? extends T> entities);
+    method @Deprecated public final void insert(T entity);
+    method @Deprecated public final void insert(T[] entities);
+    method @Deprecated public final long insertAndReturnId(T entity);
+    method @Deprecated public final long[] insertAndReturnIdsArray(java.util.Collection<? extends T> entities);
+    method @Deprecated public final long[] insertAndReturnIdsArray(T[] entities);
+    method @Deprecated public final Long[] insertAndReturnIdsArrayBox(java.util.Collection<? extends T> entities);
+    method @Deprecated public final Long[] insertAndReturnIdsArrayBox(T[] entities);
+    method @Deprecated public final java.util.List<java.lang.Long> insertAndReturnIdsList(java.util.Collection<? extends T> entities);
+    method @Deprecated public final java.util.List<java.lang.Long> insertAndReturnIdsList(T[] entities);
   }
 
   @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class EntityUpsertAdapter<T> {
@@ -117,18 +117,18 @@
   public static final class EntityUpsertAdapter.Companion {
   }
 
-  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class EntityUpsertionAdapter<T> {
-    ctor public EntityUpsertionAdapter(androidx.room.EntityInsertionAdapter<T> insertionAdapter, androidx.room.EntityDeletionOrUpdateAdapter<T> updateAdapter);
-    method public void upsert(Iterable<? extends T> entities);
-    method public void upsert(T entity);
-    method public void upsert(T[] entities);
-    method public long upsertAndReturnId(T entity);
-    method public long[] upsertAndReturnIdsArray(java.util.Collection<? extends T> entities);
-    method public long[] upsertAndReturnIdsArray(T[] entities);
-    method public Long[] upsertAndReturnIdsArrayBox(java.util.Collection<? extends T> entities);
-    method public Long[] upsertAndReturnIdsArrayBox(T[] entities);
-    method public java.util.List<java.lang.Long> upsertAndReturnIdsList(java.util.Collection<? extends T> entities);
-    method public java.util.List<java.lang.Long> upsertAndReturnIdsList(T[] entities);
+  @Deprecated @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final class EntityUpsertionAdapter<T> {
+    ctor @Deprecated public EntityUpsertionAdapter(androidx.room.EntityInsertionAdapter<T> insertionAdapter, androidx.room.EntityDeletionOrUpdateAdapter<T> updateAdapter);
+    method @Deprecated public void upsert(Iterable<? extends T> entities);
+    method @Deprecated public void upsert(T entity);
+    method @Deprecated public void upsert(T[] entities);
+    method @Deprecated public long upsertAndReturnId(T entity);
+    method @Deprecated public long[] upsertAndReturnIdsArray(java.util.Collection<? extends T> entities);
+    method @Deprecated public long[] upsertAndReturnIdsArray(T[] entities);
+    method @Deprecated public Long[] upsertAndReturnIdsArrayBox(java.util.Collection<? extends T> entities);
+    method @Deprecated public Long[] upsertAndReturnIdsArrayBox(T[] entities);
+    method @Deprecated public java.util.List<java.lang.Long> upsertAndReturnIdsList(java.util.Collection<? extends T> entities);
+    method @Deprecated public java.util.List<java.lang.Long> upsertAndReturnIdsList(T[] entities);
   }
 
   @SuppressCompatibility @kotlin.RequiresOptIn @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.BINARY) @kotlin.annotation.Target(allowedTargets={kotlin.annotation.AnnotationTarget.CLASS, kotlin.annotation.AnnotationTarget.FUNCTION}) public @interface ExperimentalRoomApi {
@@ -139,7 +139,6 @@
     ctor @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public InvalidationTracker(androidx.room.RoomDatabase database, java.util.Map<java.lang.String,java.lang.String> shadowTablesMap, java.util.Map<java.lang.String,java.util.Set<java.lang.String>> viewTables, java.lang.String... tableNames);
     method @WorkerThread public void addObserver(androidx.room.InvalidationTracker.Observer observer);
     method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) @WorkerThread public void addWeakObserver(androidx.room.InvalidationTracker.Observer observer);
-    method public final kotlinx.coroutines.flow.Flow<java.util.Set<java.lang.String>> createFlow(String[] tables, optional boolean emitInitialState);
     method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public <T> androidx.lifecycle.LiveData<T> createLiveData(String[] tableNames, boolean inTransaction, java.util.concurrent.Callable<T?> computeFunction);
     method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public final <T> androidx.lifecycle.LiveData<T> createLiveData(String[] tableNames, boolean inTransaction, kotlin.jvm.functions.Function1<? super androidx.sqlite.SQLiteConnection,? extends T?> computeFunction);
     method @Deprecated @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public <T> androidx.lifecycle.LiveData<T> createLiveData(String[] tableNames, java.util.concurrent.Callable<T?> computeFunction);
@@ -295,7 +294,7 @@
   }
 
   public final class RoomDatabaseKt {
-    method @Deprecated public static kotlinx.coroutines.flow.Flow<java.util.Set<java.lang.String>> invalidationTrackerFlow(androidx.room.RoomDatabase, String[] tables, optional boolean emitInitialState);
+    method public static kotlinx.coroutines.flow.Flow<java.util.Set<java.lang.String>> invalidationTrackerFlow(androidx.room.RoomDatabase, String[] tables, optional boolean emitInitialState);
     method public static suspend <R> Object? useReaderConnection(androidx.room.RoomDatabase, kotlin.jvm.functions.Function2<? super androidx.room.Transactor,? super kotlin.coroutines.Continuation<? super R>,? extends java.lang.Object?> block, kotlin.coroutines.Continuation<? super R>);
     method public static suspend <R> Object? useWriterConnection(androidx.room.RoomDatabase, kotlin.jvm.functions.Function2<? super androidx.room.Transactor,? super kotlin.coroutines.Continuation<? super R>,? extends java.lang.Object?> block, kotlin.coroutines.Continuation<? super R>);
     method public static suspend <R> Object? withTransaction(androidx.room.RoomDatabase, kotlin.jvm.functions.Function1<? super kotlin.coroutines.Continuation<? super R>,? extends java.lang.Object?> block, kotlin.coroutines.Continuation<? super R>);
@@ -360,6 +359,7 @@
     method public String getSql();
     method public void init(String query, int initArgCount);
     method public void release();
+    method public androidx.room.RoomRawQuery toRoomRawQuery();
     property public int argCount;
     property public final int capacity;
     property public String sql;
diff --git a/room/room-runtime/bcv/native/current.txt b/room/room-runtime/bcv/native/current.txt
index 80ebf09..b7f3618 100644
--- a/room/room-runtime/bcv/native/current.txt
+++ b/room/room-runtime/bcv/native/current.txt
@@ -379,9 +379,18 @@
 final class androidx.room/InvalidationTracker { // androidx.room/InvalidationTracker|null[0]
     constructor <init>(androidx.room/RoomDatabase, kotlin.collections/Map<kotlin/String, kotlin/String>, kotlin.collections/Map<kotlin/String, kotlin.collections/Set<kotlin/String>>, kotlin/Array<out kotlin/String>...) // androidx.room/InvalidationTracker.<init>|<init>(androidx.room.RoomDatabase;kotlin.collections.Map<kotlin.String,kotlin.String>;kotlin.collections.Map<kotlin.String,kotlin.collections.Set<kotlin.String>>;kotlin.Array<out|kotlin.String>...){}[0]
 
-    final fun createFlow(kotlin/Array<out kotlin/String>..., kotlin/Boolean = ...): kotlinx.coroutines.flow/Flow<kotlin.collections/Set<kotlin/String>> // androidx.room/InvalidationTracker.createFlow|createFlow(kotlin.Array<out|kotlin.String>...;kotlin.Boolean){}[0]
     final fun refreshAsync() // androidx.room/InvalidationTracker.refreshAsync|refreshAsync(){}[0]
-    final suspend fun refresh(kotlin/Array<out kotlin/String>...): kotlin/Boolean // androidx.room/InvalidationTracker.refresh|refresh(kotlin.Array<out|kotlin.String>...){}[0]
+    final fun stop() // androidx.room/InvalidationTracker.stop|stop(){}[0]
+    final suspend fun refreshInvalidation() // androidx.room/InvalidationTracker.refreshInvalidation|refreshInvalidation(){}[0]
+    final suspend fun subscribe(androidx.room/InvalidationTracker.Observer) // androidx.room/InvalidationTracker.subscribe|subscribe(androidx.room.InvalidationTracker.Observer){}[0]
+    final suspend fun unsubscribe(androidx.room/InvalidationTracker.Observer) // androidx.room/InvalidationTracker.unsubscribe|unsubscribe(androidx.room.InvalidationTracker.Observer){}[0]
+
+    abstract class Observer { // androidx.room/InvalidationTracker.Observer|null[0]
+        constructor <init>(kotlin/Array<out kotlin/String>) // androidx.room/InvalidationTracker.Observer.<init>|<init>(kotlin.Array<out|kotlin.String>){}[0]
+        constructor <init>(kotlin/String, kotlin/Array<out kotlin/String>...) // androidx.room/InvalidationTracker.Observer.<init>|<init>(kotlin.String;kotlin.Array<out|kotlin.String>...){}[0]
+
+        abstract fun onInvalidated(kotlin.collections/Set<kotlin/String>) // androidx.room/InvalidationTracker.Observer.onInvalidated|onInvalidated(kotlin.collections.Set<kotlin.String>){}[0]
+    }
 }
 
 final class androidx.room/RoomRawQuery { // androidx.room/RoomRawQuery|null[0]
diff --git a/room/room-runtime/src/androidMain/kotlin/androidx/room/EntityDeletionOrUpdateAdapter.android.kt b/room/room-runtime/src/androidMain/kotlin/androidx/room/EntityDeletionOrUpdateAdapter.android.kt
index 019825b..16ecdf8 100644
--- a/room/room-runtime/src/androidMain/kotlin/androidx/room/EntityDeletionOrUpdateAdapter.android.kt
+++ b/room/room-runtime/src/androidMain/kotlin/androidx/room/EntityDeletionOrUpdateAdapter.android.kt
@@ -28,6 +28,7 @@
  *   given database.
  */
 @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+@Deprecated("No longer used by generated code.", ReplaceWith("EntityDeleteOrUpdateAdapter"))
 abstract class EntityDeletionOrUpdateAdapter<T>(database: RoomDatabase) :
     SharedSQLiteStatement(database) {
     /**
diff --git a/room/room-runtime/src/androidMain/kotlin/androidx/room/EntityInsertionAdapter.android.kt b/room/room-runtime/src/androidMain/kotlin/androidx/room/EntityInsertionAdapter.android.kt
index 4b54029..aa1be1a 100644
--- a/room/room-runtime/src/androidMain/kotlin/androidx/room/EntityInsertionAdapter.android.kt
+++ b/room/room-runtime/src/androidMain/kotlin/androidx/room/EntityInsertionAdapter.android.kt
@@ -28,6 +28,7 @@
  *   database.
  */
 @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+@Deprecated("No longer used by generated code.", ReplaceWith("EntityInsertAdapter"))
 abstract class EntityInsertionAdapter<T>(database: RoomDatabase) : SharedSQLiteStatement(database) {
     /**
      * Binds the entity into the given statement.
diff --git a/room/room-runtime/src/androidMain/kotlin/androidx/room/EntityUpsertionAdapter.android.kt b/room/room-runtime/src/androidMain/kotlin/androidx/room/EntityUpsertionAdapter.android.kt
index 3c61d3b..bc8c8f2 100644
--- a/room/room-runtime/src/androidMain/kotlin/androidx/room/EntityUpsertionAdapter.android.kt
+++ b/room/room-runtime/src/androidMain/kotlin/androidx/room/EntityUpsertionAdapter.android.kt
@@ -43,9 +43,10 @@
  *   the insertion fails
  */
 @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
+@Deprecated("No longer used by generated code.", ReplaceWith("EntityUpsertAdapter"))
 class EntityUpsertionAdapter<T>(
-    private val insertionAdapter: EntityInsertionAdapter<T>,
-    private val updateAdapter: EntityDeletionOrUpdateAdapter<T>
+    @Suppress("DEPRECATION") private val insertionAdapter: EntityInsertionAdapter<T>,
+    @Suppress("DEPRECATION") private val updateAdapter: EntityDeletionOrUpdateAdapter<T>
 ) {
     /**
      * Inserts the entity into the database. If a constraint exception is thrown i.e. a primary key
diff --git a/room/room-runtime/src/androidMain/kotlin/androidx/room/InvalidationTracker.android.kt b/room/room-runtime/src/androidMain/kotlin/androidx/room/InvalidationTracker.android.kt
index 4beb12a..6c3b88d 100644
--- a/room/room-runtime/src/androidMain/kotlin/androidx/room/InvalidationTracker.android.kt
+++ b/room/room-runtime/src/androidMain/kotlin/androidx/room/InvalidationTracker.android.kt
@@ -25,25 +25,19 @@
 import androidx.sqlite.SQLiteConnection
 import java.lang.ref.WeakReference
 import java.util.concurrent.Callable
-import kotlinx.atomicfu.locks.reentrantLock
-import kotlinx.atomicfu.locks.withLock
-import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
 import kotlinx.coroutines.runBlocking
 
 /**
  * The invalidation tracker keeps track of tables modified by queries and notifies its subscribed
  * [Observer]s about such modifications.
  *
- * [Observer]s contain one or more tables and are added to the tracker via [addObserver]. Once an
+ * [Observer]s contain one or more tables and are added to the tracker via [subscribe]. Once an
  * observer is subscribed, if a database operation changes one of the tables the observer is
  * subscribed to, then such table is considered 'invalidated' and [Observer.onInvalidated] will be
  * invoked on the observer. If an observer is no longer interested in tracking modifications it can
- * be removed via [removeObserver].
- *
- * Additionally, a [Flow] tracking one or more tables can be created via [createFlow]. Once the
- * [Flow] stream starts being collected, if a database operation changes one of the tables that the
- * [Flow] was created from, then such table is considered 'invalidated' and the [Flow] will emit a
- * new value.
+ * be removed via [unsubscribe].
  */
 actual open class InvalidationTracker
 @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
@@ -54,16 +48,7 @@
     internal vararg val tableNames: String
 ) {
     private val implementation =
-        TriggerBasedInvalidationTracker(
-            database = database,
-            shadowTablesMap = shadowTablesMap,
-            viewTables = viewTables,
-            tableNames = tableNames,
-            onInvalidatedTablesIds = ::notifyInvalidatedObservers
-        )
-
-    private val observerMap = mutableMapOf<Observer, ObserverWrapper>()
-    private val observerMapLock = reentrantLock()
+        TriggerBasedInvalidationTracker(database, shadowTablesMap, viewTables, tableNames)
 
     private var autoCloser: AutoCloser? = null
 
@@ -138,10 +123,10 @@
     }
 
     /**
-     * Synchronize created [Observer]s or [Flow]s with their tables.
+     * Synchronize subscribed observers with their tables.
      *
      * This function should be called before any write operation is performed on the database so
-     * that a tracking link is created between the observers and flows, and their interested tables.
+     * that a tracking link is created between observers and its interest tables.
      *
      * @see refreshAsync
      */
@@ -153,37 +138,23 @@
     }
 
     // TODO(b/309990302): Needed for compatibility with internalBeginTransaction(), not great.
-    @WorkerThread internal fun syncBlocking(): Unit = runBlocking { sync() }
+    internal fun syncBlocking(): Unit = runBlocking { sync() }
 
     /**
-     * Refresh subscribed [Observer]s and [Flow]s asynchronously, invoking [Observer.onInvalidated]
-     * on those whose tables have been invalidated.
+     * Refresh subscribed observers asynchronously, invoking [Observer.onInvalidated] on those whose
+     * tables have been invalidated.
      *
      * This function should be called after any write operation is performed on the database, such
-     * that tracked tables and its associated observers / flows are notified if invalidated. In most
-     * cases Room will call this function automatically but if a write operation is performed on the
-     * database via another connection or through [RoomDatabase.useConnection] you might need to
-     * invoke this function to trigger invalidation.
+     * that tracked tables and its associated observers are notified if invalidated.
      */
     actual fun refreshAsync() {
         implementation.refreshInvalidationAsync(onRefreshScheduled, onRefreshCompleted)
     }
 
-    /**
-     * Non-asynchronous version of [refreshAsync] with the addition that it will return true if
-     * there were any pending invalidations.
-     *
-     * An optional array of tables can be given to validate if any of those tables had pending
-     * invalidations, if so causing this function to return true.
-     */
-    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
-    actual suspend fun refresh(vararg tables: String): Boolean {
-        return implementation.refreshInvalidation(tables, onRefreshScheduled, onRefreshCompleted)
-    }
-
     private fun onAutoCloseCallback() {
         synchronized(trackerLock) {
-            val isObserverMapEmpty = getAllObservers().filterNot { it.isRemote }.isEmpty()
+            val isObserverMapEmpty =
+                implementation.getAllObservers().filterNot { it.isRemote }.isEmpty()
             if (multiInstanceInvalidationClient != null && isObserverMapEmpty) {
                 stopMultiInstanceInvalidation()
             }
@@ -208,33 +179,18 @@
     }
 
     /**
-     * Creates a [Flow] that tracks modifications in the database and emits sets of the tables that
-     * were invalidated.
+     * Subscribes the given [observer] with the tracker such that it is notified if any table it is
+     * interested on changes.
      *
-     * The [Flow] will emit at least one value, a set of all the tables registered for observation
-     * to kick-start the stream unless [emitInitialState] is set to `false`.
+     * If the observer is already subscribed, then this function does nothing.
      *
-     * If one of the tables to observe does not exist in the database, this functions throws an
-     * [IllegalArgumentException].
-     *
-     * The returned [Flow] can be used to create a stream that reacts to changes in the database:
-     * ```
-     * fun getArtistTours(from: Date, to: Date): Flow<Map<Artist, TourState>> {
-     *   return db.invalidationTracker.createFlow("Artist").map { _ ->
-     *     val artists = artistsDao.getAllArtists()
-     *     val tours = tourService.fetchStates(artists.map { it.id })
-     *     associateTours(artists, tours, from, to)
-     *   }
-     * }
-     * ```
-     *
-     * @param tables The name of the tables or views to track.
-     * @param emitInitialState Set to `false` if no initial emission is desired. Default value is
-     *   `true`.
+     * @param observer The observer that will listen for database changes.
+     * @throws IllegalArgumentException if one of the tables in the observer does not exist.
      */
-    @JvmOverloads
-    actual fun createFlow(vararg tables: String, emitInitialState: Boolean): Flow<Set<String>> {
-        return implementation.createFlow(tables, emitInitialState)
+    // TODO(b/329315924): Replace with Flow based API
+    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+    actual suspend fun subscribe(observer: Observer) {
+        implementation.addObserver(observer)
     }
 
     /**
@@ -255,38 +211,14 @@
      * @param observer The observer which listens the database for changes.
      */
     @WorkerThread
-    open fun addObserver(observer: Observer) {
-        val shouldSync = addObserverOnly(observer)
-        if (shouldSync) {
-            runBlocking { implementation.syncTriggers() }
-        }
+    open fun addObserver(observer: Observer): Unit = runBlocking {
+        implementation.addObserver(observer)
     }
 
     /** An internal [addObserver] for remote observer only that skips trigger syncing. */
-    internal fun addRemoteObserver(observer: Observer) {
+    internal fun addRemoteObserver(observer: Observer): Unit {
         check(observer.isRemote) { "isRemote was false of observer argument" }
-        addObserverOnly(observer)
-    }
-
-    /** Add an observer and return true if it was actually added, or false if already added. */
-    private fun addObserverOnly(observer: Observer): Boolean {
-        val (resolvedTableNames, tableIds) = implementation.validateTableNames(observer.tables)
-        val wrapper =
-            ObserverWrapper(
-                observer = observer,
-                tableIds = tableIds,
-                tableNames = resolvedTableNames
-            )
-
-        val currentObserver =
-            observerMapLock.withLock {
-                if (observerMap.containsKey(observer)) {
-                    observerMap.getValue(observer)
-                } else {
-                    observerMap.put(observer, wrapper)
-                }
-            }
-        return currentObserver == null && implementation.onObserverAdded(tableIds)
+        implementation.addObserverOnly(observer)
     }
 
     /**
@@ -300,7 +232,20 @@
     @WorkerThread
     @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
     open fun addWeakObserver(observer: Observer) {
-        addObserver(WeakObserver(this, observer))
+        addObserver(WeakObserver(this, database.getCoroutineScope(), observer))
+    }
+
+    /**
+     * Unsubscribes the given [observer] from the tracker.
+     *
+     * If the observer was never subscribed in the first place, then this function does nothing.
+     *
+     * @param observer The observer to remove.
+     */
+    // TODO(b/329315924): Replace with Flow based API
+    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+    actual suspend fun unsubscribe(observer: Observer) {
+        implementation.removeObserver(observer)
     }
 
     /**
@@ -312,49 +257,31 @@
      * @param observer The observer to remove.
      */
     @WorkerThread
-    open fun removeObserver(observer: Observer): Unit {
-        val shouldSync = removeObserverOnly(observer)
-        if (shouldSync) {
-            runBlocking { implementation.syncTriggers() }
-        }
+    open fun removeObserver(observer: Observer): Unit = runBlocking {
+        implementation.removeObserver(observer)
     }
 
     /**
-     * Removes an observer and return true if it was actually removed, or false if it was not found.
-     */
-    private fun removeObserverOnly(observer: Observer): Boolean {
-        val wrapper = observerMapLock.withLock { observerMap.remove(observer) }
-        return wrapper != null && implementation.onObserverRemoved(wrapper.tableIds)
-    }
-
-    private fun getAllObservers() = observerMapLock.withLock { observerMap.keys.toList() }
-
-    /**
      * Enqueues a task to refresh the list of updated tables.
      *
      * This method is automatically called when [RoomDatabase.endTransaction] is called but if you
      * have another connection to the database or directly use
      * [androidx.sqlite.db.SupportSQLiteDatabase], you may need to call this manually.
-     *
-     * @see refreshAsync
      */
     open fun refreshVersionsAsync() {
         implementation.refreshInvalidationAsync(onRefreshScheduled, onRefreshCompleted)
     }
 
-    /**
-     * Check versions for tables, and run observers synchronously if tables have been updated.
-     *
-     * @see refresh
-     */
+    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+    actual suspend fun refreshInvalidation() {
+        implementation.refreshInvalidation(onRefreshScheduled, onRefreshCompleted)
+    }
+
+    /** Check versions for tables, and run observers synchronously if tables have been updated. */
     @WorkerThread
     @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
     open fun refreshVersionsSync(): Unit = runBlocking {
-        implementation.refreshInvalidation(emptyArray(), onRefreshScheduled, onRefreshCompleted)
-    }
-
-    private fun notifyInvalidatedObservers(tableIds: Set<Int>) {
-        observerMapLock.withLock { observerMap.values.forEach { it.notifyByTableIds(tableIds) } }
+        implementation.refreshInvalidation(onRefreshScheduled, onRefreshCompleted)
     }
 
     /**
@@ -365,14 +292,9 @@
      *
      * @param tables The invalidated tables.
      */
+    @RestrictTo(RestrictTo.Scope.LIBRARY)
     internal fun notifyObserversByTableNames(vararg tables: String) {
-        observerMapLock.withLock {
-            observerMap.values.forEach {
-                if (!it.observer.isRemote) {
-                    it.notifyByTableNames(setOf(*tables))
-                }
-            }
-        }
+        implementation.notifyInvalidatedTableNames(setOf(*tables)) { !it.isRemote }
     }
 
     /**
@@ -477,14 +399,15 @@
      * @param tables The names of the tables this observer is interested in getting notified if they
      *   are modified.
      */
-    abstract class Observer(internal val tables: Array<out String>) {
+    actual abstract class Observer
+    actual constructor(internal actual val tables: Array<out String>) {
         /**
          * Creates an observer for the given tables and views.
          *
          * @param firstTable The name of the table or view.
          * @param rest More names of tables or views.
          */
-        protected constructor(
+        protected actual constructor(
             firstTable: String,
             vararg rest: String
         ) : this(arrayOf(firstTable, *rest))
@@ -497,12 +420,34 @@
          *   invalidated. When observing a database view the names of underlying tables will be in
          *   the set instead of the view name.
          */
-        abstract fun onInvalidated(tables: Set<String>)
+        actual abstract fun onInvalidated(tables: Set<String>)
 
         internal open val isRemote: Boolean
             get() = false
     }
 
+    /**
+     * An Observer wrapper that keeps a weak reference to the given object.
+     *
+     * This class will automatically unsubscribe when the wrapped observer goes out of memory.
+     */
+    internal class WeakObserver(
+        val tracker: InvalidationTracker,
+        val coroutineScope: CoroutineScope,
+        delegate: Observer
+    ) : Observer(delegate.tables) {
+        private val delegateRef: WeakReference<Observer> = WeakReference(delegate)
+
+        override fun onInvalidated(tables: Set<String>) {
+            val observer = delegateRef.get()
+            if (observer == null) {
+                coroutineScope.launch { tracker.unsubscribe(this@WeakObserver) }
+            } else {
+                observer.onInvalidated(tables)
+            }
+        }
+    }
+
     /** Stores needed info to restart the invalidation after it was auto-closed. */
     private data class MultiInstanceClientInitState(
         val context: Context,
@@ -513,89 +458,3 @@
     // Kept for binary compatibility even if empty. :(
     companion object
 }
-
-/**
- * Wraps an [Observer] and keeps the table information.
- *
- * Internally table ids are used which may change from database to database so the table related
- * information is kept here rather than in the actual observer.
- */
-internal class ObserverWrapper(
-    internal val observer: Observer,
-    internal val tableIds: IntArray,
-    private val tableNames: Array<out String>
-) {
-    init {
-        check(tableIds.size == tableNames.size)
-    }
-
-    // Optimization for a single-table observer
-    private val singleTableSet = if (tableNames.isNotEmpty()) setOf(tableNames[0]) else emptySet()
-
-    internal fun notifyByTableIds(invalidatedTablesIds: Set<Int>) {
-        val invalidatedTables =
-            when (tableIds.size) {
-                0 -> emptySet()
-                1 -> if (invalidatedTablesIds.contains(tableIds[0])) singleTableSet else emptySet()
-                else ->
-                    buildSet {
-                        tableIds.forEachIndexed { id, tableId ->
-                            if (invalidatedTablesIds.contains(tableId)) {
-                                add(tableNames[id])
-                            }
-                        }
-                    }
-            }
-        if (invalidatedTables.isNotEmpty()) {
-            observer.onInvalidated(invalidatedTables)
-        }
-    }
-
-    internal fun notifyByTableNames(invalidatedTablesNames: Set<String>) {
-        val invalidatedTables =
-            when (tableNames.size) {
-                0 -> emptySet()
-                1 ->
-                    if (
-                        invalidatedTablesNames.any { it.equals(tableNames[0], ignoreCase = true) }
-                    ) {
-                        singleTableSet
-                    } else {
-                        emptySet()
-                    }
-                else ->
-                    buildSet {
-                        invalidatedTablesNames.forEach { table ->
-                            for (ourTable in tableNames) {
-                                if (ourTable.equals(table, ignoreCase = true)) {
-                                    add(ourTable)
-                                    break
-                                }
-                            }
-                        }
-                    }
-            }
-        if (invalidatedTables.isNotEmpty()) {
-            observer.onInvalidated(invalidatedTables)
-        }
-    }
-}
-
-/**
- * An Observer wrapper that keeps a weak reference to the given object.
- *
- * This class will automatically unsubscribe when the wrapped observer goes out of memory.
- */
-internal class WeakObserver(private val tracker: InvalidationTracker, delegate: Observer) :
-    Observer(delegate.tables) {
-    private val delegateRef: WeakReference<Observer> = WeakReference(delegate)
-
-    override fun onInvalidated(tables: Set<String>) {
-        val observer = delegateRef.get()
-        if (observer == null) {
-            tracker.removeObserver(this)
-        } else {
-            observer.onInvalidated(tables)
-        }
-    }
-}
diff --git a/room/room-runtime/src/androidMain/kotlin/androidx/room/RoomDatabase.android.kt b/room/room-runtime/src/androidMain/kotlin/androidx/room/RoomDatabase.android.kt
index e4cff7f..f5d77e9 100644
--- a/room/room-runtime/src/androidMain/kotlin/androidx/room/RoomDatabase.android.kt
+++ b/room/room-runtime/src/androidMain/kotlin/androidx/room/RoomDatabase.android.kt
@@ -23,7 +23,6 @@
 import android.content.Context
 import android.content.Intent
 import android.database.Cursor
-import android.os.Build
 import android.os.CancellationSignal
 import android.os.Looper
 import android.util.Log
@@ -46,6 +45,7 @@
 import androidx.room.util.contains as containsCommon
 import androidx.room.util.findAndInstantiateDatabaseImpl
 import androidx.room.util.findMigrationPath as findMigrationPathExt
+import androidx.room.util.getCoroutineContext
 import androidx.sqlite.SQLiteConnection
 import androidx.sqlite.SQLiteDriver
 import androidx.sqlite.db.SimpleSQLiteQuery
@@ -61,6 +61,7 @@
 import java.util.concurrent.Executor
 import java.util.concurrent.RejectedExecutionException
 import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicBoolean
 import java.util.concurrent.atomic.AtomicInteger
 import kotlin.coroutines.ContinuationInterceptor
 import kotlin.coroutines.CoroutineContext
@@ -75,8 +76,12 @@
 import kotlinx.coroutines.asContextElement
 import kotlinx.coroutines.asCoroutineDispatcher
 import kotlinx.coroutines.asExecutor
+import kotlinx.coroutines.awaitCancellation
 import kotlinx.coroutines.cancel
+import kotlinx.coroutines.channels.awaitClose
 import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.callbackFlow
+import kotlinx.coroutines.launch
 import kotlinx.coroutines.runBlocking
 import kotlinx.coroutines.suspendCancellableCoroutine
 import kotlinx.coroutines.withContext
@@ -496,22 +501,15 @@
         assertNotSuspendingTransaction()
         runBlocking {
             connectionManager.useConnection(isReadOnly = false) { connection ->
-                val supportsDeferForeignKeys = Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP
-                if (hasForeignKeys && !supportsDeferForeignKeys) {
-                    connection.execSQL("PRAGMA foreign_keys = FALSE")
-                }
                 if (!connection.inTransaction()) {
                     invalidationTracker.sync()
                 }
                 connection.withTransaction(Transactor.SQLiteTransactionType.IMMEDIATE) {
-                    if (hasForeignKeys && supportsDeferForeignKeys) {
+                    if (hasForeignKeys) {
                         execSQL("PRAGMA defer_foreign_keys = TRUE")
                     }
                     tableNames.forEach { tableName -> execSQL("DELETE FROM `$tableName`") }
                 }
-                if (hasForeignKeys && !supportsDeferForeignKeys) {
-                    connection.execSQL("PRAGMA foreign_keys = TRUE")
-                }
                 if (!connection.inTransaction()) {
                     connection.execSQL("PRAGMA wal_checkpoint(FULL)")
                     connection.execSQL("VACUUM")
@@ -2061,8 +2059,8 @@
  * The Flow will emit at least one value, a set of all the tables registered for observation to
  * kick-start the stream unless [emitInitialState] is set to `false`.
  *
- * If one of the tables to observe does not exist in the database, this functions throws an
- * [IllegalArgumentException].
+ * If one of the tables to observe does not exist in the database, this Flow throws an
+ * [IllegalArgumentException] during collection.
  *
  * The returned Flow can be used to create a stream that reacts to changes in the database:
  * ```
@@ -2079,11 +2077,37 @@
  * @param emitInitialState Set to `false` if no initial emission is desired. Default value is
  *   `true`.
  */
-@Deprecated(
-    message = "Replaced by equivalent API in InvalidationTracker.",
-    replaceWith = ReplaceWith("this.invalidationTracker.createFlow(*tables)")
-)
 fun RoomDatabase.invalidationTrackerFlow(
     vararg tables: String,
     emitInitialState: Boolean = true
-): Flow<Set<String>> = invalidationTracker.createFlow(*tables, emitInitialState = emitInitialState)
+): Flow<Set<String>> = callbackFlow {
+    // Flag to ignore invalidation until the initial state is sent.
+    val ignoreInvalidation = AtomicBoolean(emitInitialState)
+    val observer =
+        object : InvalidationTracker.Observer(tables) {
+            override fun onInvalidated(tables: Set<String>) {
+                if (ignoreInvalidation.get()) {
+                    return
+                }
+                trySend(tables)
+            }
+        }
+    // Use the database context, minus the Job since the ProducerScope has one already and the
+    // child coroutine should be tied to it.
+    val queryContext = getCoroutineContext(inTransaction = false).minusKey(Job)
+    val job =
+        launch(queryContext) {
+            invalidationTracker.addObserver(observer)
+            try {
+                if (emitInitialState) {
+                    // Initial invalidation of all tables, to kick-start the flow
+                    trySend(tables.toSet())
+                }
+                ignoreInvalidation.set(false)
+                awaitCancellation()
+            } finally {
+                invalidationTracker.removeObserver(observer)
+            }
+        }
+    awaitClose { job.cancel() }
+}
diff --git a/room/room-runtime/src/androidMain/kotlin/androidx/room/RoomSQLiteQuery.android.kt b/room/room-runtime/src/androidMain/kotlin/androidx/room/RoomSQLiteQuery.android.kt
index b0a6438..a423a69 100644
--- a/room/room-runtime/src/androidMain/kotlin/androidx/room/RoomSQLiteQuery.android.kt
+++ b/room/room-runtime/src/androidMain/kotlin/androidx/room/RoomSQLiteQuery.android.kt
@@ -78,6 +78,11 @@
         }
     }
 
+    /** Converts a SupportSQLiteStatement to a [RoomRawQuery]. */
+    fun toRoomRawQuery(): RoomRawQuery {
+        return RoomRawQuery(sql = this.sql, onBindStatement = { this.bindTo(it) })
+    }
+
     override val sql: String
         get() = checkNotNull(this.query)
 
diff --git a/room/room-runtime/src/androidMain/kotlin/androidx/room/RoomTrackingLiveData.android.kt b/room/room-runtime/src/androidMain/kotlin/androidx/room/RoomTrackingLiveData.android.kt
index 86740bd..0deb550 100644
--- a/room/room-runtime/src/androidMain/kotlin/androidx/room/RoomTrackingLiveData.android.kt
+++ b/room/room-runtime/src/androidMain/kotlin/androidx/room/RoomTrackingLiveData.android.kt
@@ -66,7 +66,13 @@
 
     private suspend fun refresh() {
         if (registeredObserver.compareAndSet(false, true)) {
-            database.invalidationTracker.addWeakObserver(observer)
+            database.invalidationTracker.subscribe(
+                InvalidationTracker.WeakObserver(
+                    database.invalidationTracker,
+                    database.getCoroutineScope(),
+                    observer
+                )
+            )
         }
         var computed: Boolean
         do {
diff --git a/room/room-runtime/src/androidMain/kotlin/androidx/room/util/TableInfo.android.kt b/room/room-runtime/src/androidMain/kotlin/androidx/room/util/TableInfo.android.kt
index 8950000..bc9b664 100644
--- a/room/room-runtime/src/androidMain/kotlin/androidx/room/util/TableInfo.android.kt
+++ b/room/room-runtime/src/androidMain/kotlin/androidx/room/util/TableInfo.android.kt
@@ -15,7 +15,6 @@
  */
 package androidx.room.util
 
-import android.os.Build
 import androidx.annotation.IntDef
 import androidx.annotation.RestrictTo
 import androidx.room.ColumnInfo.SQLiteTypeAffinity
@@ -207,13 +206,3 @@
         actual override fun toString() = toStringCommon()
     }
 }
-
-/** Checks if the primary key match. */
-internal actual fun TableInfo.Column.equalsInPrimaryKey(other: TableInfo.Column): Boolean {
-    if (Build.VERSION.SDK_INT >= 20) {
-        if (primaryKeyPosition != other.primaryKeyPosition) return false
-    } else {
-        if (isPrimaryKey != other.isPrimaryKey) return false
-    }
-    return true
-}
diff --git a/room/room-runtime/src/androidUnitTest/kotlin/androidx/room/InvalidationTrackerTest.kt b/room/room-runtime/src/androidUnitTest/kotlin/androidx/room/InvalidationTrackerTest.kt
index 0b52a4a..098a7ce 100644
--- a/room/room-runtime/src/androidUnitTest/kotlin/androidx/room/InvalidationTrackerTest.kt
+++ b/room/room-runtime/src/androidUnitTest/kotlin/androidx/room/InvalidationTrackerTest.kt
@@ -17,8 +17,9 @@
 
 import android.os.Build
 import androidx.annotation.RequiresApi
+import androidx.arch.core.executor.ArchTaskExecutor
+import androidx.arch.core.executor.testing.CountingTaskExecutorRule
 import androidx.kruth.assertThat
-import androidx.kruth.assertThrows
 import androidx.sqlite.SQLiteConnection
 import androidx.sqlite.SQLiteDriver
 import androidx.sqlite.SQLiteStatement
@@ -27,21 +28,18 @@
 import java.util.concurrent.CountDownLatch
 import java.util.concurrent.TimeUnit
 import kotlin.collections.removeFirst as removeFirstKt
+import kotlin.test.assertFailsWith
 import kotlinx.atomicfu.atomic
-import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.TimeoutCancellationException
-import kotlinx.coroutines.cancelAndJoin
 import kotlinx.coroutines.delay
-import kotlinx.coroutines.launch
 import kotlinx.coroutines.runBlocking
-import kotlinx.coroutines.sync.Mutex
-import kotlinx.coroutines.sync.withLock
-import kotlinx.coroutines.test.TestScope
 import kotlinx.coroutines.test.runTest
 import kotlinx.coroutines.withTimeout
 import org.junit.After
 import org.junit.AssumptionViolatedException
 import org.junit.Before
+import org.junit.Ignore
+import org.junit.Rule
 import org.junit.Test
 import org.junit.runner.RunWith
 import org.junit.runners.JUnit4
@@ -50,12 +48,12 @@
 @RunWith(JUnit4::class)
 class InvalidationTrackerTest {
 
-    private val testCoroutineScope = TestScope()
-
     private lateinit var tracker: InvalidationTracker
     private lateinit var sqliteDriver: FakeSQLiteDriver
     private lateinit var roomDatabase: FakeRoomDatabase
 
+    @get:Rule val taskExecutorRule = CountingTaskExecutorRule()
+
     @Before
     @RequiresApi(Build.VERSION_CODES.LOLLIPOP)
     fun setup() {
@@ -78,8 +76,8 @@
                 callbacks = null,
                 allowMainThreadQueries = true,
                 journalMode = RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING,
-                queryExecutor = { error("Should never be called") },
-                transactionExecutor = { error("Should never be called") },
+                queryExecutor = ArchTaskExecutor.getIOThreadExecutor(),
+                transactionExecutor = ArchTaskExecutor.getIOThreadExecutor(),
                 multiInstanceInvalidationServiceIntent = null,
                 requireMigration = true,
                 allowDestructiveMigrationOnDowngrade = false,
@@ -92,7 +90,7 @@
                 autoMigrationSpecs = emptyList(),
                 allowDestructiveMigrationForAllTables = false,
                 sqliteDriver = sqliteDriver,
-                queryCoroutineContext = testCoroutineScope.coroutineContext,
+                queryCoroutineContext = null,
             )
         )
         tracker = roomDatabase.invalidationTracker
@@ -101,16 +99,17 @@
     @After
     fun after() {
         Locale.setDefault(Locale.US)
+
+        taskExecutorRule.drainTasks(1, TimeUnit.SECONDS)
+        assertThat(taskExecutorRule.isIdle).isTrue()
     }
 
     @Test
     fun observerWithNoExistingTable() = runTest {
-        assertThrows<IllegalArgumentException> {
-                val observer: InvalidationTracker.Observer = LatchObserver(1, "x")
-                tracker.addObserver(observer)
-            }
-            .hasMessageThat()
-            .isEqualTo("There is no table with name x")
+        assertFailsWith<IllegalArgumentException>(message = "There is no table with name x") {
+            val observer: InvalidationTracker.Observer = LatchObserver(1, "x")
+            tracker.subscribe(observer)
+        }
     }
 
     @Test
@@ -122,7 +121,7 @@
     @Test
     fun observeOneTable() = runTest {
         val observer = LatchObserver(1, "a")
-        tracker.addObserver(observer)
+        tracker.subscribe(observer)
 
         // Mark 'a' as invalidated and expect a notification
         sqliteDriver.setInvalidatedTables(0)
@@ -148,7 +147,7 @@
     @Test
     fun observeTwoTables() = runTest {
         val observer = LatchObserver(1, "A", "B")
-        tracker.addObserver(observer)
+        tracker.subscribe(observer)
 
         // Mark 'a' and 'B' as invalidated and expect a notification
         sqliteDriver.setInvalidatedTables(0, 1)
@@ -182,7 +181,7 @@
     @Test
     fun observeFtsTable() = runTest {
         val observer = LatchObserver(1, "C")
-        tracker.addObserver(observer)
+        tracker.subscribe(observer)
 
         // Mark 'C' as invalidated and expect a notification
         sqliteDriver.setInvalidatedTables(3)
@@ -208,7 +207,7 @@
     @Test
     fun observeExternalContentFtsTable() = runTest {
         val observer = LatchObserver(1, "d")
-        tracker.addObserver(observer)
+        tracker.subscribe(observer)
 
         // Mark 'a' as invalidated and expect a notification, 'a' is the content table of 'd'
         sqliteDriver.setInvalidatedTables(0)
@@ -234,7 +233,7 @@
     @Test
     fun observeExternalContentFtsTableAndContentTable() = runTest {
         val observer = LatchObserver(1, "d", "a")
-        tracker.addObserver(observer)
+        tracker.subscribe(observer)
 
         // Mark 'a' as invalidated and expect a notification of both 'a' and 'd' since 'd' is
         // backed by 'a'
@@ -262,8 +261,8 @@
     fun observeExternalContentFatsTableAndContentTableSeparately() = runTest {
         val observerA = LatchObserver(1, "a")
         val observerD = LatchObserver(1, "d")
-        tracker.addObserver(observerA)
-        tracker.addObserver(observerD)
+        tracker.subscribe(observerA)
+        tracker.subscribe(observerD)
 
         // Mark 'a' as invalidated and expect a notification of both 'a' and 'd' since 'a' is
         // the content table for 'd'
@@ -292,7 +291,7 @@
     @Test
     fun observeView() = runTest {
         val observer = LatchObserver(1, "E")
-        tracker.addObserver(observer)
+        tracker.subscribe(observer)
 
         // Mark 'a' and 'B' as invalidated and expect a notification, the view 'E' is backed by 'a'
         sqliteDriver.setInvalidatedTables(0, 1)
@@ -316,13 +315,14 @@
     }
 
     @Test
+    @Ignore // b/349880963
     fun multipleRefreshAsync() = runTest {
         // Validate that when multiple refresh are enqueued, that only one runs.
         tracker.refreshAsync()
         tracker.refreshAsync()
         tracker.refreshAsync()
 
-        testScheduler.advanceUntilIdle()
+        taskExecutorRule.drainTasks(1, TimeUnit.SECONDS)
 
         assertThat(sqliteDriver.preparedQueries.filter { it == SELECT_INVALIDATED_QUERY })
             .hasSize(1)
@@ -358,7 +358,7 @@
         )
         sqliteDriver.setInvalidatedTables(0)
         tracker.refreshAsync()
-        testScheduler.advanceUntilIdle()
+        taskExecutorRule.drainTasks(200, TimeUnit.MILLISECONDS)
         invalidatedLatch.await()
         roomDatabase.close()
         assertThat(invalidated.value).isTrue()
@@ -371,7 +371,8 @@
         val triggers = listOf("INSERT", "UPDATE", "DELETE")
 
         val observer = LatchObserver(1, "a")
-        tracker.addObserver(observer)
+        tracker.subscribe(observer)
+        tracker.sync()
 
         // Verifies the 'invalidated' column is reset when tracking starts
         assertThat(sqliteDriver.preparedQueries)
@@ -388,7 +389,8 @@
                 )
         }
 
-        tracker.removeObserver(observer)
+        tracker.unsubscribe(observer)
+        tracker.sync()
         triggers.forEach { trigger ->
             assertThat(sqliteDriver.preparedQueries)
                 .contains("DROP TRIGGER IF EXISTS `room_table_modification_trigger_a_$trigger`")
@@ -402,7 +404,8 @@
         val triggers = listOf("INSERT", "UPDATE", "DELETE")
 
         val observer = LatchObserver(1, "C")
-        tracker.addObserver(observer)
+        tracker.subscribe(observer)
+        tracker.sync()
 
         // Verifies the 'invalidated' column is reset when tracking starts
         assertThat(sqliteDriver.preparedQueries)
@@ -420,7 +423,8 @@
                 )
         }
 
-        tracker.removeObserver(observer)
+        tracker.unsubscribe(observer)
+        tracker.sync()
         // Validates trigger are removed when tracking stops
         triggers.forEach { trigger ->
             assertThat(sqliteDriver.preparedQueries)
@@ -431,21 +435,11 @@
     }
 
     @Test
-    fun createFlowWithNoExistingTable() {
-        // Validate that sending a bad createFlow table name fails quickly
-        assertThrows<IllegalArgumentException> { tracker.createFlow(tables = arrayOf("x")) }
-            .hasMessageThat()
-            .isEqualTo("There is no table with name x")
-    }
-
-    @Test
     fun createLiveDataWithNoExistingTable() {
         // Validate that sending a bad createLiveData table name fails quickly
-        assertThrows<IllegalArgumentException> {
-                tracker.createLiveData(tableNames = arrayOf("x"), inTransaction = false) {}
-            }
-            .hasMessageThat()
-            .isEqualTo("There is no table with name x")
+        assertFailsWith<IllegalArgumentException>(message = "There is no table with name x") {
+            tracker.createLiveData(tableNames = arrayOf("x"), inTransaction = false) {}
+        }
     }
 
     @Test
@@ -514,70 +508,9 @@
         assertThat(invalidated.value).isEqualTo(1)
     }
 
-    @Test
-    fun flowObserver() = runTest {
-        // Note: This tests validate triggers that are an impl (but important)
-        // detail of the tracker, but in theory this is already covered by tests with observers
-        val triggers = listOf("INSERT", "UPDATE", "DELETE")
-
-        val flow = tracker.createFlow("a")
-        testScheduler.advanceUntilIdle()
-
-        // Validate just creating a flow will not install triggers (they are cold).
-        assertThat(sqliteDriver.preparedQueries).isEmpty()
-
-        val initialCollectLatch = Mutex(locked = true)
-        val collectJob =
-            backgroundScope.launch(Dispatchers.IO) {
-                // Collect forever in the background, we'll cancel it soon and assert on cleanup
-                flow.collect { initialCollectLatch.unlock() }
-            }
-
-        // Wait at least for one emission
-        testScheduler.advanceUntilIdle()
-        initialCollectLatch.withLock {}
-
-        // Verifies triggers created for flow table
-        triggers.forEach { trigger ->
-            assertThat(sqliteDriver.preparedQueries)
-                .contains(
-                    "CREATE TEMP TRIGGER IF NOT EXISTS " +
-                        "`room_table_modification_trigger_a_$trigger` " +
-                        "AFTER $trigger ON `a` BEGIN UPDATE " +
-                        "room_table_modification_log SET invalidated = 1 WHERE table_id = 0 " +
-                        "AND invalidated = 0; END"
-                )
-        }
-
-        // Cancel flow collection
-        collectJob.cancelAndJoin()
-        // Due do quick cancellation, flows won't sync triggers immediately after marking tables
-        // no longer needed to be observed, hence the need to sync() here manually. In practice
-        // this is fine because new flows, observers or write operations sync triggers.
-        tracker.sync()
-        // Validates trigger are removed when observing stops and triggers are synced
-        triggers.forEach { trigger ->
-            assertThat(sqliteDriver.preparedQueries)
-                .contains("DROP TRIGGER IF EXISTS `room_table_modification_trigger_a_$trigger`")
-        }
-    }
-
-    private fun runTest(testBody: suspend TestScope.() -> Unit) =
-        testCoroutineScope.runTest {
-            testBody.invoke(this)
-            roomDatabase.close()
-        }
-
-    /**
-     * Start invalidation async and await for it to be done.
-     *
-     * This is used as opposed so [InvalidationTracker.refresh] to validate the async things and
-     * because only the sync versions expect at-least one call to the async one to flush
-     * invalidation.
-     */
     private fun InvalidationTracker.awaitRefreshAsync() {
         refreshAsync()
-        testCoroutineScope.testScheduler.advanceUntilIdle()
+        taskExecutorRule.drainTasks(200, TimeUnit.MILLISECONDS)
     }
 
     private class LatchObserver(count: Int, vararg tableNames: String) :
diff --git a/room/room-runtime/src/commonMain/kotlin/androidx/room/InvalidationTracker.kt b/room/room-runtime/src/commonMain/kotlin/androidx/room/InvalidationTracker.kt
index 643d0e7..bd5d28e 100644
--- a/room/room-runtime/src/commonMain/kotlin/androidx/room/InvalidationTracker.kt
+++ b/room/room-runtime/src/commonMain/kotlin/androidx/room/InvalidationTracker.kt
@@ -17,34 +17,28 @@
 package androidx.room
 
 import androidx.annotation.RestrictTo
+import androidx.room.InvalidationTracker.Observer
 import androidx.room.Transactor.SQLiteTransactionType
 import androidx.room.concurrent.ifNotClosed
-import androidx.room.util.getCoroutineContext
 import androidx.sqlite.SQLiteConnection
 import androidx.sqlite.SQLiteException
 import androidx.sqlite.execSQL
 import androidx.sqlite.use
-import kotlin.jvm.JvmOverloads
 import kotlin.jvm.JvmSuppressWildcards
 import kotlinx.atomicfu.atomic
 import kotlinx.atomicfu.locks.reentrantLock
 import kotlinx.atomicfu.locks.withLock
-import kotlinx.coroutines.CoroutineName
-import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.FlowCollector
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.flow
-import kotlinx.coroutines.flow.update
 import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
 
 /**
- * The invalidation tracker keeps track of tables modified by queries and notifies its created
- * [Flow]s about such modifications.
+ * The invalidation tracker keeps track of tables modified by queries and notifies its subscribed
+ * [Observer]s about such modifications.
  *
- * A [Flow] tracking one or more tables can be created via [createFlow]. Once the [Flow] stream
- * starts being collected, if a database operation changes one of the tables that the [Flow] was
- * created from, then such table is considered 'invalidated' and the [Flow] will emit a new value.
+ * [Observer]s contain one or more tables and are added to the tracker via [subscribe]. Once an
+ * observer is subscribed, if a database operation changes one of the tables the observer is
+ * subscribed to, then such table is considered 'invalidated' and [Observer.onInvalidated] will be
+ * invoked on the observer. If an observer is no longer interested in tracking modifications it can
+ * be removed via [unsubscribe].
  */
 expect class InvalidationTracker
 @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
@@ -58,66 +52,80 @@
     internal fun internalInit(connection: SQLiteConnection)
 
     /**
-     * Creates a [Flow] that tracks modifications in the database and emits sets of the tables that
-     * were invalidated.
+     * Subscribes the given [observer] with the tracker such that it is notified if any table it is
+     * interested on changes.
      *
-     * The [Flow] will emit at least one value, a set of all the tables registered for observation
-     * to kick-start the stream unless [emitInitialState] is set to `false`.
+     * If the observer is already subscribed, then this function does nothing.
      *
-     * If one of the tables to observe does not exist in the database, this functions throws an
-     * [IllegalArgumentException].
-     *
-     * The returned [Flow] can be used to create a stream that reacts to changes in the database:
-     * ```
-     * fun getArtistTours(from: Date, to: Date): Flow<Map<Artist, TourState>> {
-     *   return db.invalidationTracker.createFlow("Artist").map { _ ->
-     *     val artists = artistsDao.getAllArtists()
-     *     val tours = tourService.fetchStates(artists.map { it.id })
-     *     associateTours(artists, tours, from, to)
-     *   }
-     * }
-     * ```
-     *
-     * @param tables The name of the tables or views to track.
-     * @param emitInitialState Set to `false` if no initial emission is desired. Default value is
-     *   `true`.
+     * @param observer The observer that will listen for database changes.
+     * @throws IllegalArgumentException if one of the tables in the observer does not exist.
      */
-    @JvmOverloads
-    fun createFlow(vararg tables: String, emitInitialState: Boolean = true): Flow<Set<String>>
+    // TODO(b/329315924): Replace with Flow based API
+    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) suspend fun subscribe(observer: Observer)
 
     /**
-     * Synchronize created [Flow]s with their tables.
+     * Unsubscribes the given [observer] from the tracker.
+     *
+     * If the observer was never subscribed in the first place, then this function does nothing.
+     *
+     * @param observer The observer to remove.
+     */
+    // TODO(b/329315924): Replace with Flow based API
+    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) suspend fun unsubscribe(observer: Observer)
+
+    /**
+     * Synchronize subscribed observers with their tables.
      *
      * This function should be called before any write operation is performed on the database so
-     * that a tracking link is created between the flows and its interested tables.
+     * that a tracking link is created between observers and its interest tables.
      *
      * @see refreshAsync
      */
     internal suspend fun sync()
 
     /**
-     * Refresh created [Flow]s asynchronously, emitting new values on those whose tables have been
-     * invalidated.
+     * Refresh subscribed observers asynchronously, invoking [Observer.onInvalidated] on those whose
+     * tables have been invalidated.
      *
      * This function should be called after any write operation is performed on the database, such
-     * that tracked tables and its associated flows are notified if invalidated. In most cases Room
-     * will call this function automatically but if a write operation is performed on the database
-     * via another connection or through [RoomDatabase.useConnection] you might need to invoke this
-     * function manually to trigger invalidation.
+     * that tracked tables and its associated observers are notified if invalidated.
      */
     fun refreshAsync()
 
-    /**
-     * Non-asynchronous version of [refreshAsync] with the addition that it will return true if
-     * there were any pending invalidations.
-     *
-     * An optional array of tables can be given to validate if any of those tables had pending
-     * invalidations, if so causing this function to return true.
-     */
-    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) suspend fun refresh(vararg tables: String): Boolean
+    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) suspend fun refreshInvalidation()
 
     /** Stops invalidation tracker operations. */
     internal fun stop()
+
+    /**
+     * An observer that can listen for changes in the database by subscribing to an
+     * [InvalidationTracker].
+     *
+     * @param tables The names of the tables this observer is interested in getting notified if they
+     *   are modified.
+     */
+    abstract class Observer(tables: Array<out String>) {
+
+        internal val tables: Array<out String>
+
+        /**
+         * Creates an observer for the given tables and views.
+         *
+         * @param firstTable The name of the table or view.
+         * @param rest More names of tables or views.
+         */
+        protected constructor(firstTable: String, vararg rest: String)
+
+        /**
+         * Invoked when one of the observed tables is invalidated (changed).
+         *
+         * @param tables A set of invalidated tables. When the observer is interested in multiple
+         *   tables, this set can be used to distinguish which of the observed tables were
+         *   invalidated. When observing a database view the names of underlying tables will be in
+         *   the set instead of the view name.
+         */
+        abstract fun onInvalidated(tables: Set<String>)
+    }
 }
 
 /**
@@ -127,14 +135,13 @@
  * * An in-memory table is created with two columns, 'table_id' and 'invalidated' to known which
  *   table has been modified.
  * * [ObservedTableStates] keeps the 'observed' state of each table helping the tracker know which
- *   tables should be watched (via an installed TRIGGER) based on the number of observers
+ *   tables should be watched (via an installed trigger) based on the number of observers
  *   interested.
  * * Before a write transaction, Room will sync triggers by invoking [InvalidationTracker.sync].
  * * If in the write transaction a table was modified, the installed trigger will flip the table's
  *   invalidated column in the in-memory table to ON.
  * * After a write transaction, Room will check the invalidated rows by invoking
- *   [InvalidationTracker.refreshAsync], notifying observers if necessary via the provided
- *   [onInvalidatedTablesIds] callback.
+ *   [InvalidationTracker.refreshAsync], notifying observers if necessary.
  */
 internal class TriggerBasedInvalidationTracker(
     private val database: RoomDatabase,
@@ -142,18 +149,16 @@
     private val shadowTablesMap: Map<String, String>,
     // View to underlying table names
     private val viewTables: Map<String, Set<String>>,
-    tableNames: Array<out String>,
-    // Callback function for when a set of tables are invalidated, the 'id' of a table is its
-    // index in the given `tableNames`
-    private val onInvalidatedTablesIds: (Set<Int>) -> Unit
+    tableNames: Array<out String>
 ) {
     /** Table name (lowercase) to index (id) in [tablesNames], used as a quick lookup map. */
     private val tableIdLookup: Map<String, Int>
     /** Table names (lowercase), the index is at which a table is in the array is its 'id'. */
     private val tablesNames: Array<String>
 
+    private val observerMap: MutableMap<Observer, ObserverWrapper>
+    private val observerMapLock = reentrantLock()
     private val observedTableStates: ObservedTableStates
-    private val observedTableVersions: ObservedTableVersions
 
     /**
      * Whether there is a pending [refreshInvalidation] to be done or not. Since a refresh can be
@@ -185,8 +190,8 @@
             }
         }
 
+        observerMap = mutableMapOf()
         observedTableStates = ObservedTableStates(tablesNames.size)
-        observedTableVersions = ObservedTableVersions(tablesNames.size)
     }
 
     /**
@@ -210,46 +215,57 @@
         }
     }
 
-    internal fun createFlow(
-        tables: Array<out String>,
-        emitInitialState: Boolean
-    ): Flow<Set<String>> {
-        val (resolvedTableNames, tableIds) = validateTableNames(tables)
-        return flow {
-            val shouldSync = observedTableStates.onObserverAdded(tableIds)
-            if (shouldSync) {
-                // Syncing triggers is a database operation, we use the database context just for
-                // the sync while adhering to flow context preservation.
-                withContext(database.getCoroutineContext(inTransaction = false)) { syncTriggers() }
-            }
-            try {
-                var currentVersions: IntArray? = null
-                observedTableVersions.collect { newVersions ->
-                    if (currentVersions == null) {
-                        // Initial invalidation of all tables, to kick-start the flow
-                        if (emitInitialState) {
-                            emit(resolvedTableNames.toSet())
-                        }
-                    } else {
-                        val invalidatedTablesNames =
-                            resolvedTableNames.filterIndexed { i, _ ->
-                                checkNotNull(currentVersions)[tableIds[i]] !=
-                                    newVersions[tableIds[i]]
-                            }
-                        if (invalidatedTablesNames.isNotEmpty()) {
-                            emit(invalidatedTablesNames.toSet())
-                        }
-                    }
-                    currentVersions = newVersions
-                }
-            } finally {
-                // For quick cancellation, no trigger sync is done. In practice this means
-                // a trigger might be kept longer than needed if database interactions are done
-                // through direct connections, but otherwise continues usage of Room will eventually
-                // cleanup the triggers.
-                observedTableStates.onObserverRemoved(tableIds)
-            }
+    /**
+     * Add an observer, sync triggers and return true if it was actually added, or false if already
+     * added.
+     */
+    internal suspend fun addObserver(observer: Observer): Boolean {
+        val shouldSync = addObserverOnly(observer)
+        if (shouldSync) {
+            syncTriggers()
         }
+        return shouldSync
+    }
+
+    /** Add an observer and return true if it was actually added, or false if already added. */
+    internal fun addObserverOnly(observer: Observer): Boolean {
+        val (resolvedTableNames, tableIds) = validateTableNames(observer.tables)
+        val wrapper =
+            ObserverWrapper(
+                observer = observer,
+                tableIds = tableIds,
+                tableNames = resolvedTableNames
+            )
+
+        val currentObserver =
+            observerMapLock.withLock {
+                if (observerMap.containsKey(observer)) {
+                    observerMap.getValue(observer)
+                } else {
+                    observerMap.put(observer, wrapper)
+                }
+            }
+        return currentObserver == null && observedTableStates.onObserverAdded(tableIds)
+    }
+
+    /**
+     * Removes an observer, sync triggers and return true if it was actually removed, or false if it
+     * was not found.
+     */
+    internal suspend fun removeObserver(observer: Observer): Boolean {
+        val shouldSync = removeObserverOnly(observer)
+        if (shouldSync) {
+            syncTriggers()
+        }
+        return shouldSync
+    }
+
+    /**
+     * Removes an observer and return true if it was actually removed, or false if it was not found.
+     */
+    private fun removeObserverOnly(observer: Observer): Boolean {
+        val wrapper = observerMapLock.withLock { observerMap.remove(observer) }
+        return wrapper != null && observedTableStates.onObserverRemoved(wrapper.tableIds)
     }
 
     /** Resolves the list of tables and views into unique table names and ids. */
@@ -277,13 +293,6 @@
             .toTypedArray()
     }
 
-    /** Notifies that an observer was added and return true if the state of some table changed. */
-    internal fun onObserverAdded(tableIds: IntArray) = observedTableStates.onObserverAdded(tableIds)
-
-    /** Notifies that an observer was removed and return true if the state of some table changed. */
-    internal fun onObserverRemoved(tableIds: IntArray) =
-        observedTableStates.onObserverRemoved(tableIds)
-
     /** Synchronizes database triggers with observed tables. */
     internal suspend fun syncTriggers() =
         database.closeBarrier.ifNotClosed {
@@ -332,49 +341,35 @@
     }
 
     /**
-     * Attempts to notify invalidated trackers if there is a pending refresh. If there is no pending
-     * refresh (no previous call to [refreshInvalidationAsync] then this function does nothing and
-     * returns false.
-     *
-     * An optional array of table names can be provided to check if any of those tables where
-     * invalidated, causing this function to return true if indeed any of them where invalidated.
-     *
-     * If no tables are provided then this function will return true due to any invalidation.
+     * Attempts to notify invalidated observers if there is a pending refresh. If there is no
+     * pending refresh (no previous call to [refreshInvalidationAsync] then this function does
+     * nothing.
      *
      * This can be useful to accelerate a pending refresh instead of waiting for the coroutine to
      * launch.
      */
     internal suspend fun refreshInvalidation(
-        tables: Array<out String>,
         onRefreshScheduled: () -> Unit = {},
         onRefreshCompleted: () -> Unit = {},
-    ): Boolean {
-        val (_, tableIds) = validateTableNames(tables)
+    ) {
         onRefreshScheduled.invoke()
         try {
-            val invalidatesTableIds = notifyInvalidation()
-            return if (tableIds.isNotEmpty()) {
-                tableIds.any { it in invalidatesTableIds }
-            } else {
-                invalidatesTableIds.isNotEmpty()
-            }
+            notifyInvalidatedObservers()
         } finally {
             onRefreshCompleted.invoke()
         }
     }
 
-    /** Launches a coroutine to notify of invalidation. */
+    /** Launches a coroutine to notify invalidated observers. */
     internal fun refreshInvalidationAsync(
         onRefreshScheduled: () -> Unit = {},
         onRefreshCompleted: () -> Unit = {},
     ) {
         if (pendingRefresh.compareAndSet(expect = false, update = true)) {
             onRefreshScheduled.invoke()
-            database.getCoroutineScope().launch(
-                CoroutineName("Room Invalidation Tracker Refresh")
-            ) {
+            database.getCoroutineScope().launch {
                 try {
-                    notifyInvalidation()
+                    notifyInvalidatedObservers()
                 } finally {
                     onRefreshCompleted.invoke()
                 }
@@ -382,26 +377,23 @@
         }
     }
 
-    /**
-     * Checks for invalidates tables and emit notifications, returning true if there where any
-     * invalidated tables or false if there were not.
-     */
-    private suspend fun notifyInvalidation(): Set<Int> {
+    private suspend fun notifyInvalidatedObservers() =
         database.closeBarrier.ifNotClosed {
             if (!pendingRefresh.compareAndSet(expect = true, update = false)) {
                 // No pending refresh
-                return emptySet()
+                return
             }
             if (!onAllowRefresh()) {
                 // Compatibility callback is disallowing a refresh.
-                return emptySet()
+                return
             }
             val invalidatedTableIds =
                 database.useConnection(isReadOnly = false) { connection ->
                     if (connection.inTransaction()) {
                         // Skip refresh if connection is already in a transaction, an indication
-                        // that this is a nested transaction and refresh is expected to be invoked
-                        // after completing a top-level transaction.
+                        // that
+                        // this is a nested transaction and refresh is expected to be invoked after
+                        // completing a top-level transaction.
                         return@useConnection emptySet()
                     }
                     try {
@@ -414,13 +406,9 @@
                     }
                 }
             if (invalidatedTableIds.isNotEmpty()) {
-                observedTableVersions.increment(invalidatedTableIds)
-                onInvalidatedTablesIds.invoke(invalidatedTableIds)
+                notifyInvalidatedTableIds(invalidatedTableIds)
             }
-            return invalidatedTableIds
         }
-        return emptySet()
-    }
 
     /** Checks which tables have been invalidated and resets their invalidation state. */
     private suspend fun checkInvalidatedTables(connection: PooledConnection): Set<Int> {
@@ -438,6 +426,25 @@
         return invalidatedTableIds
     }
 
+    private fun notifyInvalidatedTableIds(tableIds: Set<Int>) {
+        observerMapLock.withLock { observerMap.values.forEach { it.notifyByTableIds(tableIds) } }
+    }
+
+    internal fun notifyInvalidatedTableNames(
+        tableNames: Set<String>,
+        filterPredicate: (Observer) -> Boolean = { true }
+    ) {
+        observerMapLock.withLock {
+            observerMap.values.forEach {
+                if (filterPredicate(it.observer)) {
+                    it.notifyByTableNames(tableNames)
+                }
+            }
+        }
+    }
+
+    internal fun getAllObservers() = observerMapLock.withLock { observerMap.keys.toList() }
+
     internal fun resetSync() {
         observedTableStates.resetTriggerState()
     }
@@ -555,31 +562,68 @@
 }
 
 /**
- * Keeps an ever incrementing version of each table as they get invalidated.
+ * Wraps an [Observer] and keeps the table information.
  *
- * The versions value is not persistent and its true meaning can be better described as the amount
- * of detected invalidations for a given table, it is solely used to drive invalidation of [Flow]s.
+ * Internally table ids are used which may change from database to database so the table related
+ * information is kept here rather than in the actual observer.
  */
-internal class ObservedTableVersions(size: Int) {
-    /* Table versions, the index at which a version is in the array is the table 'id'. */
-    private val versions = MutableStateFlow(IntArray(size))
+internal class ObserverWrapper(
+    internal val observer: Observer,
+    internal val tableIds: IntArray,
+    private val tableNames: Array<out String>
+) {
+    init {
+        check(tableIds.size == tableNames.size)
+    }
 
-    /** Increment the version of the given table ids, causing flow emissions on any collectors. */
-    fun increment(tableIds: Set<Int>) {
-        if (tableIds.isEmpty()) {
-            return
-        }
-        versions.update { currentVersions ->
-            IntArray(currentVersions.size) { id ->
-                if (id in tableIds) {
-                    currentVersions[id] + 1
-                } else {
-                    currentVersions[id]
-                }
+    // Optimization for a single-table observer
+    private val singleTableSet = if (tableNames.isNotEmpty()) setOf(tableNames[0]) else emptySet()
+
+    internal fun notifyByTableIds(invalidatedTablesIds: Set<Int>) {
+        val invalidatedTables =
+            when (tableIds.size) {
+                0 -> emptySet()
+                1 -> if (invalidatedTablesIds.contains(tableIds[0])) singleTableSet else emptySet()
+                else ->
+                    buildSet {
+                        tableIds.forEachIndexed { id, tableId ->
+                            if (invalidatedTablesIds.contains(tableId)) {
+                                add(tableNames[id])
+                            }
+                        }
+                    }
             }
+        if (invalidatedTables.isNotEmpty()) {
+            observer.onInvalidated(invalidatedTables)
         }
     }
 
-    /** Equivalent to [Flow.collect] specifically for the table versions. */
-    suspend fun collect(collector: FlowCollector<IntArray>): Nothing = versions.collect(collector)
+    internal fun notifyByTableNames(invalidatedTablesNames: Set<String>) {
+        val invalidatedTables =
+            when (tableNames.size) {
+                0 -> emptySet()
+                1 ->
+                    if (
+                        invalidatedTablesNames.any { it.equals(tableNames[0], ignoreCase = true) }
+                    ) {
+                        singleTableSet
+                    } else {
+                        emptySet()
+                    }
+                else ->
+                    buildSet {
+                        invalidatedTablesNames.forEach { table ->
+                            for (ourTable in tableNames) {
+                                if (ourTable.equals(table, ignoreCase = true)) {
+                                    add(ourTable)
+                                    break
+                                }
+                            }
+                        }
+                    }
+            }
+        if (invalidatedTables.isNotEmpty()) {
+            observer.onInvalidated(invalidatedTables)
+        }
+    }
 }
diff --git a/room/room-runtime/src/commonMain/kotlin/androidx/room/coroutines/FlowBuilder.kt b/room/room-runtime/src/commonMain/kotlin/androidx/room/coroutines/FlowBuilder.kt
index 3fdf0d1..a6a3f66 100644
--- a/room/room-runtime/src/commonMain/kotlin/androidx/room/coroutines/FlowBuilder.kt
+++ b/room/room-runtime/src/commonMain/kotlin/androidx/room/coroutines/FlowBuilder.kt
@@ -19,21 +19,57 @@
 package androidx.room.coroutines
 
 import androidx.annotation.RestrictTo
+import androidx.room.InvalidationTracker
 import androidx.room.RoomDatabase
-import androidx.room.util.performSuspending
+import androidx.room.util.getCoroutineContext
+import androidx.room.util.internalPerform
 import androidx.sqlite.SQLiteConnection
 import kotlin.jvm.JvmName
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.coroutineScope
 import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.conflate
-import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.emitAll
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.launch
 
+// TODO(b/329315924): Migrate to Flow based machinery.
 @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
 fun <R> createFlow(
     db: RoomDatabase,
     inTransaction: Boolean,
     tableNames: Array<String>,
     block: (SQLiteConnection) -> R
-): Flow<R> =
-    db.invalidationTracker.createFlow(*tableNames, emitInitialState = true).conflate().map {
-        performSuspending(db, true, inTransaction, block)
+): Flow<R> = flow {
+    coroutineScope {
+        // Observer channel receives signals from the invalidation tracker to emit queries.
+        val observerChannel = Channel<Unit>(Channel.CONFLATED)
+        val observer =
+            object : InvalidationTracker.Observer(tableNames) {
+                override fun onInvalidated(tables: Set<String>) {
+                    observerChannel.trySend(Unit)
+                }
+            }
+        observerChannel.trySend(Unit) // Initial signal to perform first query.
+        val resultChannel = Channel<R>()
+        launch(db.getCoroutineContext(inTransaction).minusKey(Job)) {
+            db.invalidationTracker.subscribe(observer)
+            try {
+                // Iterate until cancelled, transforming observer signals to query results
+                // to be emitted to the flow.
+                for (signal in observerChannel) {
+                    val result =
+                        db.internalPerform(true, inTransaction) { connection ->
+                            val rawConnection = (connection as RawConnectionAccessor).rawConnection
+                            block.invoke(rawConnection)
+                        }
+                    resultChannel.send(result)
+                }
+            } finally {
+                db.invalidationTracker.unsubscribe(observer)
+            }
+        }
+
+        emitAll(resultChannel)
     }
+}
diff --git a/room/room-runtime/src/commonMain/kotlin/androidx/room/util/TableInfo.kt b/room/room-runtime/src/commonMain/kotlin/androidx/room/util/TableInfo.kt
index 9f41f2d6..feb7d93 100644
--- a/room/room-runtime/src/commonMain/kotlin/androidx/room/util/TableInfo.kt
+++ b/room/room-runtime/src/commonMain/kotlin/androidx/room/util/TableInfo.kt
@@ -201,7 +201,7 @@
 internal fun TableInfo.Column.equalsCommon(other: Any?): Boolean {
     if (this === other) return true
     if (other !is TableInfo.Column) return false
-    if (!equalsInPrimaryKey(other)) return false
+    if (isPrimaryKey != other.isPrimaryKey) return false
     if (name != other.name) return false
     if (notNull != other.notNull) return false
     // Only validate default value if it was defined in an entity, i.e. if the info
@@ -231,9 +231,6 @@
     return affinity == other.affinity
 }
 
-/** Checks if the primary key match. */
-internal expect fun TableInfo.Column.equalsInPrimaryKey(other: TableInfo.Column): Boolean
-
 /**
  * Checks if the default values provided match. Handles the special case in which the default value
  * is surrounded by parenthesis (e.g. encountered in b/182284899).
diff --git a/room/room-runtime/src/jvmNativeMain/kotlin/androidx/room/InvalidationTracker.jvmNative.kt b/room/room-runtime/src/jvmNativeMain/kotlin/androidx/room/InvalidationTracker.jvmNative.kt
index 730036b..b1fee15 100644
--- a/room/room-runtime/src/jvmNativeMain/kotlin/androidx/room/InvalidationTracker.jvmNative.kt
+++ b/room/room-runtime/src/jvmNativeMain/kotlin/androidx/room/InvalidationTracker.jvmNative.kt
@@ -17,34 +17,29 @@
 package androidx.room
 
 import androidx.annotation.RestrictTo
+import androidx.room.InvalidationTracker.Observer
 import androidx.sqlite.SQLiteConnection
-import kotlin.jvm.JvmOverloads
-import kotlinx.coroutines.flow.Flow
 
 /**
- * The invalidation tracker keeps track of tables modified by queries and notifies its created
- * [Flow]s about such modifications.
+ * The invalidation tracker keeps track of tables modified by queries and notifies its subscribed
+ * [Observer]s about such modifications.
  *
- * A [Flow] tracking one or more tables can be created via [createFlow]. Once the [Flow] stream
- * starts being collected, if a database operation changes one of the tables that the [Flow] was
- * created from, then such table is considered 'invalidated' and the [Flow] will emit a new value.
+ * [Observer]s contain one or more tables and are added to the tracker via [subscribe]. Once an
+ * observer is subscribed, if a database operation changes one of the tables the observer is
+ * subscribed to, then such table is considered 'invalidated' and [Observer.onInvalidated] will be
+ * invoked on the observer. If an observer is no longer interested in tracking modifications it can
+ * be removed via [unsubscribe].
  */
 actual class InvalidationTracker
 @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
 actual constructor(
-    private val database: RoomDatabase,
+    database: RoomDatabase,
     shadowTablesMap: Map<String, String>,
     viewTables: Map<String, Set<String>>,
     vararg tableNames: String
 ) {
     private val implementation =
-        TriggerBasedInvalidationTracker(
-            database = database,
-            shadowTablesMap = shadowTablesMap,
-            viewTables = viewTables,
-            tableNames = tableNames,
-            onInvalidatedTablesIds = {}
-        )
+        TriggerBasedInvalidationTracker(database, shadowTablesMap, viewTables, tableNames)
 
     /** Internal method to initialize table tracking. Invoked by generated code. */
     internal actual fun internalInit(connection: SQLiteConnection) {
@@ -52,40 +47,38 @@
     }
 
     /**
-     * Creates a [Flow] that tracks modifications in the database and emits sets of the tables that
-     * were invalidated.
+     * Subscribes the given [observer] with the tracker such that it is notified if any table it is
+     * interested on changes.
      *
-     * The [Flow] will emit at least one value, a set of all the tables registered for observation
-     * to kick-start the stream unless [emitInitialState] is set to `false`.
+     * If the observer is already subscribed, then this function does nothing.
      *
-     * If one of the tables to observe does not exist in the database, this functions throws an
-     * [IllegalArgumentException].
-     *
-     * The returned [Flow] can be used to create a stream that reacts to changes in the database:
-     * ```
-     * fun getArtistTours(from: Date, to: Date): Flow<Map<Artist, TourState>> {
-     *   return db.invalidationTracker.createFlow("Artist").map { _ ->
-     *     val artists = artistsDao.getAllArtists()
-     *     val tours = tourService.fetchStates(artists.map { it.id })
-     *     associateTours(artists, tours, from, to)
-     *   }
-     * }
-     * ```
-     *
-     * @param tables The name of the tables or views to track.
-     * @param emitInitialState Set to `false` if no initial emission is desired. Default value is
-     *   `true`.
+     * @param observer The observer that will listen for database changes.
+     * @throws IllegalArgumentException if one of the tables in the observer does not exist.
      */
-    @JvmOverloads
-    actual fun createFlow(vararg tables: String, emitInitialState: Boolean): Flow<Set<String>> {
-        return implementation.createFlow(tables, emitInitialState)
+    // TODO(b/329315924): Replace with Flow based API
+    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+    actual suspend fun subscribe(observer: Observer) {
+        implementation.addObserver(observer)
     }
 
     /**
-     * Synchronize created [Flow]s with their tables.
+     * Unsubscribes the given [observer] from the tracker.
+     *
+     * If the observer was never subscribed in the first place, then this function does nothing.
+     *
+     * @param observer The observer to remove.
+     */
+    // TODO(b/329315924): Replace with Flow based API
+    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+    actual suspend fun unsubscribe(observer: Observer) {
+        implementation.removeObserver(observer)
+    }
+
+    /**
+     * Synchronize subscribed observers with their tables.
      *
      * This function should be called before any write operation is performed on the database so
-     * that a tracking link is created between the flows and its interested tables.
+     * that a tracking link is created between observers and its interest tables.
      *
      * @see refreshAsync
      */
@@ -94,31 +87,52 @@
     }
 
     /**
-     * Refresh created [Flow]s asynchronously, emitting new values on those whose tables have been
-     * invalidated.
+     * Refresh subscribed observers asynchronously, invoking [Observer.onInvalidated] on those whose
+     * tables have been invalidated.
      *
      * This function should be called after any write operation is performed on the database, such
-     * that tracked tables and its associated flows are notified if invalidated. In most cases Room
-     * will call this function automatically but if a write operation is performed on the database
-     * via another connection or through [RoomDatabase.useConnection] you might need to invoke this
-     * function to trigger invalidation.
+     * that tracked tables and its associated observers are notified if invalidated.
      */
     actual fun refreshAsync() {
         implementation.refreshInvalidationAsync()
     }
 
-    /**
-     * Non-asynchronous version of [refreshAsync] with the addition that it will return true if
-     * there were any pending invalidations.
-     *
-     * An optional array of tables can be given to validate if any of those tables had pending
-     * invalidations, if so causing this function to return true.
-     */
     @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
-    actual suspend fun refresh(vararg tables: String): Boolean {
-        return implementation.refreshInvalidation(tables)
+    actual suspend fun refreshInvalidation() {
+        implementation.refreshInvalidation()
     }
 
     /** Stops invalidation tracker operations. */
-    internal actual fun stop() {}
+    actual fun stop() {}
+
+    /**
+     * An observer that can listen for changes in the database by subscribing to an
+     * [InvalidationTracker].
+     *
+     * @param tables The names of the tables this observer is interested in getting notified if they
+     *   are modified.
+     */
+    actual abstract class Observer
+    actual constructor(internal actual val tables: Array<out String>) {
+        /**
+         * Creates an observer for the given tables and views.
+         *
+         * @param firstTable The name of the table or view.
+         * @param rest More names of tables or views.
+         */
+        protected actual constructor(
+            firstTable: String,
+            vararg rest: String
+        ) : this(arrayOf(firstTable, *rest))
+
+        /**
+         * Invoked when one of the observed tables is invalidated (changed).
+         *
+         * @param tables A set of invalidated tables. When the observer is interested in multiple
+         *   tables, this set can be used to distinguish which of the observed tables were
+         *   invalidated. When observing a database view the names of underlying tables will be in
+         *   the set instead of the view name.
+         */
+        actual abstract fun onInvalidated(tables: Set<String>)
+    }
 }
diff --git a/room/room-runtime/src/jvmNativeMain/kotlin/androidx/room/util/TableInfo.jvmNative.kt b/room/room-runtime/src/jvmNativeMain/kotlin/androidx/room/util/TableInfo.jvmNative.kt
index 20a2a9d..0e2f13d 100644
--- a/room/room-runtime/src/jvmNativeMain/kotlin/androidx/room/util/TableInfo.jvmNative.kt
+++ b/room/room-runtime/src/jvmNativeMain/kotlin/androidx/room/util/TableInfo.jvmNative.kt
@@ -156,8 +156,3 @@
         actual override fun toString() = toStringCommon()
     }
 }
-
-/** Checks if the primary key match. */
-internal actual fun TableInfo.Column.equalsInPrimaryKey(other: TableInfo.Column): Boolean {
-    return isPrimaryKey == other.isPrimaryKey
-}
diff --git a/settings.gradle b/settings.gradle
index 26a3406..210612c 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -632,6 +632,7 @@
 includeProject(":core:core-role", [BuildType.MAIN])
 includeProject(":core:core-telecom", [BuildType.MAIN])
 includeProject(":core:core-telecom:integration-tests:testapp", [BuildType.MAIN])
+includeProject(":core:core-telecom:integration-tests:testicsapp", [BuildType.MAIN])
 includeProject(":core:haptics:haptics", [BuildType.MAIN])
 includeProject(":core:haptics:haptics-samples", "core/haptics/haptics/samples", [BuildType.MAIN])
 includeProject(":core:haptics:haptics-demos", "core/haptics/haptics/integration-tests/demos", [BuildType.MAIN])
@@ -750,6 +751,7 @@
 includeProject(":ink:ink-geometry", [BuildType.MAIN])
 includeProject(":ink:ink-nativeloader", [BuildType.MAIN])
 includeProject(":ink:ink-strokes", [BuildType.MAIN])
+includeProject(":ink:ink-rendering", [BuildType.MAIN])
 includeProject(":input:input-motionprediction", [BuildType.MAIN])
 includeProject(":inspection:inspection", [BuildType.MAIN, BuildType.COMPOSE])
 includeProject(":inspection:inspection-gradle-plugin", [BuildType.MAIN])
diff --git a/testutils/testutils-common/src/main/java/androidx/testutils/FilteringCoroutineContext.kt b/testutils/testutils-common/src/main/java/androidx/testutils/FilteringCoroutineContext.kt
deleted file mode 100644
index e4bcfd4..0000000
--- a/testutils/testutils-common/src/main/java/androidx/testutils/FilteringCoroutineContext.kt
+++ /dev/null
@@ -1,52 +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.testutils
-
-import java.util.concurrent.ExecutorService
-import kotlin.coroutines.CoroutineContext
-import kotlinx.coroutines.CoroutineDispatcher
-
-/**
- * A coroutine dispatcher that can block some known coroutine runnables. We use it to slow down
- * database invalidation events.
- */
-class FilteringCoroutineContext(delegate: ExecutorService) : CoroutineDispatcher() {
-
-    val executor = FilteringExecutor(delegate)
-
-    var filterFunction: (CoroutineContext, Runnable) -> Boolean = { _, _ -> true }
-        set(value) {
-            field = value
-            executor.filterFunction = this::filter
-        }
-
-    private fun filter(runnable: Runnable): Boolean {
-        if (runnable is RunnableWithCoroutineContext) {
-            return filterFunction.invoke(runnable.context, runnable)
-        }
-        return true
-    }
-
-    override fun dispatch(context: CoroutineContext, block: Runnable) {
-        executor.execute(RunnableWithCoroutineContext(context, block))
-    }
-
-    override fun isDispatchNeeded(context: CoroutineContext): Boolean = true
-
-    class RunnableWithCoroutineContext(val context: CoroutineContext, val actual: Runnable) :
-        Runnable by actual
-}
diff --git a/testutils/testutils-common/src/main/java/androidx/testutils/FilteringExecutor.kt b/testutils/testutils-common/src/main/java/androidx/testutils/FilteringExecutor.kt
index 2ee8b53..96f154d 100644
--- a/testutils/testutils-common/src/main/java/androidx/testutils/FilteringExecutor.kt
+++ b/testutils/testutils-common/src/main/java/androidx/testutils/FilteringExecutor.kt
@@ -21,15 +21,17 @@
 import java.util.concurrent.TimeUnit
 import java.util.concurrent.locks.ReentrantLock
 import kotlin.concurrent.withLock
+import kotlinx.coroutines.ExperimentalCoroutinesApi
 import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.filter
 import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.mapLatest
 import kotlinx.coroutines.withTimeout
 
 /**
  * An executor that can block some known runnables. We use it to slow down database invalidation
  * events.
  */
+@OptIn(ExperimentalCoroutinesApi::class)
 class FilteringExecutor(
     private val delegate: ExecutorService = Executors.newSingleThreadExecutor()
 ) : Executor {
@@ -44,7 +46,7 @@
         }
 
     suspend fun awaitDeferredSizeAtLeast(min: Int) = withTestTimeout {
-        deferredSize.filter { it >= min }.first()
+        deferredSize.mapLatest { it >= min }.first()
     }
 
     private fun reEnqueueDeferred() {
diff --git a/tv/tv-foundation/build.gradle b/tv/tv-foundation/build.gradle
index ea396c0..c2bfe40 100644
--- a/tv/tv-foundation/build.gradle
+++ b/tv/tv-foundation/build.gradle
@@ -49,7 +49,7 @@
     api("androidx.compose.ui:ui-graphics:$composeVersion")
     api("androidx.compose.ui:ui-text:$composeVersion")
 
-    implementation("androidx.profileinstaller:profileinstaller:1.4.0-rc01")
+    implementation("androidx.profileinstaller:profileinstaller:1.4.0")
 
     androidTestImplementation(libs.truth)
     androidTestImplementation(project(":compose:runtime:runtime"))
diff --git a/tv/tv-material/build.gradle b/tv/tv-material/build.gradle
index 59df813..f5ae584 100644
--- a/tv/tv-material/build.gradle
+++ b/tv/tv-material/build.gradle
@@ -35,7 +35,7 @@
 
     def annotationVersion = "1.8.0"
     def composeVersion = "1.6.8"
-    def profileInstallerVersion = "1.3.1"
+    def profileInstallerVersion = "1.4.0"
 
     api("androidx.annotation:annotation:1.8.1")
     api("androidx.compose.animation:animation:$composeVersion")
@@ -48,7 +48,7 @@
     api("androidx.compose.ui:ui-graphics:$composeVersion")
     api("androidx.compose.ui:ui-text:$composeVersion")
 
-    implementation("androidx.profileinstaller:profileinstaller:$profileInstallerVersion")
+    implementation("androidx.profileinstaller:profileinstaller:1.4.0")
 
     androidTestImplementation(libs.truth)
     androidTestImplementation(project(":compose:runtime:runtime"))
diff --git a/wear/compose/compose-foundation/build.gradle b/wear/compose/compose-foundation/build.gradle
index 4844a72..c8e5dc70 100644
--- a/wear/compose/compose-foundation/build.gradle
+++ b/wear/compose/compose-foundation/build.gradle
@@ -42,7 +42,7 @@
     implementation("androidx.compose.ui:ui-util:1.7.0")
     implementation("androidx.lifecycle:lifecycle-runtime-compose:2.7.0")
     implementation("androidx.core:core:1.12.0")
-    implementation("androidx.profileinstaller:profileinstaller:1.3.1")
+    implementation("androidx.profileinstaller:profileinstaller:1.4.0")
 
     testImplementation(libs.testRules)
     testImplementation(libs.testRunner)
diff --git a/wear/compose/compose-foundation/src/main/java/androidx/wear/compose/foundation/lazy/ScalingLazyColumn.kt b/wear/compose/compose-foundation/src/main/java/androidx/wear/compose/foundation/lazy/ScalingLazyColumn.kt
index 11f04ab1..e1ba021 100644
--- a/wear/compose/compose-foundation/src/main/java/androidx/wear/compose/foundation/lazy/ScalingLazyColumn.kt
+++ b/wear/compose/compose-foundation/src/main/java/androidx/wear/compose/foundation/lazy/ScalingLazyColumn.kt
@@ -399,9 +399,7 @@
  * [ScalingLazyColumn] should be wrapped by [HierarchicalFocusCoordinator]. By default
  * [HierarchicalFocusCoordinator] is already implemented in [BasicSwipeToDismissBox], which is a
  * part of material Scaffold - meaning that rotary will be able to request a focus without any
- * additional changes. Another FocusRequester can be added through Modifier chain by adding
- * `.focusRequester(focusRequester)`. Do not call `focusable()` or `focusTarget()` after it as this
- * will reset the focusRequester chain and rotary support will not be available.
+ * additional changes.
  *
  * Example of a [ScalingLazyColumn] with default parameters:
  *
@@ -461,7 +459,8 @@
  *   [flingBehavior] parameter that controls touch scroll are expected to produce similar list
  *   scrolling. For example, if [rotaryScrollableBehavior] is set for snap (using
  *   [RotaryScrollableDefaults.snapBehavior]), [flingBehavior] should be set for snap as well (using
- *   [ScalingLazyColumnDefaults.snapFlingBehavior]). Can be null if rotary support is not required.
+ *   [ScalingLazyColumnDefaults.snapFlingBehavior]). Can be null if rotary support is not required
+ *   or when it should be handled externally - with a separate .rotary modifier.
  * @param content The content of the [ScalingLazyColumn]
  */
 @OptIn(ExperimentalWearFoundationApi::class)
diff --git a/wear/compose/compose-material-core/build.gradle b/wear/compose/compose-material-core/build.gradle
index df1b6c6..4b75357 100644
--- a/wear/compose/compose-material-core/build.gradle
+++ b/wear/compose/compose-material-core/build.gradle
@@ -44,7 +44,7 @@
     implementation("androidx.compose.material:material-ripple:1.7.0")
     implementation("androidx.compose.ui:ui-util:1.7.0")
     implementation(project(":wear:compose:compose-foundation"))
-    implementation("androidx.profileinstaller:profileinstaller:1.3.1")
+    implementation("androidx.profileinstaller:profileinstaller:1.4.0")
 
     androidTestImplementation(project(":compose:ui:ui-test"))
     androidTestImplementation(project(":compose:ui:ui-test-junit4"))
diff --git a/wear/compose/compose-material/build.gradle b/wear/compose/compose-material/build.gradle
index 1d2fc79..864e6ad 100644
--- a/wear/compose/compose-material/build.gradle
+++ b/wear/compose/compose-material/build.gradle
@@ -43,7 +43,7 @@
     implementation("androidx.compose.material:material-ripple:1.7.0")
     implementation("androidx.compose.ui:ui-util:1.7.0")
     implementation(project(":wear:compose:compose-material-core"))
-    implementation("androidx.profileinstaller:profileinstaller:1.3.1")
+    implementation("androidx.profileinstaller:profileinstaller:1.4.0")
     implementation("androidx.lifecycle:lifecycle-common:2.7.0")
 
     // This :foundation dependency can be removed once the material libraries are updated to use
diff --git a/wear/compose/compose-material3/api/current.txt b/wear/compose/compose-material3/api/current.txt
index 98a3473..e755b18 100644
--- a/wear/compose/compose-material3/api/current.txt
+++ b/wear/compose/compose-material3/api/current.txt
@@ -266,10 +266,12 @@
   public final class CircularProgressIndicatorDefaults {
     method public float calculateRecommendedGapSize(float strokeWidth);
     method public float getFullScreenPadding();
+    method public float getIndeterminateStrokeWidth();
     method @androidx.compose.runtime.Composable public float getLargeStrokeWidth();
     method @androidx.compose.runtime.Composable public float getSmallStrokeWidth();
     method public float getStartAngle();
     property public final float FullScreenPadding;
+    property public final float IndeterminateStrokeWidth;
     property public final float StartAngle;
     property @androidx.compose.runtime.Composable public final float largeStrokeWidth;
     property @androidx.compose.runtime.Composable public final float smallStrokeWidth;
@@ -277,7 +279,8 @@
   }
 
   public final class CircularProgressIndicatorKt {
-    method @androidx.compose.runtime.Composable public static void CircularProgressIndicator(kotlin.jvm.functions.Function0<java.lang.Float> progress, optional androidx.compose.ui.Modifier modifier, optional float startAngle, optional float endAngle, optional androidx.wear.compose.material3.ProgressIndicatorColors colors, optional float strokeWidth, optional float gapSize, optional boolean enabled);
+    method @androidx.compose.runtime.Composable public static void CircularProgressIndicator(optional androidx.compose.ui.Modifier modifier, optional androidx.wear.compose.material3.ProgressIndicatorColors colors, optional float strokeWidth, optional float gapSize);
+    method @androidx.compose.runtime.Composable public static void CircularProgressIndicator(kotlin.jvm.functions.Function0<java.lang.Float> progress, optional androidx.compose.ui.Modifier modifier, optional boolean allowProgressOverflow, optional float startAngle, optional float endAngle, optional androidx.wear.compose.material3.ProgressIndicatorColors colors, optional float strokeWidth, optional float gapSize, optional boolean enabled);
   }
 
   @androidx.compose.runtime.Immutable @androidx.compose.runtime.Stable public final class ColorScheme {
@@ -489,8 +492,6 @@
     method @androidx.compose.runtime.Composable public androidx.wear.compose.material3.IconButtonColors iconButtonColors();
     method @androidx.compose.runtime.Composable public androidx.wear.compose.material3.IconButtonColors iconButtonColors(optional long containerColor, optional long contentColor, optional long disabledContainerColor, optional long disabledContentColor);
     method public float iconSizeFor(float size);
-    method @androidx.compose.runtime.Composable public androidx.wear.compose.material3.IconToggleButtonColors iconToggleButtonColors();
-    method @androidx.compose.runtime.Composable public androidx.wear.compose.material3.IconToggleButtonColors iconToggleButtonColors(optional long checkedContainerColor, optional long checkedContentColor, optional long uncheckedContainerColor, optional long uncheckedContentColor, optional long disabledCheckedContainerColor, optional long disabledCheckedContentColor, optional long disabledUncheckedContainerColor, optional long disabledUncheckedContentColor);
     method @androidx.compose.runtime.Composable public androidx.wear.compose.material3.IconButtonColors outlinedIconButtonColors();
     method @androidx.compose.runtime.Composable public androidx.wear.compose.material3.IconButtonColors outlinedIconButtonColors(optional long contentColor, optional long disabledContentColor);
     property public final float DefaultButtonSize;
@@ -540,6 +541,19 @@
     property public final long uncheckedContentColor;
   }
 
+  public final class IconToggleButtonDefaults {
+    method @androidx.compose.runtime.Composable public androidx.compose.ui.graphics.Shape animatedToggleButtonShape(androidx.compose.foundation.interaction.InteractionSource interactionSource, boolean checked, optional androidx.compose.foundation.shape.CornerSize uncheckedCornerSize, optional androidx.compose.foundation.shape.CornerSize checkedCornerSize, optional androidx.compose.foundation.shape.CornerSize pressedCornerSize, optional androidx.compose.animation.core.FiniteAnimationSpec<java.lang.Float> onPressAnimationSpec, optional androidx.compose.animation.core.FiniteAnimationSpec<java.lang.Float> onReleaseAnimationSpec);
+    method public androidx.compose.foundation.shape.CornerSize getCheckedCornerSize();
+    method public androidx.compose.foundation.shape.CornerSize getPressedCornerSize();
+    method public androidx.compose.foundation.shape.CornerSize getUncheckedCornerSize();
+    method @androidx.compose.runtime.Composable public androidx.wear.compose.material3.IconToggleButtonColors iconToggleButtonColors();
+    method @androidx.compose.runtime.Composable public androidx.wear.compose.material3.IconToggleButtonColors iconToggleButtonColors(optional long checkedContainerColor, optional long checkedContentColor, optional long uncheckedContainerColor, optional long uncheckedContentColor, optional long disabledCheckedContainerColor, optional long disabledCheckedContentColor, optional long disabledUncheckedContainerColor, optional long disabledUncheckedContentColor);
+    property public final androidx.compose.foundation.shape.CornerSize CheckedCornerSize;
+    property public final androidx.compose.foundation.shape.CornerSize PressedCornerSize;
+    property public final androidx.compose.foundation.shape.CornerSize UncheckedCornerSize;
+    field public static final androidx.wear.compose.material3.IconToggleButtonDefaults INSTANCE;
+  }
+
   @SuppressCompatibility @androidx.compose.runtime.Immutable @androidx.wear.compose.material3.ExperimentalWearMaterial3Api public final class InlineSliderColors {
     ctor public InlineSliderColors(long containerColor, long buttonIconColor, long selectedBarColor, long unselectedBarColor, long barSeparatorColor, long disabledContainerColor, long disabledButtonIconColor, long disabledSelectedBarColor, long disabledUnselectedBarColor, long disabledBarSeparatorColor);
     method public long getBarSeparatorColor();
@@ -805,21 +819,25 @@
   }
 
   public final class ProgressIndicatorColors {
-    ctor public ProgressIndicatorColors(androidx.compose.ui.graphics.Brush indicatorBrush, androidx.compose.ui.graphics.Brush trackBrush, optional androidx.compose.ui.graphics.Brush disabledIndicatorBrush, optional androidx.compose.ui.graphics.Brush disabledTrackBrush);
+    ctor public ProgressIndicatorColors(androidx.compose.ui.graphics.Brush indicatorBrush, androidx.compose.ui.graphics.Brush trackBrush, androidx.compose.ui.graphics.Brush overflowTrackBrush, androidx.compose.ui.graphics.Brush disabledIndicatorBrush, androidx.compose.ui.graphics.Brush disabledTrackBrush, androidx.compose.ui.graphics.Brush disabledOverflowTrackBrush);
     method public androidx.compose.ui.graphics.Brush getDisabledIndicatorBrush();
+    method public androidx.compose.ui.graphics.Brush getDisabledOverflowTrackBrush();
     method public androidx.compose.ui.graphics.Brush getDisabledTrackBrush();
     method public androidx.compose.ui.graphics.Brush getIndicatorBrush();
+    method public androidx.compose.ui.graphics.Brush getOverflowTrackBrush();
     method public androidx.compose.ui.graphics.Brush getTrackBrush();
     property public final androidx.compose.ui.graphics.Brush disabledIndicatorBrush;
+    property public final androidx.compose.ui.graphics.Brush disabledOverflowTrackBrush;
     property public final androidx.compose.ui.graphics.Brush disabledTrackBrush;
     property public final androidx.compose.ui.graphics.Brush indicatorBrush;
+    property public final androidx.compose.ui.graphics.Brush overflowTrackBrush;
     property public final androidx.compose.ui.graphics.Brush trackBrush;
   }
 
   public final class ProgressIndicatorDefaults {
     method @androidx.compose.runtime.Composable public androidx.wear.compose.material3.ProgressIndicatorColors colors();
-    method @androidx.compose.runtime.Composable public androidx.wear.compose.material3.ProgressIndicatorColors colors(optional androidx.compose.ui.graphics.Brush? indicatorBrush, optional androidx.compose.ui.graphics.Brush? trackBrush, optional androidx.compose.ui.graphics.Brush? disabledIndicatorBrush, optional androidx.compose.ui.graphics.Brush? disabledTrackBrush);
-    method @androidx.compose.runtime.Composable public androidx.wear.compose.material3.ProgressIndicatorColors colors(optional long indicatorColor, optional long trackColor, optional long disabledIndicatorColor, optional long disabledTrackColor);
+    method @androidx.compose.runtime.Composable public androidx.wear.compose.material3.ProgressIndicatorColors colors(optional androidx.compose.ui.graphics.Brush? indicatorBrush, optional androidx.compose.ui.graphics.Brush? trackBrush, optional androidx.compose.ui.graphics.Brush? overflowTrackBrush, optional androidx.compose.ui.graphics.Brush? disabledIndicatorBrush, optional androidx.compose.ui.graphics.Brush? disabledTrackBrush, optional androidx.compose.ui.graphics.Brush? disabledOverflowTrackBrush);
+    method @androidx.compose.runtime.Composable public androidx.wear.compose.material3.ProgressIndicatorColors colors(optional long indicatorColor, optional long trackColor, optional long overflowTrackColor, optional long disabledIndicatorColor, optional long disabledTrackColor, optional long disabledOverflowTrackColor);
     field public static final androidx.wear.compose.material3.ProgressIndicatorDefaults INSTANCE;
   }
 
@@ -934,7 +952,7 @@
   }
 
   public final class SegmentedCircularProgressIndicatorKt {
-    method @androidx.compose.runtime.Composable public static void SegmentedCircularProgressIndicator(@IntRange(from=1L) int segmentCount, kotlin.jvm.functions.Function0<java.lang.Float> progress, optional androidx.compose.ui.Modifier modifier, optional float startAngle, optional float endAngle, optional androidx.wear.compose.material3.ProgressIndicatorColors colors, optional float strokeWidth, optional float gapSize, optional boolean enabled);
+    method @androidx.compose.runtime.Composable public static void SegmentedCircularProgressIndicator(@IntRange(from=1L) int segmentCount, kotlin.jvm.functions.Function0<java.lang.Float> progress, optional androidx.compose.ui.Modifier modifier, optional boolean allowProgressOverflow, optional float startAngle, optional float endAngle, optional androidx.wear.compose.material3.ProgressIndicatorColors colors, optional float strokeWidth, optional float gapSize, optional boolean enabled);
     method @androidx.compose.runtime.Composable public static void SegmentedCircularProgressIndicator(@IntRange(from=1L) int segmentCount, kotlin.jvm.functions.Function1<? super java.lang.Integer,java.lang.Boolean> completed, optional androidx.compose.ui.Modifier modifier, optional float startAngle, optional float endAngle, optional androidx.wear.compose.material3.ProgressIndicatorColors colors, optional float strokeWidth, optional float gapSize, optional boolean enabled);
   }
 
@@ -1258,8 +1276,6 @@
     method @androidx.compose.runtime.Composable public androidx.wear.compose.material3.TextButtonColors outlinedTextButtonColors(optional long contentColor, optional long disabledContentColor);
     method @androidx.compose.runtime.Composable public androidx.wear.compose.material3.TextButtonColors textButtonColors();
     method @androidx.compose.runtime.Composable public androidx.wear.compose.material3.TextButtonColors textButtonColors(optional long containerColor, optional long contentColor, optional long disabledContainerColor, optional long disabledContentColor);
-    method @androidx.compose.runtime.Composable public androidx.wear.compose.material3.TextToggleButtonColors textToggleButtonColors();
-    method @androidx.compose.runtime.Composable public androidx.wear.compose.material3.TextToggleButtonColors textToggleButtonColors(optional long checkedContainerColor, optional long checkedContentColor, optional long uncheckedContainerColor, optional long uncheckedContentColor, optional long disabledCheckedContainerColor, optional long disabledCheckedContentColor, optional long disabledUncheckedContainerColor, optional long disabledUncheckedContentColor);
     property public final float DefaultButtonSize;
     property public final float LargeButtonSize;
     property public final float SmallButtonSize;
@@ -1325,6 +1341,19 @@
     property public final long uncheckedContentColor;
   }
 
+  public final class TextToggleButtonDefaults {
+    method @androidx.compose.runtime.Composable public androidx.compose.ui.graphics.Shape animatedToggleButtonShape(androidx.compose.foundation.interaction.InteractionSource interactionSource, boolean checked, optional androidx.compose.foundation.shape.CornerSize uncheckedCornerSize, optional androidx.compose.foundation.shape.CornerSize checkedCornerSize, optional androidx.compose.foundation.shape.CornerSize pressedCornerSize, optional androidx.compose.animation.core.FiniteAnimationSpec<java.lang.Float> onPressAnimationSpec, optional androidx.compose.animation.core.FiniteAnimationSpec<java.lang.Float> onReleaseAnimationSpec);
+    method public androidx.compose.foundation.shape.CornerSize getCheckedCornerSize();
+    method public androidx.compose.foundation.shape.CornerSize getPressedCornerSize();
+    method public androidx.compose.foundation.shape.CornerSize getUncheckedCornerSize();
+    method @androidx.compose.runtime.Composable public androidx.wear.compose.material3.TextToggleButtonColors textToggleButtonColors();
+    method @androidx.compose.runtime.Composable public androidx.wear.compose.material3.TextToggleButtonColors textToggleButtonColors(optional long checkedContainerColor, optional long checkedContentColor, optional long uncheckedContainerColor, optional long uncheckedContentColor, optional long disabledCheckedContainerColor, optional long disabledCheckedContentColor, optional long disabledUncheckedContainerColor, optional long disabledUncheckedContentColor);
+    property public final androidx.compose.foundation.shape.CornerSize CheckedCornerSize;
+    property public final androidx.compose.foundation.shape.CornerSize PressedCornerSize;
+    property public final androidx.compose.foundation.shape.CornerSize UncheckedCornerSize;
+    field public static final androidx.wear.compose.material3.TextToggleButtonDefaults INSTANCE;
+  }
+
   @androidx.compose.runtime.Immutable public final class TimePickerColors {
     ctor public TimePickerColors(long selectedPickerContentColor, long unselectedPickerContentColor, long separatorColor, long pickerLabelColor, long confirmButtonContentColor, long confirmButtonContainerColor);
     method public long getConfirmButtonContainerColor();
diff --git a/wear/compose/compose-material3/api/restricted_current.txt b/wear/compose/compose-material3/api/restricted_current.txt
index 98a3473..e755b18 100644
--- a/wear/compose/compose-material3/api/restricted_current.txt
+++ b/wear/compose/compose-material3/api/restricted_current.txt
@@ -266,10 +266,12 @@
   public final class CircularProgressIndicatorDefaults {
     method public float calculateRecommendedGapSize(float strokeWidth);
     method public float getFullScreenPadding();
+    method public float getIndeterminateStrokeWidth();
     method @androidx.compose.runtime.Composable public float getLargeStrokeWidth();
     method @androidx.compose.runtime.Composable public float getSmallStrokeWidth();
     method public float getStartAngle();
     property public final float FullScreenPadding;
+    property public final float IndeterminateStrokeWidth;
     property public final float StartAngle;
     property @androidx.compose.runtime.Composable public final float largeStrokeWidth;
     property @androidx.compose.runtime.Composable public final float smallStrokeWidth;
@@ -277,7 +279,8 @@
   }
 
   public final class CircularProgressIndicatorKt {
-    method @androidx.compose.runtime.Composable public static void CircularProgressIndicator(kotlin.jvm.functions.Function0<java.lang.Float> progress, optional androidx.compose.ui.Modifier modifier, optional float startAngle, optional float endAngle, optional androidx.wear.compose.material3.ProgressIndicatorColors colors, optional float strokeWidth, optional float gapSize, optional boolean enabled);
+    method @androidx.compose.runtime.Composable public static void CircularProgressIndicator(optional androidx.compose.ui.Modifier modifier, optional androidx.wear.compose.material3.ProgressIndicatorColors colors, optional float strokeWidth, optional float gapSize);
+    method @androidx.compose.runtime.Composable public static void CircularProgressIndicator(kotlin.jvm.functions.Function0<java.lang.Float> progress, optional androidx.compose.ui.Modifier modifier, optional boolean allowProgressOverflow, optional float startAngle, optional float endAngle, optional androidx.wear.compose.material3.ProgressIndicatorColors colors, optional float strokeWidth, optional float gapSize, optional boolean enabled);
   }
 
   @androidx.compose.runtime.Immutable @androidx.compose.runtime.Stable public final class ColorScheme {
@@ -489,8 +492,6 @@
     method @androidx.compose.runtime.Composable public androidx.wear.compose.material3.IconButtonColors iconButtonColors();
     method @androidx.compose.runtime.Composable public androidx.wear.compose.material3.IconButtonColors iconButtonColors(optional long containerColor, optional long contentColor, optional long disabledContainerColor, optional long disabledContentColor);
     method public float iconSizeFor(float size);
-    method @androidx.compose.runtime.Composable public androidx.wear.compose.material3.IconToggleButtonColors iconToggleButtonColors();
-    method @androidx.compose.runtime.Composable public androidx.wear.compose.material3.IconToggleButtonColors iconToggleButtonColors(optional long checkedContainerColor, optional long checkedContentColor, optional long uncheckedContainerColor, optional long uncheckedContentColor, optional long disabledCheckedContainerColor, optional long disabledCheckedContentColor, optional long disabledUncheckedContainerColor, optional long disabledUncheckedContentColor);
     method @androidx.compose.runtime.Composable public androidx.wear.compose.material3.IconButtonColors outlinedIconButtonColors();
     method @androidx.compose.runtime.Composable public androidx.wear.compose.material3.IconButtonColors outlinedIconButtonColors(optional long contentColor, optional long disabledContentColor);
     property public final float DefaultButtonSize;
@@ -540,6 +541,19 @@
     property public final long uncheckedContentColor;
   }
 
+  public final class IconToggleButtonDefaults {
+    method @androidx.compose.runtime.Composable public androidx.compose.ui.graphics.Shape animatedToggleButtonShape(androidx.compose.foundation.interaction.InteractionSource interactionSource, boolean checked, optional androidx.compose.foundation.shape.CornerSize uncheckedCornerSize, optional androidx.compose.foundation.shape.CornerSize checkedCornerSize, optional androidx.compose.foundation.shape.CornerSize pressedCornerSize, optional androidx.compose.animation.core.FiniteAnimationSpec<java.lang.Float> onPressAnimationSpec, optional androidx.compose.animation.core.FiniteAnimationSpec<java.lang.Float> onReleaseAnimationSpec);
+    method public androidx.compose.foundation.shape.CornerSize getCheckedCornerSize();
+    method public androidx.compose.foundation.shape.CornerSize getPressedCornerSize();
+    method public androidx.compose.foundation.shape.CornerSize getUncheckedCornerSize();
+    method @androidx.compose.runtime.Composable public androidx.wear.compose.material3.IconToggleButtonColors iconToggleButtonColors();
+    method @androidx.compose.runtime.Composable public androidx.wear.compose.material3.IconToggleButtonColors iconToggleButtonColors(optional long checkedContainerColor, optional long checkedContentColor, optional long uncheckedContainerColor, optional long uncheckedContentColor, optional long disabledCheckedContainerColor, optional long disabledCheckedContentColor, optional long disabledUncheckedContainerColor, optional long disabledUncheckedContentColor);
+    property public final androidx.compose.foundation.shape.CornerSize CheckedCornerSize;
+    property public final androidx.compose.foundation.shape.CornerSize PressedCornerSize;
+    property public final androidx.compose.foundation.shape.CornerSize UncheckedCornerSize;
+    field public static final androidx.wear.compose.material3.IconToggleButtonDefaults INSTANCE;
+  }
+
   @SuppressCompatibility @androidx.compose.runtime.Immutable @androidx.wear.compose.material3.ExperimentalWearMaterial3Api public final class InlineSliderColors {
     ctor public InlineSliderColors(long containerColor, long buttonIconColor, long selectedBarColor, long unselectedBarColor, long barSeparatorColor, long disabledContainerColor, long disabledButtonIconColor, long disabledSelectedBarColor, long disabledUnselectedBarColor, long disabledBarSeparatorColor);
     method public long getBarSeparatorColor();
@@ -805,21 +819,25 @@
   }
 
   public final class ProgressIndicatorColors {
-    ctor public ProgressIndicatorColors(androidx.compose.ui.graphics.Brush indicatorBrush, androidx.compose.ui.graphics.Brush trackBrush, optional androidx.compose.ui.graphics.Brush disabledIndicatorBrush, optional androidx.compose.ui.graphics.Brush disabledTrackBrush);
+    ctor public ProgressIndicatorColors(androidx.compose.ui.graphics.Brush indicatorBrush, androidx.compose.ui.graphics.Brush trackBrush, androidx.compose.ui.graphics.Brush overflowTrackBrush, androidx.compose.ui.graphics.Brush disabledIndicatorBrush, androidx.compose.ui.graphics.Brush disabledTrackBrush, androidx.compose.ui.graphics.Brush disabledOverflowTrackBrush);
     method public androidx.compose.ui.graphics.Brush getDisabledIndicatorBrush();
+    method public androidx.compose.ui.graphics.Brush getDisabledOverflowTrackBrush();
     method public androidx.compose.ui.graphics.Brush getDisabledTrackBrush();
     method public androidx.compose.ui.graphics.Brush getIndicatorBrush();
+    method public androidx.compose.ui.graphics.Brush getOverflowTrackBrush();
     method public androidx.compose.ui.graphics.Brush getTrackBrush();
     property public final androidx.compose.ui.graphics.Brush disabledIndicatorBrush;
+    property public final androidx.compose.ui.graphics.Brush disabledOverflowTrackBrush;
     property public final androidx.compose.ui.graphics.Brush disabledTrackBrush;
     property public final androidx.compose.ui.graphics.Brush indicatorBrush;
+    property public final androidx.compose.ui.graphics.Brush overflowTrackBrush;
     property public final androidx.compose.ui.graphics.Brush trackBrush;
   }
 
   public final class ProgressIndicatorDefaults {
     method @androidx.compose.runtime.Composable public androidx.wear.compose.material3.ProgressIndicatorColors colors();
-    method @androidx.compose.runtime.Composable public androidx.wear.compose.material3.ProgressIndicatorColors colors(optional androidx.compose.ui.graphics.Brush? indicatorBrush, optional androidx.compose.ui.graphics.Brush? trackBrush, optional androidx.compose.ui.graphics.Brush? disabledIndicatorBrush, optional androidx.compose.ui.graphics.Brush? disabledTrackBrush);
-    method @androidx.compose.runtime.Composable public androidx.wear.compose.material3.ProgressIndicatorColors colors(optional long indicatorColor, optional long trackColor, optional long disabledIndicatorColor, optional long disabledTrackColor);
+    method @androidx.compose.runtime.Composable public androidx.wear.compose.material3.ProgressIndicatorColors colors(optional androidx.compose.ui.graphics.Brush? indicatorBrush, optional androidx.compose.ui.graphics.Brush? trackBrush, optional androidx.compose.ui.graphics.Brush? overflowTrackBrush, optional androidx.compose.ui.graphics.Brush? disabledIndicatorBrush, optional androidx.compose.ui.graphics.Brush? disabledTrackBrush, optional androidx.compose.ui.graphics.Brush? disabledOverflowTrackBrush);
+    method @androidx.compose.runtime.Composable public androidx.wear.compose.material3.ProgressIndicatorColors colors(optional long indicatorColor, optional long trackColor, optional long overflowTrackColor, optional long disabledIndicatorColor, optional long disabledTrackColor, optional long disabledOverflowTrackColor);
     field public static final androidx.wear.compose.material3.ProgressIndicatorDefaults INSTANCE;
   }
 
@@ -934,7 +952,7 @@
   }
 
   public final class SegmentedCircularProgressIndicatorKt {
-    method @androidx.compose.runtime.Composable public static void SegmentedCircularProgressIndicator(@IntRange(from=1L) int segmentCount, kotlin.jvm.functions.Function0<java.lang.Float> progress, optional androidx.compose.ui.Modifier modifier, optional float startAngle, optional float endAngle, optional androidx.wear.compose.material3.ProgressIndicatorColors colors, optional float strokeWidth, optional float gapSize, optional boolean enabled);
+    method @androidx.compose.runtime.Composable public static void SegmentedCircularProgressIndicator(@IntRange(from=1L) int segmentCount, kotlin.jvm.functions.Function0<java.lang.Float> progress, optional androidx.compose.ui.Modifier modifier, optional boolean allowProgressOverflow, optional float startAngle, optional float endAngle, optional androidx.wear.compose.material3.ProgressIndicatorColors colors, optional float strokeWidth, optional float gapSize, optional boolean enabled);
     method @androidx.compose.runtime.Composable public static void SegmentedCircularProgressIndicator(@IntRange(from=1L) int segmentCount, kotlin.jvm.functions.Function1<? super java.lang.Integer,java.lang.Boolean> completed, optional androidx.compose.ui.Modifier modifier, optional float startAngle, optional float endAngle, optional androidx.wear.compose.material3.ProgressIndicatorColors colors, optional float strokeWidth, optional float gapSize, optional boolean enabled);
   }
 
@@ -1258,8 +1276,6 @@
     method @androidx.compose.runtime.Composable public androidx.wear.compose.material3.TextButtonColors outlinedTextButtonColors(optional long contentColor, optional long disabledContentColor);
     method @androidx.compose.runtime.Composable public androidx.wear.compose.material3.TextButtonColors textButtonColors();
     method @androidx.compose.runtime.Composable public androidx.wear.compose.material3.TextButtonColors textButtonColors(optional long containerColor, optional long contentColor, optional long disabledContainerColor, optional long disabledContentColor);
-    method @androidx.compose.runtime.Composable public androidx.wear.compose.material3.TextToggleButtonColors textToggleButtonColors();
-    method @androidx.compose.runtime.Composable public androidx.wear.compose.material3.TextToggleButtonColors textToggleButtonColors(optional long checkedContainerColor, optional long checkedContentColor, optional long uncheckedContainerColor, optional long uncheckedContentColor, optional long disabledCheckedContainerColor, optional long disabledCheckedContentColor, optional long disabledUncheckedContainerColor, optional long disabledUncheckedContentColor);
     property public final float DefaultButtonSize;
     property public final float LargeButtonSize;
     property public final float SmallButtonSize;
@@ -1325,6 +1341,19 @@
     property public final long uncheckedContentColor;
   }
 
+  public final class TextToggleButtonDefaults {
+    method @androidx.compose.runtime.Composable public androidx.compose.ui.graphics.Shape animatedToggleButtonShape(androidx.compose.foundation.interaction.InteractionSource interactionSource, boolean checked, optional androidx.compose.foundation.shape.CornerSize uncheckedCornerSize, optional androidx.compose.foundation.shape.CornerSize checkedCornerSize, optional androidx.compose.foundation.shape.CornerSize pressedCornerSize, optional androidx.compose.animation.core.FiniteAnimationSpec<java.lang.Float> onPressAnimationSpec, optional androidx.compose.animation.core.FiniteAnimationSpec<java.lang.Float> onReleaseAnimationSpec);
+    method public androidx.compose.foundation.shape.CornerSize getCheckedCornerSize();
+    method public androidx.compose.foundation.shape.CornerSize getPressedCornerSize();
+    method public androidx.compose.foundation.shape.CornerSize getUncheckedCornerSize();
+    method @androidx.compose.runtime.Composable public androidx.wear.compose.material3.TextToggleButtonColors textToggleButtonColors();
+    method @androidx.compose.runtime.Composable public androidx.wear.compose.material3.TextToggleButtonColors textToggleButtonColors(optional long checkedContainerColor, optional long checkedContentColor, optional long uncheckedContainerColor, optional long uncheckedContentColor, optional long disabledCheckedContainerColor, optional long disabledCheckedContentColor, optional long disabledUncheckedContainerColor, optional long disabledUncheckedContentColor);
+    property public final androidx.compose.foundation.shape.CornerSize CheckedCornerSize;
+    property public final androidx.compose.foundation.shape.CornerSize PressedCornerSize;
+    property public final androidx.compose.foundation.shape.CornerSize UncheckedCornerSize;
+    field public static final androidx.wear.compose.material3.TextToggleButtonDefaults INSTANCE;
+  }
+
   @androidx.compose.runtime.Immutable public final class TimePickerColors {
     ctor public TimePickerColors(long selectedPickerContentColor, long unselectedPickerContentColor, long separatorColor, long pickerLabelColor, long confirmButtonContentColor, long confirmButtonContainerColor);
     method public long getConfirmButtonContainerColor();
diff --git a/wear/compose/compose-material3/build.gradle b/wear/compose/compose-material3/build.gradle
index f026396..fb86c7e 100644
--- a/wear/compose/compose-material3/build.gradle
+++ b/wear/compose/compose-material3/build.gradle
@@ -45,7 +45,7 @@
     implementation("androidx.compose.material:material-ripple:1.7.0")
     implementation("androidx.compose.ui:ui-util:1.7.0")
     implementation(project(":wear:compose:compose-material-core"))
-    implementation("androidx.profileinstaller:profileinstaller:1.3.1")
+    implementation("androidx.profileinstaller:profileinstaller:1.4.0")
     implementation("androidx.graphics:graphics-shapes:1.0.1")
     implementation project(':compose:animation:animation-graphics')
 
diff --git a/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/AnimatedShapeButtonDemo.kt b/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/AnimatedShapeButtonDemo.kt
index 740b637..e466b27 100644
--- a/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/AnimatedShapeButtonDemo.kt
+++ b/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/AnimatedShapeButtonDemo.kt
@@ -48,7 +48,8 @@
                 val interactionSource1 = remember { MutableInteractionSource() }
                 TextButton(
                     onClick = {},
-                    shape = TextButtonDefaults.animatedShape(interactionSource1)
+                    shape = TextButtonDefaults.animatedShape(interactionSource1),
+                    interactionSource = interactionSource1,
                 ) {
                     Text(text = "ABC")
                 }
@@ -62,8 +63,9 @@
                         TextButtonDefaults.animatedShape(
                             interactionSource2,
                             shape = CutCornerShape(15.dp),
-                            pressedShape = RoundedCornerShape(15.dp)
-                        )
+                            pressedShape = RoundedCornerShape(15.dp),
+                        ),
+                    interactionSource = interactionSource2,
                 ) {
                     Text(text = "ABC")
                 }
diff --git a/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/AnimatedShapeToggleButtonDemo.kt b/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/AnimatedShapeToggleButtonDemo.kt
new file mode 100644
index 0000000..771a8af
--- /dev/null
+++ b/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/AnimatedShapeToggleButtonDemo.kt
@@ -0,0 +1,113 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.wear.compose.material3.demos
+
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.width
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.Home
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.wear.compose.material3.Icon
+import androidx.wear.compose.material3.IconButtonDefaults
+import androidx.wear.compose.material3.IconToggleButton
+import androidx.wear.compose.material3.IconToggleButtonDefaults
+import androidx.wear.compose.material3.ListHeader
+import androidx.wear.compose.material3.Text
+import androidx.wear.compose.material3.TextButtonDefaults
+import androidx.wear.compose.material3.TextToggleButton
+import androidx.wear.compose.material3.TextToggleButtonDefaults
+
+@Composable
+fun AnimatedShapeToggleButtonDemo() {
+    ScalingLazyDemo {
+        item { ListHeader { Text("Default Toggle") } }
+        item {
+            Row {
+                val checked = remember { mutableStateOf(false) }
+
+                val interactionSource1 = remember { MutableInteractionSource() }
+
+                TextToggleButton(
+                    onCheckedChange = { checked.value = !checked.value },
+                    shape =
+                        TextButtonDefaults.animatedShape(
+                            interactionSource1,
+                        ),
+                    checked = checked.value,
+                    interactionSource = interactionSource1,
+                ) {
+                    Text(text = "ABC")
+                }
+
+                Spacer(modifier = Modifier.width(5.dp))
+
+                IconToggleButton(
+                    onCheckedChange = { checked.value = !checked.value },
+                    shape =
+                        IconButtonDefaults.animatedShape(
+                            interactionSource1,
+                        ),
+                    checked = checked.value,
+                    interactionSource = interactionSource1,
+                ) {
+                    Icon(imageVector = Icons.Rounded.Home, contentDescription = null)
+                }
+            }
+        }
+        item { ListHeader { Text("Toggle Variant") } }
+        item {
+            Row {
+                val checked = remember { mutableStateOf(false) }
+
+                val interactionSource1 = remember { MutableInteractionSource() }
+                TextToggleButton(
+                    onCheckedChange = { checked.value = !checked.value },
+                    shape =
+                        TextToggleButtonDefaults.animatedToggleButtonShape(
+                            interactionSource1,
+                            checked = checked.value,
+                        ),
+                    checked = checked.value,
+                    interactionSource = interactionSource1,
+                ) {
+                    Text(text = "ABC")
+                }
+
+                Spacer(modifier = Modifier.width(5.dp))
+
+                IconToggleButton(
+                    onCheckedChange = { checked.value = !checked.value },
+                    shape =
+                        IconToggleButtonDefaults.animatedToggleButtonShape(
+                            interactionSource1,
+                            checked = checked.value,
+                        ),
+                    checked = checked.value,
+                    interactionSource = interactionSource1,
+                ) {
+                    Icon(imageVector = Icons.Rounded.Home, contentDescription = null)
+                }
+            }
+        }
+    }
+}
diff --git a/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/ProgressIndicatorDemo.kt b/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/ProgressIndicatorDemo.kt
index b8370ff..8500205 100644
--- a/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/ProgressIndicatorDemo.kt
+++ b/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/ProgressIndicatorDemo.kt
@@ -52,6 +52,7 @@
 import androidx.wear.compose.material3.SwitchButton
 import androidx.wear.compose.material3.Text
 import androidx.wear.compose.material3.samples.FullScreenProgressIndicatorSample
+import androidx.wear.compose.material3.samples.IndeterminateProgressIndicatorSample
 import androidx.wear.compose.material3.samples.LinearProgressIndicatorSample
 import androidx.wear.compose.material3.samples.MediaButtonProgressIndicatorSample
 import androidx.wear.compose.material3.samples.OverflowProgressIndicatorSample
@@ -90,6 +91,9 @@
         ComposableDemo("Small progress values") {
             Centralize { SmallValuesProgressIndicatorSample() }
         },
+        ComposableDemo("Indeterminate progress") {
+            Centralize { IndeterminateProgressIndicatorSample() }
+        },
         ComposableDemo("Segmented progress") { Centralize { SegmentedProgressIndicatorSample() } },
         ComposableDemo("Progress segments on/off") {
             Centralize { SegmentedProgressIndicatorOnOffSample() }
@@ -140,13 +144,15 @@
     val startAngle = remember { mutableFloatStateOf(360f) }
     val endAngle = remember { mutableFloatStateOf(360f) }
     val enabled = remember { mutableStateOf(true) }
+    val overflowAllowed = remember { mutableStateOf(true) }
     val hasLargeStroke = remember { mutableStateOf(true) }
     val hasCustomColors = remember { mutableStateOf(false) }
     val colors =
         if (hasCustomColors.value) {
             ProgressIndicatorDefaults.colors(
                 indicatorColor = Color.Green,
-                trackColor = Color.Green.copy(alpha = 0.5f)
+                trackColor = Color.Green.copy(alpha = 0.5f),
+                overflowTrackColor = Color.Green.copy(alpha = 0.7f),
             )
         } else {
             ProgressIndicatorDefaults.colors()
@@ -166,6 +172,7 @@
             startAngle = startAngle,
             endAngle = endAngle,
             enabled = enabled,
+            overflowAllowed = overflowAllowed,
             hasLargeStroke = hasLargeStroke,
             hasCustomColors = hasCustomColors,
         )
@@ -175,8 +182,9 @@
             startAngle = startAngle.value,
             endAngle = endAngle.value,
             enabled = enabled.value,
+            allowProgressOverflow = overflowAllowed.value,
             strokeWidth = strokeWidth,
-            colors = colors
+            colors = colors,
         )
     }
 }
@@ -187,6 +195,7 @@
     val startAngle = remember { mutableFloatStateOf(0f) }
     val endAngle = remember { mutableFloatStateOf(0f) }
     val enabled = remember { mutableStateOf(true) }
+    val overflowAllowed = remember { mutableStateOf(true) }
     val hasCustomColors = remember { mutableStateOf(false) }
     val hasLargeStroke = remember { mutableStateOf(true) }
     val numSegments = remember { mutableIntStateOf(5) }
@@ -194,7 +203,8 @@
         if (hasCustomColors.value) {
             ProgressIndicatorDefaults.colors(
                 indicatorColor = Color.Green,
-                trackColor = Color.Green.copy(alpha = 0.5f)
+                trackColor = Color.Green.copy(alpha = 0.5f),
+                overflowTrackColor = Color.Green.copy(alpha = 0.7f),
             )
         } else {
             ProgressIndicatorDefaults.colors()
@@ -217,6 +227,7 @@
             hasLargeStroke = hasLargeStroke,
             hasCustomColors = hasCustomColors,
             numSegments = numSegments,
+            overflowAllowed = overflowAllowed
         )
 
         SegmentedCircularProgressIndicator(
@@ -225,8 +236,9 @@
             startAngle = startAngle.value,
             endAngle = endAngle.value,
             enabled = enabled.value,
+            allowProgressOverflow = overflowAllowed.value,
             strokeWidth = strokeWidth,
-            colors = colors
+            colors = colors,
         )
     }
 }
@@ -240,6 +252,7 @@
     enabled: MutableState<Boolean>,
     hasLargeStroke: MutableState<Boolean>,
     hasCustomColors: MutableState<Boolean>,
+    overflowAllowed: MutableState<Boolean>,
     numSegments: MutableState<Int>? = null,
 ) {
     ScalingLazyColumn(
@@ -254,8 +267,8 @@
                 onValueChange = { progress.value = it },
                 increaseIcon = { Icon(InlineSliderDefaults.Increase, "Increase") },
                 decreaseIcon = { Icon(InlineSliderDefaults.Decrease, "Decrease") },
-                valueRange = 0f..1f,
-                steps = 4,
+                valueRange = 0f..2f,
+                steps = 9,
                 colors =
                     InlineSliderDefaults.colors(
                         containerColor = MaterialTheme.colorScheme.background,
@@ -336,5 +349,13 @@
                 label = { Text("Custom colors") },
             )
         }
+        item {
+            SwitchButton(
+                modifier = Modifier.fillMaxWidth().padding(8.dp),
+                checked = overflowAllowed.value,
+                onCheckedChange = { overflowAllowed.value = it },
+                label = { Text("Overflow") },
+            )
+        }
     }
 }
diff --git a/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/WearMaterial3Demos.kt b/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/WearMaterial3Demos.kt
index b532f69..5e2a401 100644
--- a/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/WearMaterial3Demos.kt
+++ b/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/WearMaterial3Demos.kt
@@ -95,6 +95,7 @@
             Material3DemoCategory("Time Text", TimeTextDemos),
             ComposableDemo("Card") { CardDemo() },
             ComposableDemo("Animated Shape Buttons") { AnimatedShapeButtonDemo() },
+            ComposableDemo("Animated Shape Toggle Buttons") { AnimatedShapeToggleButtonDemo() },
             ComposableDemo("Text Toggle Button") { TextToggleButtonDemo() },
             ComposableDemo("Icon Toggle Button") { IconToggleButtonDemo() },
             ComposableDemo("Checkbox Button") { CheckboxButtonDemo() },
diff --git a/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/IconToggleButtonSample.kt b/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/IconToggleButtonSample.kt
index 84892fb..7c9dd74 100644
--- a/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/IconToggleButtonSample.kt
+++ b/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/IconToggleButtonSample.kt
@@ -17,6 +17,7 @@
 package androidx.wear.compose.material3.samples
 
 import androidx.annotation.Sampled
+import androidx.compose.foundation.interaction.MutableInteractionSource
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.filled.Favorite
 import androidx.compose.runtime.Composable
@@ -25,13 +26,43 @@
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.setValue
 import androidx.wear.compose.material3.Icon
+import androidx.wear.compose.material3.IconButtonDefaults
 import androidx.wear.compose.material3.IconToggleButton
+import androidx.wear.compose.material3.IconToggleButtonDefaults
 
 @Sampled
 @Composable
 fun IconToggleButtonSample() {
+    val interactionSource = remember { MutableInteractionSource() }
     var checked by remember { mutableStateOf(true) }
-    IconToggleButton(checked = checked, onCheckedChange = { checked = !checked }) {
+    IconToggleButton(
+        checked = checked,
+        onCheckedChange = { checked = !checked },
+        interactionSource = interactionSource,
+        shape =
+            IconButtonDefaults.animatedShape(
+                interactionSource = interactionSource,
+            ),
+    ) {
+        Icon(imageVector = Icons.Filled.Favorite, contentDescription = "Favorite icon")
+    }
+}
+
+@Sampled
+@Composable
+fun IconToggleButtonVariantSample() {
+    val interactionSource = remember { MutableInteractionSource() }
+    var checked by remember { mutableStateOf(true) }
+    IconToggleButton(
+        checked = checked,
+        onCheckedChange = { checked = !checked },
+        interactionSource = interactionSource,
+        shape =
+            IconToggleButtonDefaults.animatedToggleButtonShape(
+                interactionSource = interactionSource,
+                checked = checked
+            ),
+    ) {
         Icon(imageVector = Icons.Filled.Favorite, contentDescription = "Favorite icon")
     }
 }
diff --git a/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/ProgressIndicatorSample.kt b/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/ProgressIndicatorSample.kt
index 3ba6437..47a6bd2 100644
--- a/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/ProgressIndicatorSample.kt
+++ b/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/ProgressIndicatorSample.kt
@@ -34,7 +34,6 @@
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.draw.clip
-import androidx.compose.ui.graphics.Brush
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.semantics.contentDescription
 import androidx.compose.ui.semantics.semantics
@@ -114,21 +113,11 @@
                 .fillMaxSize()
     ) {
         CircularProgressIndicator(
-            // The progress is limited by 100%, 120% ends up being 20% with the track brush
-            // indicating overflow.
-            progress = { 0.2f },
+            // Overflow value of 120%
+            progress = { 1.2f },
+            allowProgressOverflow = true,
             startAngle = 120f,
             endAngle = 60f,
-            colors =
-                ProgressIndicatorDefaults.colors(
-                    trackBrush =
-                        Brush.linearGradient(
-                            listOf(
-                                MaterialTheme.colorScheme.primary,
-                                MaterialTheme.colorScheme.surfaceContainer
-                            )
-                        )
-                )
         )
     }
 }
@@ -156,6 +145,14 @@
 
 @Sampled
 @Composable
+fun IndeterminateProgressIndicatorSample() {
+    Box(modifier = Modifier.fillMaxSize()) {
+        CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
+    }
+}
+
+@Sampled
+@Composable
 fun SegmentedProgressIndicatorSample() {
     Box(
         modifier =
diff --git a/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/TextToggleButtonSample.kt b/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/TextToggleButtonSample.kt
index 4da883e..3e6de06 100644
--- a/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/TextToggleButtonSample.kt
+++ b/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/TextToggleButtonSample.kt
@@ -17,6 +17,7 @@
 package androidx.wear.compose.material3.samples
 
 import androidx.annotation.Sampled
+import androidx.compose.foundation.interaction.MutableInteractionSource
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
@@ -27,13 +28,42 @@
 import androidx.wear.compose.material3.Text
 import androidx.wear.compose.material3.TextButtonDefaults
 import androidx.wear.compose.material3.TextToggleButton
+import androidx.wear.compose.material3.TextToggleButtonDefaults
 import androidx.wear.compose.material3.touchTargetAwareSize
 
 @Sampled
 @Composable
 fun TextToggleButtonSample() {
+    val interactionSource = remember { MutableInteractionSource() }
     var checked by remember { mutableStateOf(true) }
-    TextToggleButton(checked = checked, onCheckedChange = { checked = !checked }) {
+    TextToggleButton(
+        checked = checked,
+        onCheckedChange = { checked = !checked },
+        interactionSource = interactionSource,
+        shape =
+            TextButtonDefaults.animatedShape(
+                interactionSource = interactionSource,
+            ),
+    ) {
+        Text(text = if (checked) "On" else "Off")
+    }
+}
+
+@Sampled
+@Composable
+fun TextToggleButtonVariantSample() {
+    val interactionSource = remember { MutableInteractionSource() }
+    var checked by remember { mutableStateOf(true) }
+    TextToggleButton(
+        checked = checked,
+        onCheckedChange = { checked = !checked },
+        interactionSource = interactionSource,
+        shape =
+            TextToggleButtonDefaults.animatedToggleButtonShape(
+                interactionSource = interactionSource,
+                checked = checked
+            )
+    ) {
         Text(text = if (checked) "On" else "Off")
     }
 }
diff --git a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/AlertDialogScreenshotTest.kt b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/AlertDialogScreenshotTest.kt
index 7074d90..b593267 100644
--- a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/AlertDialogScreenshotTest.kt
+++ b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/AlertDialogScreenshotTest.kt
@@ -16,19 +16,14 @@
 
 package androidx.wear.compose.material3
 
-import android.content.res.Configuration
 import android.os.Build
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.size
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.filled.Favorite
 import androidx.compose.runtime.Composable
-import androidx.compose.runtime.CompositionLocalProvider
-import androidx.compose.runtime.remember
 import androidx.compose.testutils.assertAgainstGolden
 import androidx.compose.ui.Modifier
-import androidx.compose.ui.platform.LocalConfiguration
-import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.platform.testTag
 import androidx.compose.ui.test.captureToImage
 import androidx.compose.ui.test.junit4.ComposeContentTestRule
@@ -237,23 +232,8 @@
         messageText: String? = "Your battery is low. Turn on battery saver.",
         titleText: String = "Mobile network is not currently available"
     ) {
-        setContentWithTheme() {
-            val originalConfiguration = LocalConfiguration.current
-            val originalContext = LocalContext.current
-            val fixedScreenSizeConfiguration =
-                remember(originalConfiguration) {
-                    Configuration(originalConfiguration).apply {
-                        screenWidthDp = screenSize.size
-                        screenHeightDp = screenSize.size
-                        screenLayout = Configuration.SCREENLAYOUT_ROUND_YES
-                    }
-                }
-            originalContext.resources.configuration.updateFrom(fixedScreenSizeConfiguration)
-
-            CompositionLocalProvider(
-                LocalContext provides originalContext,
-                LocalConfiguration provides fixedScreenSizeConfiguration,
-            ) {
+        setContentWithTheme {
+            ScreenConfiguration(screenSize.size) {
                 AlertDialogHelper(
                     modifier = Modifier.size(screenSize.size.dp).testTag(TEST_TAG),
                     title = { Text(titleText) },
diff --git a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/ButtonGroupScreenshotTest.kt b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/ButtonGroupScreenshotTest.kt
index ca670d5..bfd690a 100644
--- a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/ButtonGroupScreenshotTest.kt
+++ b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/ButtonGroupScreenshotTest.kt
@@ -19,8 +19,6 @@
 import android.os.Build
 import androidx.compose.foundation.background
 import androidx.compose.foundation.interaction.MutableInteractionSource
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.runtime.remember
 import androidx.compose.testutils.assertAgainstGolden
 import androidx.compose.ui.Modifier
@@ -58,7 +56,7 @@
 
     @Test
     fun button_group_3_items_different_sizes() =
-        verifyScreenshot(numItems = 3, weight2 = 2f, weight3 = 3f)
+        verifyScreenshot(numItems = 3, minWidth1 = 24.dp, weight2 = 2f, weight3 = 3f)
 
     private fun verifyScreenshot(
         numItems: Int = 2,
@@ -73,12 +71,10 @@
     ) {
         require(numItems in 1..3)
         rule.setContentWithTheme {
-            val interactionSource1 = remember { MutableInteractionSource() }
-            val interactionSource2 = remember { MutableInteractionSource() }
-            val interactionSource3 = remember { MutableInteractionSource() }
-            Box(
-                modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)
-            ) {
+            ScreenConfiguration(SCREEN_SIZE_SMALL) {
+                val interactionSource1 = remember { MutableInteractionSource() }
+                val interactionSource2 = remember { MutableInteractionSource() }
+                val interactionSource3 = remember { MutableInteractionSource() }
                 ButtonGroup(
                     Modifier.testTag(TEST_TAG),
                     spacing = spacing,
diff --git a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/ConfirmationScreenshotTest.kt b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/ConfirmationScreenshotTest.kt
index 73e047e..ae7588d 100644
--- a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/ConfirmationScreenshotTest.kt
+++ b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/ConfirmationScreenshotTest.kt
@@ -16,18 +16,13 @@
 
 package androidx.wear.compose.material3
 
-import android.content.res.Configuration
 import android.os.Build
 import androidx.compose.foundation.layout.size
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.filled.Add
 import androidx.compose.runtime.Composable
-import androidx.compose.runtime.CompositionLocalProvider
-import androidx.compose.runtime.remember
 import androidx.compose.testutils.assertAgainstGolden
 import androidx.compose.ui.Modifier
-import androidx.compose.ui.platform.LocalConfiguration
-import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.platform.testTag
 import androidx.compose.ui.test.captureToImage
 import androidx.compose.ui.test.junit4.ComposeContentTestRule
@@ -181,21 +176,7 @@
         content: @Composable (modifier: Modifier) -> Unit
     ) {
         setContentWithTheme {
-            val originalConfiguration = LocalConfiguration.current
-            val originalContext = LocalContext.current
-            val fixedScreenSizeConfiguration =
-                remember(originalConfiguration) {
-                    Configuration(originalConfiguration).apply {
-                        screenWidthDp = screenSize.size
-                        screenHeightDp = screenSize.size
-                    }
-                }
-            originalContext.resources.configuration.updateFrom(fixedScreenSizeConfiguration)
-
-            CompositionLocalProvider(
-                LocalContext provides originalContext,
-                LocalConfiguration provides fixedScreenSizeConfiguration,
-            ) {
+            ScreenConfiguration(screenSize.size) {
                 content(Modifier.size(screenSize.size.dp).testTag(TEST_TAG))
             }
         }
diff --git a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/DatePickerScreenshotTest.kt b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/DatePickerScreenshotTest.kt
index 7f334a8..fbebf51 100644
--- a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/DatePickerScreenshotTest.kt
+++ b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/DatePickerScreenshotTest.kt
@@ -16,18 +16,12 @@
 
 package androidx.wear.compose.material3
 
-import android.content.res.Configuration
 import android.os.Build
 import androidx.annotation.RequiresApi
-import androidx.compose.foundation.background
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.size
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.CompositionLocalProvider
-import androidx.compose.runtime.remember
 import androidx.compose.testutils.assertAgainstGolden
 import androidx.compose.ui.Modifier
-import androidx.compose.ui.platform.LocalConfiguration
 import androidx.compose.ui.platform.LocalLayoutDirection
 import androidx.compose.ui.platform.testTag
 import androidx.compose.ui.test.SemanticsNodeInteraction
@@ -40,7 +34,6 @@
 import androidx.compose.ui.test.onNodeWithTag
 import androidx.compose.ui.test.performClick
 import androidx.compose.ui.unit.LayoutDirection
-import androidx.compose.ui.unit.dp
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.MediumTest
 import androidx.test.filters.SdkSuppress
@@ -323,23 +316,8 @@
     ) {
         val screenSizeDp = if (isLargeScreen) SCREENSHOT_SIZE_LARGE else SCREENSHOT_SIZE
         setContentWithTheme {
-            val originalConfiguration = LocalConfiguration.current
-            val fixedScreenSizeConfiguration =
-                remember(originalConfiguration) {
-                    Configuration(originalConfiguration).apply {
-                        screenWidthDp = screenSizeDp
-                        screenHeightDp = screenSizeDp
-                    }
-                }
-            CompositionLocalProvider(
-                LocalLayoutDirection provides layoutDirection,
-                LocalConfiguration provides fixedScreenSizeConfiguration
-            ) {
-                Box(
-                    modifier =
-                        Modifier.size(screenSizeDp.dp)
-                            .background(MaterialTheme.colorScheme.background)
-                ) {
+            ScreenConfiguration(screenSizeDp) {
+                CompositionLocalProvider(LocalLayoutDirection provides layoutDirection) {
                     content()
                 }
             }
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 d37e1e2..7d9b092 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
@@ -17,7 +17,6 @@
 package androidx.wear.compose.material3
 
 import android.os.Build
-import androidx.compose.foundation.background
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.height
@@ -55,23 +54,18 @@
     @get:Rule val testName = TestName()
 
     @Test
-    fun edge_button_default() =
-        verifyScreenshot() {
-            Box(Modifier.fillMaxSize(), contentAlignment = Alignment.BottomCenter) {
-                EdgeButton(
-                    onClick = { /* Do something */ },
-                    modifier = Modifier.testTag(TEST_TAG)
-                ) {
-                    BasicText("Text")
-                }
+    fun edge_button_default() = verifyScreenshot {
+        Box(Modifier.fillMaxSize(), contentAlignment = Alignment.BottomCenter) {
+            EdgeButton(onClick = { /* Do something */ }, modifier = Modifier.testTag(TEST_TAG)) {
+                BasicText("Text")
             }
         }
+    }
 
     @Test
-    fun edge_button_xsmall() =
-        verifyScreenshot() {
-            BasicEdgeButton(buttonHeight = ButtonDefaults.EdgeButtonHeightExtraSmall)
-        }
+    fun edge_button_xsmall() = verifyScreenshot {
+        BasicEdgeButton(buttonHeight = ButtonDefaults.EdgeButtonHeightExtraSmall)
+    }
 
     @Test
     fun edge_button_small() =
@@ -101,41 +95,34 @@
         }
 
     @Test
-    fun edge_button_small_space_limited() =
-        verifyScreenshot() {
-            BasicEdgeButton(
-                buttonHeight = ButtonDefaults.EdgeButtonHeightSmall,
-                constrainedHeight = 30.dp
-            )
-        }
+    fun edge_button_small_space_limited() = verifyScreenshot {
+        BasicEdgeButton(
+            buttonHeight = ButtonDefaults.EdgeButtonHeightSmall,
+            constrainedHeight = 30.dp
+        )
+    }
 
     @Test
-    fun edge_button_small_slightly_limited() =
-        verifyScreenshot() {
-            BasicEdgeButton(
-                buttonHeight = ButtonDefaults.EdgeButtonHeightSmall,
-                constrainedHeight = 40.dp
-            )
-        }
+    fun edge_button_small_slightly_limited() = verifyScreenshot {
+        BasicEdgeButton(
+            buttonHeight = ButtonDefaults.EdgeButtonHeightSmall,
+            constrainedHeight = 40.dp
+        )
+    }
 
     private val LONG_TEXT =
         "Lorem ipsum dolor sit amet, consectetur adipiscing elit, " +
             "sed do eiusmod tempor incididunt ut labore et dolore."
 
     @Test
-    fun edge_button_xsmall_long_text() =
-        verifyScreenshot() {
-            BasicEdgeButton(
-                buttonHeight = ButtonDefaults.EdgeButtonHeightExtraSmall,
-                text = LONG_TEXT
-            )
-        }
+    fun edge_button_xsmall_long_text() = verifyScreenshot {
+        BasicEdgeButton(buttonHeight = ButtonDefaults.EdgeButtonHeightExtraSmall, text = LONG_TEXT)
+    }
 
     @Test
-    fun edge_button_large_long_text() =
-        verifyScreenshot() {
-            BasicEdgeButton(buttonHeight = ButtonDefaults.EdgeButtonHeightLarge, text = LONG_TEXT)
-        }
+    fun edge_button_large_long_text() = verifyScreenshot {
+        BasicEdgeButton(buttonHeight = ButtonDefaults.EdgeButtonHeightLarge, text = LONG_TEXT)
+    }
 
     @Composable
     private fun BasicEdgeButton(
@@ -164,11 +151,8 @@
         content: @Composable () -> Unit
     ) {
         rule.setContentWithTheme {
-            CompositionLocalProvider(LocalLayoutDirection provides layoutDirection) {
-                Box(
-                    modifier =
-                        Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)
-                ) {
+            ScreenConfiguration(SCREEN_SIZE_SMALL) {
+                CompositionLocalProvider(LocalLayoutDirection provides layoutDirection) {
                     content()
                 }
             }
diff --git a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/IconButtonTest.kt b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/IconButtonTest.kt
index 176847a..f95bc67b 100644
--- a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/IconButtonTest.kt
+++ b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/IconButtonTest.kt
@@ -339,7 +339,7 @@
             status = Status.Enabled,
             colors = { IconButtonDefaults.iconButtonColors() },
             expectedContainerColor = { Color.Transparent },
-            expectedContentColor = { MaterialTheme.colorScheme.onSurface }
+            expectedContentColor = { MaterialTheme.colorScheme.primary }
         )
     }
 
@@ -415,7 +415,7 @@
             status = Status.Enabled,
             colors = { IconButtonDefaults.filledTonalIconButtonColors() },
             expectedContainerColor = { MaterialTheme.colorScheme.surfaceContainer },
-            expectedContentColor = { MaterialTheme.colorScheme.onSurfaceVariant }
+            expectedContentColor = { MaterialTheme.colorScheme.primary }
         )
     }
 
@@ -441,7 +441,7 @@
             status = Status.Enabled,
             colors = { IconButtonDefaults.outlinedIconButtonColors() },
             expectedContainerColor = { Color.Transparent },
-            expectedContentColor = { MaterialTheme.colorScheme.onSurface }
+            expectedContentColor = { MaterialTheme.colorScheme.primary }
         )
     }
 
diff --git a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/IconToggleButtonScreenshotTest.kt b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/IconToggleButtonScreenshotTest.kt
index 04eb12e..630a7be 100644
--- a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/IconToggleButtonScreenshotTest.kt
+++ b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/IconToggleButtonScreenshotTest.kt
@@ -17,18 +17,33 @@
 package androidx.wear.compose.material3
 
 import android.os.Build
+import androidx.compose.foundation.background
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.interaction.PressInteraction
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.offset
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.outlined.Star
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.remember
+import androidx.compose.testutils.assertAgainstGolden
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.platform.LocalLayoutDirection
 import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.captureToImage
 import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.unit.LayoutDirection
 import androidx.compose.ui.unit.dp
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.MediumTest
 import androidx.test.filters.SdkSuppress
 import androidx.test.screenshot.AndroidXScreenshotTestRule
+import org.junit.Ignore
 import org.junit.Rule
 import org.junit.Test
 import org.junit.rules.TestName
@@ -84,17 +99,95 @@
             content = { sampleIconToggleButton(modifier = Modifier.offset(10.dp)) }
         )
 
+    @Ignore("TODO: b/345199060 work out how to show pressed state in test")
+    @Test
+    fun animatedIconToggleButtonPressed() {
+        rule.setContentWithTheme {
+            CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) {
+                Box(
+                    modifier =
+                        Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)
+                ) {
+                    val interactionSource = remember {
+                        MutableInteractionSource().apply {
+                            tryEmit(PressInteraction.Press(Offset(0f, 0f)))
+                        }
+                    }
+                    sampleIconToggleButton(
+                        checked = false,
+                        shape =
+                            IconToggleButtonDefaults.animatedToggleButtonShape(
+                                interactionSource = interactionSource,
+                                checked = false
+                            ),
+                        interactionSource = interactionSource
+                    )
+                }
+            }
+        }
+
+        rule.mainClock.autoAdvance = false
+        rule.mainClock.advanceTimeBy(500)
+
+        rule
+            .onNodeWithTag(TEST_TAG)
+            .captureToImage()
+            .assertAgainstGolden(rule = screenshotRule, goldenIdentifier = testName.methodName)
+    }
+
+    @Test
+    fun animatedIconToggleButtonChecked() =
+        rule.verifyScreenshot(
+            methodName = testName.methodName,
+            screenshotRule = screenshotRule,
+            content = {
+                val interactionSource = remember { MutableInteractionSource() }
+                sampleIconToggleButton(
+                    checked = true,
+                    shape =
+                        IconToggleButtonDefaults.animatedToggleButtonShape(
+                            interactionSource = interactionSource,
+                            checked = true
+                        ),
+                    interactionSource = interactionSource
+                )
+            }
+        )
+
+    @Test
+    fun animatedIconToggleButtonUnchecked() =
+        rule.verifyScreenshot(
+            methodName = testName.methodName,
+            screenshotRule = screenshotRule,
+            content = {
+                val interactionSource = remember { MutableInteractionSource() }
+                sampleIconToggleButton(
+                    checked = false,
+                    shape =
+                        IconToggleButtonDefaults.animatedToggleButtonShape(
+                            interactionSource = interactionSource,
+                            checked = false
+                        ),
+                    interactionSource = interactionSource
+                )
+            }
+        )
+
     @Composable
     private fun sampleIconToggleButton(
         enabled: Boolean = true,
         checked: Boolean = true,
-        modifier: Modifier = Modifier
+        modifier: Modifier = Modifier,
+        shape: Shape = TextButtonDefaults.shape,
+        interactionSource: MutableInteractionSource? = null
     ) {
         IconToggleButton(
             checked = checked,
             onCheckedChange = {},
             enabled = enabled,
-            modifier = modifier.testTag(TEST_TAG)
+            modifier = modifier.testTag(TEST_TAG),
+            shape = shape,
+            interactionSource = interactionSource
         ) {
             Icon(imageVector = Icons.Outlined.Star, contentDescription = "Favourite")
         }
diff --git a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/IconToggleButtonTest.kt b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/IconToggleButtonTest.kt
index 9c0a3f0..6ccc0ff 100644
--- a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/IconToggleButtonTest.kt
+++ b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/IconToggleButtonTest.kt
@@ -392,7 +392,7 @@
         rule.verifyIconToggleButtonColors(
             status = Status.Enabled,
             checked = true,
-            colors = { IconButtonDefaults.iconToggleButtonColors() },
+            colors = { IconToggleButtonDefaults.iconToggleButtonColors() },
             containerColor = { MaterialTheme.colorScheme.primary },
             contentColor = { MaterialTheme.colorScheme.onPrimary }
         )
@@ -403,7 +403,7 @@
         rule.verifyIconToggleButtonColors(
             status = Status.Enabled,
             checked = false,
-            colors = { IconButtonDefaults.iconToggleButtonColors() },
+            colors = { IconToggleButtonDefaults.iconToggleButtonColors() },
             containerColor = { MaterialTheme.colorScheme.surfaceContainer },
             contentColor = { MaterialTheme.colorScheme.onSurfaceVariant }
         )
@@ -414,7 +414,7 @@
         rule.verifyIconToggleButtonColors(
             status = Status.Disabled,
             checked = false,
-            colors = { IconButtonDefaults.iconToggleButtonColors() },
+            colors = { IconToggleButtonDefaults.iconToggleButtonColors() },
             containerColor = {
                 MaterialTheme.colorScheme.onSurface.toDisabledColor(DisabledContainerAlpha)
             },
@@ -427,7 +427,7 @@
         rule.verifyIconToggleButtonColors(
             status = Status.Disabled,
             checked = true,
-            colors = { IconButtonDefaults.iconToggleButtonColors() },
+            colors = { IconToggleButtonDefaults.iconToggleButtonColors() },
             containerColor = {
                 MaterialTheme.colorScheme.onSurface.toDisabledColor(DisabledContainerAlpha)
             },
@@ -443,7 +443,9 @@
             status = Status.Enabled,
             checked = true,
             colors = {
-                IconButtonDefaults.iconToggleButtonColors(checkedContainerColor = overrideColor)
+                IconToggleButtonDefaults.iconToggleButtonColors(
+                    checkedContainerColor = overrideColor
+                )
             },
             containerColor = { overrideColor },
             contentColor = { MaterialTheme.colorScheme.onPrimary }
@@ -459,7 +461,7 @@
             status = Status.Enabled,
             checked = true,
             colors = {
-                IconButtonDefaults.iconToggleButtonColors(checkedContentColor = overrideColor)
+                IconToggleButtonDefaults.iconToggleButtonColors(checkedContentColor = overrideColor)
             },
             containerColor = { MaterialTheme.colorScheme.primary },
             contentColor = { overrideColor }
@@ -475,7 +477,9 @@
             status = Status.Enabled,
             checked = false,
             colors = {
-                IconButtonDefaults.iconToggleButtonColors(uncheckedContainerColor = overrideColor)
+                IconToggleButtonDefaults.iconToggleButtonColors(
+                    uncheckedContainerColor = overrideColor
+                )
             },
             containerColor = { overrideColor },
             contentColor = { MaterialTheme.colorScheme.onSurfaceVariant }
@@ -491,7 +495,9 @@
             status = Status.Enabled,
             checked = false,
             colors = {
-                IconButtonDefaults.iconToggleButtonColors(uncheckedContentColor = overrideColor)
+                IconToggleButtonDefaults.iconToggleButtonColors(
+                    uncheckedContentColor = overrideColor
+                )
             },
             containerColor = { MaterialTheme.colorScheme.surfaceContainer },
             contentColor = { overrideColor }
@@ -507,7 +513,7 @@
             status = Status.Disabled,
             checked = true,
             colors = {
-                IconButtonDefaults.iconToggleButtonColors(
+                IconToggleButtonDefaults.iconToggleButtonColors(
                     // Apply the content color override for the content alpha to be applied
                     disabledCheckedContainerColor = overrideColor
                 )
@@ -526,7 +532,7 @@
             status = Status.Disabled,
             checked = true,
             colors = {
-                IconButtonDefaults.iconToggleButtonColors(
+                IconToggleButtonDefaults.iconToggleButtonColors(
                     // Apply the content color override for the content alpha to be applied
                     disabledCheckedContentColor = overrideColor
                 )
@@ -547,7 +553,7 @@
             status = Status.Disabled,
             checked = false,
             colors = {
-                IconButtonDefaults.iconToggleButtonColors(
+                IconToggleButtonDefaults.iconToggleButtonColors(
                     // Apply the content color override for the content alpha to be applied
                     disabledUncheckedContainerColor = overrideColor
                 )
@@ -566,7 +572,7 @@
             status = Status.Disabled,
             checked = false,
             colors = {
-                IconButtonDefaults.iconToggleButtonColors(
+                IconToggleButtonDefaults.iconToggleButtonColors(
                     // Apply the content color override for the content alpha to be applied
                     disabledUncheckedContentColor = overrideColor
                 )
@@ -643,7 +649,7 @@
 
     @Composable
     private fun shapeColor(): Color {
-        return IconButtonDefaults.iconToggleButtonColors()
+        return IconToggleButtonDefaults.iconToggleButtonColors()
             .containerColor(enabled = true, checked = true)
             .value
     }
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 c49f71f..e3af5a4 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
@@ -16,6 +16,7 @@
 
 package androidx.wear.compose.material3
 
+import android.content.res.Configuration
 import android.os.Build
 import androidx.annotation.RequiresApi
 import androidx.compose.foundation.Image
@@ -26,11 +27,13 @@
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.fillMaxHeight
 import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.size
 import androidx.compose.foundation.layout.sizeIn
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.outlined.Add
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.remember
 import androidx.compose.testutils.assertAgainstGolden
 import androidx.compose.testutils.assertContainsColor
 import androidx.compose.ui.Alignment
@@ -41,6 +44,8 @@
 import androidx.compose.ui.graphics.compositeOver
 import androidx.compose.ui.graphics.toPixelMap
 import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalConfiguration
+import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.platform.LocalLayoutDirection
 import androidx.compose.ui.platform.testTag
 import androidx.compose.ui.semantics.SemanticsActions
@@ -89,6 +94,33 @@
     SQUARE_DEVICE(false)
 }
 
+@Composable
+fun ScreenConfiguration(screenSizeDp: Int, content: @Composable () -> Unit) {
+    val originalConfiguration = LocalConfiguration.current
+    val originalContext = LocalContext.current
+
+    val fixedScreenSizeConfiguration =
+        remember(originalConfiguration) {
+            Configuration(originalConfiguration).apply {
+                screenWidthDp = screenSizeDp
+                screenHeightDp = screenSizeDp
+            }
+        }
+    originalContext.resources.configuration.updateFrom(fixedScreenSizeConfiguration)
+
+    CompositionLocalProvider(
+        LocalContext provides originalContext,
+        LocalConfiguration provides fixedScreenSizeConfiguration
+    ) {
+        Box(
+            modifier =
+                Modifier.size(screenSizeDp.dp).background(MaterialTheme.colorScheme.background),
+        ) {
+            content()
+        }
+    }
+}
+
 /**
  * Valid characters for golden identifiers are [A-Za-z0-9_-] TestParameterInjector adds '[' +
  * parameter_values + ']' to the test name.
diff --git a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/OpenOnPhoneDialogScreenshotTest.kt b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/OpenOnPhoneDialogScreenshotTest.kt
index 74568a6..82d14ee 100644
--- a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/OpenOnPhoneDialogScreenshotTest.kt
+++ b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/OpenOnPhoneDialogScreenshotTest.kt
@@ -16,15 +16,10 @@
 
 package androidx.wear.compose.material3
 
-import android.content.res.Configuration
 import android.os.Build
 import androidx.compose.foundation.layout.size
-import androidx.compose.runtime.CompositionLocalProvider
-import androidx.compose.runtime.remember
 import androidx.compose.testutils.assertAgainstGolden
 import androidx.compose.ui.Modifier
-import androidx.compose.ui.platform.LocalConfiguration
-import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.platform.testTag
 import androidx.compose.ui.test.captureToImage
 import androidx.compose.ui.test.junit4.ComposeContentTestRule
@@ -79,21 +74,7 @@
     ) {
         rule.mainClock.autoAdvance = false
         setContentWithTheme {
-            val originalConfiguration = LocalConfiguration.current
-            val originalContext = LocalContext.current
-            val fixedScreenSizeConfiguration =
-                remember(originalConfiguration) {
-                    Configuration(originalConfiguration).apply {
-                        screenWidthDp = screenSize.size
-                        screenHeightDp = screenSize.size
-                    }
-                }
-            originalContext.resources.configuration.updateFrom(fixedScreenSizeConfiguration)
-
-            CompositionLocalProvider(
-                LocalContext provides originalContext,
-                LocalConfiguration provides fixedScreenSizeConfiguration,
-            ) {
+            ScreenConfiguration(screenSize.size) {
                 OpenOnPhoneDialog(
                     show = true,
                     modifier = Modifier.size(screenSize.size.dp).testTag(TEST_TAG),
diff --git a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/ProgressIndicatorScreenshotTest.kt b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/ProgressIndicatorScreenshotTest.kt
index 37798e1..66b4f3a 100644
--- a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/ProgressIndicatorScreenshotTest.kt
+++ b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/ProgressIndicatorScreenshotTest.kt
@@ -16,7 +16,6 @@
 
 package androidx.wear.compose.material3
 
-import android.content.res.Configuration
 import android.os.Build
 import androidx.compose.foundation.background
 import androidx.compose.foundation.layout.Box
@@ -28,14 +27,11 @@
 import androidx.compose.material.icons.filled.PlayArrow
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.CompositionLocalProvider
-import androidx.compose.runtime.remember
 import androidx.compose.testutils.assertAgainstGolden
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.draw.clip
-import androidx.compose.ui.graphics.Brush
 import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.platform.LocalConfiguration
 import androidx.compose.ui.platform.LocalLayoutDirection
 import androidx.compose.ui.platform.testTag
 import androidx.compose.ui.test.captureToImage
@@ -43,17 +39,18 @@
 import androidx.compose.ui.test.onNodeWithTag
 import androidx.compose.ui.unit.LayoutDirection
 import androidx.compose.ui.unit.dp
-import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.MediumTest
 import androidx.test.filters.SdkSuppress
 import androidx.test.screenshot.AndroidXScreenshotTestRule
+import com.google.testing.junit.testparameterinjector.TestParameter
+import com.google.testing.junit.testparameterinjector.TestParameterInjector
 import org.junit.Rule
 import org.junit.Test
 import org.junit.rules.TestName
 import org.junit.runner.RunWith
 
 @MediumTest
-@RunWith(AndroidJUnit4::class)
+@RunWith(TestParameterInjector::class)
 @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
 class ProgressIndicatorScreenshotTest {
     @get:Rule val rule = createComposeRule()
@@ -63,18 +60,8 @@
     @get:Rule val testName = TestName()
 
     @Test
-    fun progress_indicator_fullscreen() = verifyProgressIndicatorScreenshot {
-        CircularProgressIndicator(
-            progress = { 0.25f },
-            modifier = Modifier.aspectRatio(1f).testTag(TEST_TAG),
-            startAngle = 120f,
-            endAngle = 60f,
-        )
-    }
-
-    @Test
-    fun progress_indicator_fullscreen_large_screen() {
-        verifyProgressIndicatorScreenshot(isLargeScreen = true) {
+    fun progress_indicator_fullscreen(@TestParameter screenSize: ScreenSize) =
+        verifyProgressIndicatorScreenshot(screenSize = screenSize) {
             CircularProgressIndicator(
                 progress = { 0.25f },
                 modifier = Modifier.aspectRatio(1f).testTag(TEST_TAG),
@@ -82,26 +69,10 @@
                 endAngle = 60f,
             )
         }
-    }
 
     @Test
-    fun progress_indicator_custom_color() = verifyProgressIndicatorScreenshot {
-        CircularProgressIndicator(
-            progress = { 0.75f },
-            modifier = Modifier.size(200.dp).testTag(TEST_TAG),
-            startAngle = 120f,
-            endAngle = 60f,
-            colors =
-                ProgressIndicatorDefaults.colors(
-                    indicatorColor = Color.Green,
-                    trackColor = Color.Red.copy(alpha = 0.5f)
-                )
-        )
-    }
-
-    @Test
-    fun progress_indicator_custom_color_large_screen() {
-        verifyProgressIndicatorScreenshot(isLargeScreen = true) {
+    fun progress_indicator_custom_color(@TestParameter screenSize: ScreenSize) =
+        verifyProgressIndicatorScreenshot(screenSize = screenSize) {
             CircularProgressIndicator(
                 progress = { 0.75f },
                 modifier = Modifier.size(200.dp).testTag(TEST_TAG),
@@ -114,36 +85,10 @@
                     )
             )
         }
-    }
 
     @Test
-    fun progress_indicator_wrapping_media_button() = verifyProgressIndicatorScreenshot {
-        val progressPadding = 4.dp
-        Box(
-            modifier =
-                Modifier.size(IconButtonDefaults.DefaultButtonSize + progressPadding)
-                    .testTag(TEST_TAG)
-        ) {
-            CircularProgressIndicator(progress = { 0.75f }, strokeWidth = progressPadding)
-            IconButton(
-                modifier =
-                    Modifier.align(Alignment.Center)
-                        .padding(progressPadding)
-                        .clip(CircleShape)
-                        .background(MaterialTheme.colorScheme.surfaceContainer),
-                onClick = {}
-            ) {
-                Icon(
-                    imageVector = Icons.Filled.PlayArrow,
-                    contentDescription = "Play/pause button icon"
-                )
-            }
-        }
-    }
-
-    @Test
-    fun progress_indicator_wrapping_media_button_large_screen() {
-        verifyProgressIndicatorScreenshot(isLargeScreen = true) {
+    fun progress_indicator_wrapping_media_button(@TestParameter screenSize: ScreenSize) {
+        verifyProgressIndicatorScreenshot(screenSize = screenSize) {
             val progressPadding = 4.dp
             Box(
                 modifier =
@@ -169,61 +114,33 @@
     }
 
     @Test
-    fun progress_indicator_overflow() = verifyProgressIndicatorScreenshot {
-        CircularProgressIndicator(
-            progress = { 0.2f },
-            modifier = Modifier.aspectRatio(1f).testTag(TEST_TAG),
-            startAngle = 120f,
-            endAngle = 60f,
-            colors =
-                ProgressIndicatorDefaults.colors(
-                    trackBrush =
-                        Brush.linearGradient(
-                            listOf(
-                                MaterialTheme.colorScheme.surfaceContainer,
-                                MaterialTheme.colorScheme.primary
-                            )
-                        )
-                )
-        )
-    }
-
-    @Test
-    fun progress_indicator_overflow_large_screen() {
-        verifyProgressIndicatorScreenshot(isLargeScreen = true) {
+    fun progress_indicator_overflow(@TestParameter screenSize: ScreenSize) =
+        verifyProgressIndicatorScreenshot(screenSize = screenSize) {
             CircularProgressIndicator(
-                progress = { 0.2f },
+                progress = { 1.2f },
                 modifier = Modifier.aspectRatio(1f).testTag(TEST_TAG),
                 startAngle = 120f,
                 endAngle = 60f,
-                colors =
-                    ProgressIndicatorDefaults.colors(
-                        trackBrush =
-                            Brush.linearGradient(
-                                listOf(
-                                    MaterialTheme.colorScheme.surfaceContainer,
-                                    MaterialTheme.colorScheme.primary
-                                )
-                            )
-                    )
+                allowProgressOverflow = true
             )
         }
-    }
 
     @Test
-    fun progress_indicator_disabled() = verifyProgressIndicatorScreenshot {
-        CircularProgressIndicator(
-            progress = { 0.75f },
-            modifier = Modifier.size(200.dp).testTag(TEST_TAG),
-            startAngle = 120f,
-            endAngle = 60f,
-            enabled = false,
-        )
-    }
+    fun progress_indicator_overflow_disabled(@TestParameter screenSize: ScreenSize) =
+        verifyProgressIndicatorScreenshot(screenSize = screenSize) {
+            CircularProgressIndicator(
+                progress = { 1.2f },
+                modifier = Modifier.aspectRatio(1f).testTag(TEST_TAG),
+                startAngle = 120f,
+                endAngle = 60f,
+                allowProgressOverflow = true,
+                enabled = false
+            )
+        }
 
     @Test
-    fun progress_indicator_disabled_large_screen() {
-        verifyProgressIndicatorScreenshot(isLargeScreen = true) {
+    fun progress_indicator_disabled(@TestParameter screenSize: ScreenSize) =
+        verifyProgressIndicatorScreenshot(screenSize = screenSize) {
             CircularProgressIndicator(
                 progress = { 0.75f },
                 modifier = Modifier.size(200.dp).testTag(TEST_TAG),
@@ -232,22 +149,10 @@
                 enabled = false,
             )
         }
-    }
 
     @Test
-    fun segmented_progress_indicator_with_progress() = verifyProgressIndicatorScreenshot {
-        SegmentedCircularProgressIndicator(
-            progress = { 0.5f },
-            segmentCount = 5,
-            modifier = Modifier.aspectRatio(1f).testTag(TEST_TAG),
-            startAngle = 120f,
-            endAngle = 60f,
-        )
-    }
-
-    @Test
-    fun segmented_progress_indicator_with_progress_large_screen() {
-        verifyProgressIndicatorScreenshot(isLargeScreen = true) {
+    fun segmented_progress_indicator_with_progress(@TestParameter screenSize: ScreenSize) =
+        verifyProgressIndicatorScreenshot(screenSize = screenSize) {
             SegmentedCircularProgressIndicator(
                 progress = { 0.5f },
                 segmentCount = 5,
@@ -256,23 +161,37 @@
                 endAngle = 60f,
             )
         }
-    }
 
     @Test
-    fun segmented_progress_indicator_with_progress_disabled() = verifyProgressIndicatorScreenshot {
-        SegmentedCircularProgressIndicator(
-            progress = { 0.5f },
-            segmentCount = 5,
-            modifier = Modifier.aspectRatio(1f).testTag(TEST_TAG),
-            startAngle = 120f,
-            endAngle = 60f,
-            enabled = false,
-        )
-    }
+    fun segmented_progress_indicator_overflow(@TestParameter screenSize: ScreenSize) =
+        verifyProgressIndicatorScreenshot(screenSize = screenSize) {
+            SegmentedCircularProgressIndicator(
+                progress = { 1.2f },
+                segmentCount = 5,
+                modifier = Modifier.aspectRatio(1f).testTag(TEST_TAG),
+                startAngle = 120f,
+                endAngle = 60f,
+                allowProgressOverflow = true,
+            )
+        }
 
     @Test
-    fun segmented_progress_indicator_with_progress_disabled_large_screen() {
-        verifyProgressIndicatorScreenshot(isLargeScreen = true) {
+    fun segmented_progress_indicator_overflow_disabled(@TestParameter screenSize: ScreenSize) =
+        verifyProgressIndicatorScreenshot(screenSize = screenSize) {
+            SegmentedCircularProgressIndicator(
+                progress = { 1.2f },
+                segmentCount = 5,
+                modifier = Modifier.aspectRatio(1f).testTag(TEST_TAG),
+                startAngle = 120f,
+                endAngle = 60f,
+                allowProgressOverflow = true,
+                enabled = false,
+            )
+        }
+
+    @Test
+    fun segmented_progress_indicator_with_progress_disabled(@TestParameter screenSize: ScreenSize) =
+        verifyProgressIndicatorScreenshot(screenSize = screenSize) {
             SegmentedCircularProgressIndicator(
                 progress = { 0.5f },
                 segmentCount = 5,
@@ -282,22 +201,10 @@
                 enabled = false,
             )
         }
-    }
 
     @Test
-    fun segmented_progress_indicator_on_off() = verifyProgressIndicatorScreenshot {
-        SegmentedCircularProgressIndicator(
-            segmentCount = 6,
-            completed = { it % 2 == 0 },
-            modifier = Modifier.aspectRatio(1f).testTag(TEST_TAG),
-            startAngle = 120f,
-            endAngle = 60f,
-        )
-    }
-
-    @Test
-    fun segmented_progress_indicator_on_off_large_screen() {
-        verifyProgressIndicatorScreenshot(isLargeScreen = true) {
+    fun segmented_progress_indicator_on_off(@TestParameter screenSize: ScreenSize) =
+        verifyProgressIndicatorScreenshot(screenSize = screenSize) {
             SegmentedCircularProgressIndicator(
                 segmentCount = 6,
                 completed = { it % 2 == 0 },
@@ -306,23 +213,10 @@
                 endAngle = 60f,
             )
         }
-    }
 
     @Test
-    fun segmented_progress_indicator_on_off_disabled() = verifyProgressIndicatorScreenshot {
-        SegmentedCircularProgressIndicator(
-            segmentCount = 6,
-            completed = { it % 2 == 0 },
-            modifier = Modifier.aspectRatio(1f).testTag(TEST_TAG),
-            startAngle = 120f,
-            endAngle = 60f,
-            enabled = false,
-        )
-    }
-
-    @Test
-    fun segmented_progress_indicator_on_off_disabled_large_screen() {
-        verifyProgressIndicatorScreenshot(isLargeScreen = true) {
+    fun segmented_progress_indicator_on_off_disabled(@TestParameter screenSize: ScreenSize) =
+        verifyProgressIndicatorScreenshot(screenSize = screenSize) {
             SegmentedCircularProgressIndicator(
                 segmentCount = 6,
                 completed = { it % 2 == 0 },
@@ -332,35 +226,34 @@
                 enabled = false,
             )
         }
-    }
+
+    @Test
+    fun progress_indicator_indeterminate(@TestParameter screenSize: ScreenSize) =
+        verifyProgressIndicatorScreenshot(screenSize = screenSize) {
+            CircularProgressIndicator(
+                modifier =
+                    Modifier.size(
+                            CircularProgressIndicatorDefaults.IndeterminateCircularIndicatorDiameter
+                        )
+                        .testTag(TEST_TAG),
+            )
+        }
 
     private fun verifyProgressIndicatorScreenshot(
-        isLargeScreen: Boolean = false,
+        screenSize: ScreenSize,
         content: @Composable () -> Unit
     ) {
-        val screenSizeDp = if (isLargeScreen) SCREEN_SIZE_LARGE else SCREEN_SIZE_SMALL
-
-        rule.setContentWithTheme(modifier = Modifier.background(Color.Black)) {
-            val originalConfiguration = LocalConfiguration.current
-            val fixedScreenSizeConfiguration =
-                remember(originalConfiguration) {
-                    Configuration(originalConfiguration).apply {
-                        screenWidthDp = screenSizeDp
-                        screenHeightDp = screenSizeDp
-                    }
+        rule.setContentWithTheme {
+            ScreenConfiguration(screenSize.size) {
+                CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) {
+                    content()
                 }
-
-            CompositionLocalProvider(
-                LocalLayoutDirection provides LayoutDirection.Ltr,
-                LocalConfiguration provides fixedScreenSizeConfiguration
-            ) {
-                Box(modifier = Modifier.size(screenSizeDp.dp).background(Color.Black)) { content() }
             }
         }
 
         rule
             .onNodeWithTag(TEST_TAG)
             .captureToImage()
-            .assertAgainstGolden(screenshotRule, testName.methodName)
+            .assertAgainstGolden(screenshotRule, testName.goldenIdentifier())
     }
 }
diff --git a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/ProgressIndicatorTest.kt b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/ProgressIndicatorTest.kt
index 2165861..2031ed4 100644
--- a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/ProgressIndicatorTest.kt
+++ b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/ProgressIndicatorTest.kt
@@ -77,7 +77,7 @@
     fun contains_progress_color() {
         setContentWithTheme {
             CircularProgressIndicator(
-                modifier = Modifier.testTag(TEST_TAG),
+                modifier = Modifier.size(SCREEN_SIZE_LARGE.dp).testTag(TEST_TAG),
                 progress = { 1f },
                 colors =
                     ProgressIndicatorDefaults.colors(
@@ -100,7 +100,7 @@
     fun contains_progress_incomplete_color() {
         setContentWithTheme {
             CircularProgressIndicator(
-                modifier = Modifier.testTag(TEST_TAG),
+                modifier = Modifier.size(SCREEN_SIZE_LARGE.dp).testTag(TEST_TAG),
                 progress = { 0f },
                 colors =
                     ProgressIndicatorDefaults.colors(
@@ -123,7 +123,7 @@
     fun change_start_end_angle() {
         setContentWithTheme {
             CircularProgressIndicator(
-                modifier = Modifier.testTag(TEST_TAG),
+                modifier = Modifier.size(SCREEN_SIZE_LARGE.dp).testTag(TEST_TAG),
                 progress = { 0.5f },
                 startAngle = 0f,
                 endAngle = 180f,
@@ -152,7 +152,7 @@
     fun set_small_progress_value() {
         setContentWithTheme {
             CircularProgressIndicator(
-                modifier = Modifier.testTag(TEST_TAG),
+                modifier = Modifier.size(SCREEN_SIZE_LARGE.dp).testTag(TEST_TAG),
                 progress = { 0.02f },
                 colors =
                     ProgressIndicatorDefaults.colors(
@@ -178,7 +178,7 @@
     fun set_small_stroke_width() {
         setContentWithTheme {
             CircularProgressIndicator(
-                modifier = Modifier.testTag(TEST_TAG),
+                modifier = Modifier.size(SCREEN_SIZE_LARGE.dp).testTag(TEST_TAG),
                 progress = { 0.5f },
                 strokeWidth = CircularProgressIndicatorDefaults.smallStrokeWidth,
                 colors =
@@ -204,7 +204,7 @@
     fun set_large_stroke_width() {
         setContentWithTheme {
             CircularProgressIndicator(
-                modifier = Modifier.testTag(TEST_TAG),
+                modifier = Modifier.size(SCREEN_SIZE_LARGE.dp).testTag(TEST_TAG),
                 progress = { 0.5f },
                 strokeWidth = CircularProgressIndicatorDefaults.largeStrokeWidth,
                 colors =
diff --git a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/SegmentedCircularProgressIndicatorTest.kt b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/SegmentedCircularProgressIndicatorTest.kt
index 47d4a80..2182465 100644
--- a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/SegmentedCircularProgressIndicatorTest.kt
+++ b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/SegmentedCircularProgressIndicatorTest.kt
@@ -312,6 +312,98 @@
         rule.onNodeWithTag(TEST_TAG).captureToImage().assertContainsColor(Color.Green)
     }
 
+    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
+    @Test
+    fun progress_overflow_contains_overflow_color() {
+        val customIndicatorColor = Color.Yellow
+        val customTrackColor = Color.Red
+        val customOverflowTrackColor = Color.Blue
+
+        setContentWithTheme {
+            SegmentedCircularProgressIndicator(
+                progress = { 1.5f },
+                segmentCount = 5,
+                modifier = Modifier.testTag(TEST_TAG),
+                colors =
+                    ProgressIndicatorDefaults.colors(
+                        indicatorColor = customIndicatorColor,
+                        trackColor = customTrackColor,
+                        overflowTrackColor = customOverflowTrackColor
+                    ),
+                allowProgressOverflow = true
+            )
+        }
+        rule.waitForIdle()
+        // When overflow is allowed then over-achieved (>100%) progress values the track should be
+        // in overflowTrackColor and the indicator should still be in indicatorColor.
+        rule.onNodeWithTag(TEST_TAG).captureToImage().assertDoesNotContainColor(customTrackColor)
+        rule.onNodeWithTag(TEST_TAG).captureToImage().assertContainsColor(customIndicatorColor)
+        rule.onNodeWithTag(TEST_TAG).captureToImage().assertContainsColor(customOverflowTrackColor)
+    }
+
+    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
+    @Test
+    fun progress_overflow_not_allowed_contains_only_indicator_color() {
+        val customIndicatorColor = Color.Yellow
+        val customTrackColor = Color.Red
+        val customOverflowTrackColor = Color.Blue
+
+        setContentWithTheme {
+            SegmentedCircularProgressIndicator(
+                progress = { 1.5f },
+                segmentCount = 5,
+                modifier = Modifier.testTag(TEST_TAG),
+                colors =
+                    ProgressIndicatorDefaults.colors(
+                        indicatorColor = customIndicatorColor,
+                        trackColor = customTrackColor,
+                        overflowTrackColor = customOverflowTrackColor
+                    ),
+                allowProgressOverflow = false
+            )
+        }
+        rule.waitForIdle()
+        // When progress overflow is disabled, then overflow progress values should be coerced to 1
+        // and overflowTrackColor should not appear, only customIndicatorColor.
+        rule
+            .onNodeWithTag(TEST_TAG)
+            .captureToImage()
+            .assertDoesNotContainColor(customOverflowTrackColor)
+        rule.onNodeWithTag(TEST_TAG).captureToImage().assertDoesNotContainColor(customTrackColor)
+        rule.onNodeWithTag(TEST_TAG).captureToImage().assertContainsColor(customIndicatorColor)
+    }
+
+    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
+    @Test
+    fun progress_overflow_200_percent_contains_only_indicator_color() {
+        val customIndicatorColor = Color.Yellow
+        val customTrackColor = Color.Red
+        val customOverflowTrackColor = Color.Blue
+
+        setContentWithTheme {
+            SegmentedCircularProgressIndicator(
+                progress = { 2.0f },
+                segmentCount = 5,
+                modifier = Modifier.testTag(TEST_TAG),
+                colors =
+                    ProgressIndicatorDefaults.colors(
+                        indicatorColor = customIndicatorColor,
+                        trackColor = customTrackColor,
+                        overflowTrackColor = customOverflowTrackColor,
+                    ),
+            )
+        }
+        rule.waitForIdle()
+        // For 200% over-achieved progress the indicator should take the whole progress
+        // circle, just like for 100%.
+        rule.onNodeWithTag(TEST_TAG).captureToImage().assertDoesNotContainColor(customTrackColor)
+        rule
+            .onNodeWithTag(TEST_TAG)
+            .captureToImage()
+            .assertDoesNotContainColor(customOverflowTrackColor)
+        rule.onNodeWithTag(TEST_TAG).captureToImage().assertContainsColor(customIndicatorColor)
+    }
+
     private fun setContentWithTheme(composable: @Composable BoxScope.() -> Unit) {
         // Use constant size modifier to limit relative color percentage ranges.
         rule.setContentWithTheme(modifier = Modifier.size(204.dp), composable = composable)
diff --git a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/TextButtonTest.kt b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/TextButtonTest.kt
index f130f2a..70ae3f4 100644
--- a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/TextButtonTest.kt
+++ b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/TextButtonTest.kt
@@ -418,7 +418,7 @@
             status = Status.Enabled,
             colors = { TextButtonDefaults.filledTonalTextButtonColors() },
             expectedContainerColor = { MaterialTheme.colorScheme.surfaceContainer },
-            expectedContentColor = { MaterialTheme.colorScheme.onSurfaceVariant }
+            expectedContentColor = { MaterialTheme.colorScheme.onSurface }
         )
     }
 
diff --git a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/TextToggleButtonScreenshotTest.kt b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/TextToggleButtonScreenshotTest.kt
index fb4421f..0d58261 100644
--- a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/TextToggleButtonScreenshotTest.kt
+++ b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/TextToggleButtonScreenshotTest.kt
@@ -17,9 +17,14 @@
 package androidx.wear.compose.material3
 
 import android.os.Build
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.interaction.PressInteraction
 import androidx.compose.foundation.layout.offset
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Shape
 import androidx.compose.ui.platform.testTag
 import androidx.compose.ui.test.junit4.createComposeRule
 import androidx.compose.ui.unit.dp
@@ -27,6 +32,7 @@
 import androidx.test.filters.MediumTest
 import androidx.test.filters.SdkSuppress
 import androidx.test.screenshot.AndroidXScreenshotTestRule
+import org.junit.Ignore
 import org.junit.Rule
 import org.junit.Test
 import org.junit.rules.TestName
@@ -82,17 +88,83 @@
             content = { sampleTextToggleButton(modifier = Modifier.offset(10.dp)) }
         )
 
+    @Ignore("TODO: b/345199060 work out how to show pressed state in test")
+    @Test
+    fun animatedTextToggleButtonPressed() =
+        rule.verifyScreenshot(
+            methodName = testName.methodName,
+            screenshotRule = screenshotRule,
+            content = {
+                val interactionSource = remember {
+                    MutableInteractionSource().apply {
+                        tryEmit(PressInteraction.Press(Offset(0f, 0f)))
+                    }
+                }
+                sampleTextToggleButton(
+                    checked = false,
+                    shape =
+                        TextToggleButtonDefaults.animatedToggleButtonShape(
+                            interactionSource = interactionSource,
+                            checked = false
+                        ),
+                    interactionSource = interactionSource
+                )
+            }
+        )
+
+    @Test
+    fun animatedTextToggleButtonChecked() =
+        rule.verifyScreenshot(
+            methodName = testName.methodName,
+            screenshotRule = screenshotRule,
+            content = {
+                val interactionSource = remember { MutableInteractionSource() }
+                sampleTextToggleButton(
+                    checked = true,
+                    shape =
+                        TextToggleButtonDefaults.animatedToggleButtonShape(
+                            interactionSource = interactionSource,
+                            checked = true
+                        ),
+                    interactionSource = interactionSource
+                )
+            }
+        )
+
+    @Test
+    fun animatedTextToggleButtonUnchecked() =
+        rule.verifyScreenshot(
+            methodName = testName.methodName,
+            screenshotRule = screenshotRule,
+            content = {
+                val interactionSource = remember { MutableInteractionSource() }
+                sampleTextToggleButton(
+                    checked = false,
+                    shape =
+                        TextToggleButtonDefaults.animatedToggleButtonShape(
+                            interactionSource = interactionSource,
+                            checked = false
+                        ),
+                    interactionSource = interactionSource
+                )
+            }
+        )
+
     @Composable
     private fun sampleTextToggleButton(
         enabled: Boolean = true,
         checked: Boolean = true,
-        modifier: Modifier = Modifier
+        modifier: Modifier = Modifier,
+        shape: Shape = TextButtonDefaults.shape,
+        interactionSource: MutableInteractionSource? = null
     ) {
         TextToggleButton(
             checked = checked,
             onCheckedChange = {},
             enabled = enabled,
-            modifier = modifier.testTag(TEST_TAG)
+            modifier = modifier.testTag(TEST_TAG),
+            shape = shape,
+            interactionSource = interactionSource
         ) {
             Text(text = if (checked) "ON" else "OFF")
         }
diff --git a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/TextToggleButtonTest.kt b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/TextToggleButtonTest.kt
index 166a54c..18123fe5 100644
--- a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/TextToggleButtonTest.kt
+++ b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/TextToggleButtonTest.kt
@@ -382,7 +382,7 @@
         rule.verifyTextToggleButtonColors(
             status = Status.Enabled,
             checked = true,
-            colors = { TextButtonDefaults.textToggleButtonColors() },
+            colors = { TextToggleButtonDefaults.textToggleButtonColors() },
             containerColor = { MaterialTheme.colorScheme.primary },
             contentColor = { MaterialTheme.colorScheme.onPrimary }
         )
@@ -393,7 +393,7 @@
         rule.verifyTextToggleButtonColors(
             status = Status.Enabled,
             checked = false,
-            colors = { TextButtonDefaults.textToggleButtonColors() },
+            colors = { TextToggleButtonDefaults.textToggleButtonColors() },
             containerColor = { MaterialTheme.colorScheme.surfaceContainer },
             contentColor = { MaterialTheme.colorScheme.onSurfaceVariant }
         )
@@ -404,7 +404,7 @@
         rule.verifyTextToggleButtonColors(
             status = Status.Disabled,
             checked = false,
-            colors = { TextButtonDefaults.textToggleButtonColors() },
+            colors = { TextToggleButtonDefaults.textToggleButtonColors() },
             containerColor = {
                 MaterialTheme.colorScheme.onSurface.toDisabledColor(DisabledContainerAlpha)
             },
@@ -417,7 +417,7 @@
         rule.verifyTextToggleButtonColors(
             status = Status.Disabled,
             checked = true,
-            colors = { TextButtonDefaults.textToggleButtonColors() },
+            colors = { TextToggleButtonDefaults.textToggleButtonColors() },
             containerColor = {
                 MaterialTheme.colorScheme.onSurface.toDisabledColor(DisabledContainerAlpha)
             },
@@ -433,7 +433,7 @@
             status = Status.Enabled,
             checked = true,
             colors = {
-                TextButtonDefaults.textToggleButtonColors(checkedContainerColor = override)
+                TextToggleButtonDefaults.textToggleButtonColors(checkedContainerColor = override)
             },
             containerColor = { override },
             contentColor = { MaterialTheme.colorScheme.onPrimary }
@@ -448,7 +448,9 @@
         rule.verifyTextToggleButtonColors(
             status = Status.Enabled,
             checked = true,
-            colors = { TextButtonDefaults.textToggleButtonColors(checkedContentColor = override) },
+            colors = {
+                TextToggleButtonDefaults.textToggleButtonColors(checkedContentColor = override)
+            },
             containerColor = { MaterialTheme.colorScheme.primary },
             contentColor = { override }
         )
@@ -463,7 +465,7 @@
             status = Status.Enabled,
             checked = false,
             colors = {
-                TextButtonDefaults.textToggleButtonColors(uncheckedContainerColor = override)
+                TextToggleButtonDefaults.textToggleButtonColors(uncheckedContainerColor = override)
             },
             containerColor = { override },
             contentColor = { MaterialTheme.colorScheme.onSurfaceVariant }
@@ -479,7 +481,7 @@
             status = Status.Enabled,
             checked = false,
             colors = {
-                TextButtonDefaults.textToggleButtonColors(uncheckedContentColor = override)
+                TextToggleButtonDefaults.textToggleButtonColors(uncheckedContentColor = override)
             },
             containerColor = { MaterialTheme.colorScheme.surfaceContainer },
             contentColor = { override }
@@ -495,7 +497,9 @@
             status = Status.Disabled,
             checked = true,
             colors = {
-                TextButtonDefaults.textToggleButtonColors(disabledCheckedContainerColor = override)
+                TextToggleButtonDefaults.textToggleButtonColors(
+                    disabledCheckedContainerColor = override
+                )
             },
             containerColor = { override },
             contentColor = { MaterialTheme.colorScheme.onSurface.toDisabledColor() }
@@ -511,7 +515,7 @@
             status = Status.Disabled,
             checked = true,
             colors = {
-                TextButtonDefaults.textToggleButtonColors(
+                TextToggleButtonDefaults.textToggleButtonColors(
                     // Apply the content color override for the content alpha to be applied
                     disabledCheckedContentColor = override
                 )
@@ -532,7 +536,7 @@
             status = Status.Disabled,
             checked = false,
             colors = {
-                TextButtonDefaults.textToggleButtonColors(
+                TextToggleButtonDefaults.textToggleButtonColors(
                     // Apply the content color override for the content alpha to be applied
                     disabledUncheckedContainerColor = override
                 )
@@ -551,7 +555,7 @@
             status = Status.Disabled,
             checked = false,
             colors = {
-                TextButtonDefaults.textToggleButtonColors(
+                TextToggleButtonDefaults.textToggleButtonColors(
                     // Apply the content color override for the content alpha to be applied
                     disabledUncheckedContentColor = override
                 )
@@ -602,7 +606,7 @@
 
     @Composable
     private fun shapeColor(): Color {
-        return TextButtonDefaults.textToggleButtonColors()
+        return TextToggleButtonDefaults.textToggleButtonColors()
             .containerColor(enabled = true, checked = true)
             .value
     }
diff --git a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/TimePickerScreenshotTest.kt b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/TimePickerScreenshotTest.kt
index ed1aaf9..3511aa3 100644
--- a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/TimePickerScreenshotTest.kt
+++ b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/TimePickerScreenshotTest.kt
@@ -16,24 +16,16 @@
 
 package androidx.wear.compose.material3
 
-import android.content.res.Configuration
 import android.os.Build
 import androidx.annotation.RequiresApi
-import androidx.compose.foundation.background
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.size
 import androidx.compose.runtime.Composable
-import androidx.compose.runtime.CompositionLocalProvider
-import androidx.compose.runtime.remember
 import androidx.compose.testutils.assertAgainstGolden
 import androidx.compose.ui.Modifier
-import androidx.compose.ui.platform.LocalConfiguration
 import androidx.compose.ui.platform.testTag
 import androidx.compose.ui.test.captureToImage
 import androidx.compose.ui.test.junit4.ComposeContentTestRule
 import androidx.compose.ui.test.junit4.createComposeRule
 import androidx.compose.ui.test.onNodeWithTag
-import androidx.compose.ui.unit.dp
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.MediumTest
 import androidx.test.filters.SdkSuppress
@@ -155,30 +147,9 @@
         isLargeScreen: Boolean = false,
         content: @Composable () -> Unit
     ) {
-        val screenSizeDp = if (isLargeScreen) SCREENSHOT_SIZE_LARGE else SCREENSHOT_SIZE
-        setContentWithTheme {
-            val originalConfiguration = LocalConfiguration.current
-            val fixedScreenSizeConfiguration =
-                remember(originalConfiguration) {
-                    Configuration(originalConfiguration).apply {
-                        screenWidthDp = screenSizeDp
-                        screenHeightDp = screenSizeDp
-                    }
-                }
-            CompositionLocalProvider(LocalConfiguration provides fixedScreenSizeConfiguration) {
-                Box(
-                    modifier =
-                        Modifier.size(screenSizeDp.dp)
-                            .background(MaterialTheme.colorScheme.background)
-                ) {
-                    content()
-                }
-            }
-        }
+        val screenSizeDp = if (isLargeScreen) SCREEN_SIZE_LARGE else SCREEN_SIZE_SMALL
+        setContentWithTheme { ScreenConfiguration(screenSizeDp) { content() } }
 
         onNodeWithTag(testTag).captureToImage().assertAgainstGolden(screenshotRule, methodName)
     }
 }
-
-private const val SCREENSHOT_SIZE = 192
-private const val SCREENSHOT_SIZE_LARGE = 228
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
new file mode 100644
index 0000000..f660f8f
--- /dev/null
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/AnimatedToggleRoundedCornerShape.kt
@@ -0,0 +1,147 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.wear.compose.material3
+
+import androidx.compose.animation.core.FiniteAnimationSpec
+import androidx.compose.animation.core.animateFloat
+import androidx.compose.animation.core.updateTransition
+import androidx.compose.foundation.shape.CornerSize
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.geometry.RoundRect
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.geometry.toRect
+import androidx.compose.ui.graphics.Outline
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.LayoutDirection
+
+/**
+ * A animated [RoundedCornerShape]. Animation is driven by changes to the [cornerSize] lambda.
+ * [currentShapeSize] is provided as Size is received here, but must affect the animation.
+ *
+ * @param currentShapeSize MutableState coordinating the current size.
+ * @param cornerSize a lambda resolving to the current Corner size.
+ */
+@Stable
+internal class AnimatedToggleRoundedCornerShape(
+    private val currentShapeSize: MutableState<Size?>,
+    private val cornerSize: () -> CornerSize,
+) : Shape {
+    override fun createOutline(
+        size: Size,
+        layoutDirection: LayoutDirection,
+        density: Density,
+    ): Outline {
+        val cornerRadius = cornerSize().toPx(size, density)
+
+        currentShapeSize.value = size
+
+        return Outline.Rounded(
+            roundRect =
+                RoundRect(rect = size.toRect(), radiusX = cornerRadius, radiusY = cornerRadius)
+        )
+    }
+}
+
+/**
+ * Returns a Shape that will internally animate between the unchecked, checked and pressed shape as
+ * the button is pressed and checked/unchecked.
+ */
+@Composable
+internal fun rememberAnimatedToggleRoundedCornerShape(
+    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 transition = updateTransition(toggleState, label = "Toggle State")
+    val density = LocalDensity.current
+
+    val currentShapeSize = remember { mutableStateOf<Size?>(null) }
+
+    val observedSize = currentShapeSize.value
+
+    if (observedSize != null) {
+        val sizePx =
+            transition.animateFloat(
+                label = "Corner Size",
+                transitionSpec = {
+                    when {
+                        targetState isTransitioningTo ToggleState.Pressed -> onPressAnimationSpec
+                        else -> onReleaseAnimationSpec
+                    }
+                },
+            ) { newState ->
+                newState
+                    .cornerSize(uncheckedCornerSize, checkedCornerSize, pressedCornerSize)
+                    .toPx(observedSize, density)
+            }
+
+        return remember(sizePx) {
+            AnimatedToggleRoundedCornerShape(
+                currentShapeSize = currentShapeSize,
+            ) {
+                CornerSize(sizePx.value)
+            }
+        }
+    } else {
+        return remember(toggleState, uncheckedCornerSize, checkedCornerSize, pressedCornerSize) {
+            AnimatedToggleRoundedCornerShape(
+                currentShapeSize = currentShapeSize,
+            ) {
+                toggleState.cornerSize(
+                    uncheckedCornerSize,
+                    checkedCornerSize,
+                    pressedCornerSize,
+                )
+            }
+        }
+    }
+}
+
+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,
+}
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/ButtonGroup.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/ButtonGroup.kt
index e05a9f2..722f667 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/ButtonGroup.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/ButtonGroup.kt
@@ -30,7 +30,6 @@
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.layout.Layout
-import androidx.compose.ui.platform.LocalConfiguration
 import androidx.compose.ui.platform.LocalDensity
 import androidx.compose.ui.unit.Dp
 import androidx.compose.ui.unit.dp
@@ -38,6 +37,7 @@
 import androidx.compose.ui.util.fastForEachIndexed
 import androidx.compose.ui.util.fastMap
 import androidx.compose.ui.util.fastMapIndexed
+import androidx.wear.compose.materialcore.screenHeightDp
 import kotlin.math.abs
 import kotlin.math.roundToInt
 import kotlinx.coroutines.flow.collectLatest
@@ -46,7 +46,7 @@
 import kotlinx.coroutines.launch
 
 /** Scope for the children of a [ButtonGroup] */
-public class ButtonGroupScope {
+class ButtonGroupScope {
     internal val items = mutableListOf<ButtonGroupItem>()
 
     /**
@@ -225,7 +225,7 @@
      */
     @Composable
     fun fullWidthPaddings(): PaddingValues {
-        val screenHeight = LocalConfiguration.current.screenHeightDp.dp
+        val screenHeight = screenHeightDp().dp
         return PaddingValues(
             horizontal = screenHeight * FullWidthHorizontalPaddingPercentage / 100,
             vertical = 0.dp
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/CircularProgressIndicator.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/CircularProgressIndicator.kt
index e35b8fb..507df53 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/CircularProgressIndicator.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/CircularProgressIndicator.kt
@@ -16,18 +16,34 @@
 
 package androidx.wear.compose.material3
 
+import androidx.compose.animation.core.LinearEasing
+import androidx.compose.animation.core.animateFloat
+import androidx.compose.animation.core.infiniteRepeatable
+import androidx.compose.animation.core.keyframes
+import androidx.compose.animation.core.rememberInfiniteTransition
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.Canvas
 import androidx.compose.foundation.focusable
 import androidx.compose.foundation.layout.Spacer
 import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.size
 import androidx.compose.runtime.Composable
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.draw.drawWithCache
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Brush
 import androidx.compose.ui.graphics.StrokeCap
+import androidx.compose.ui.graphics.drawscope.DrawScope
 import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.graphics.drawscope.rotate
+import androidx.compose.ui.platform.LocalDensity
 import androidx.compose.ui.semantics.clearAndSetSemantics
 import androidx.compose.ui.unit.Dp
 import androidx.compose.ui.unit.dp
+import androidx.wear.compose.material3.tokens.MotionTokens
 import androidx.wear.compose.materialcore.isSmallScreen
+import kotlin.math.PI
 import kotlin.math.asin
 import kotlin.math.max
 import kotlin.math.min
@@ -55,8 +71,13 @@
  * Progress indicators express the proportion of completion of an ongoing task.
  *
  * @param progress The progress of this progress indicator where 0.0 represents no progress and 1.0
- *   represents completion. Values outside of this range are coerced into the range 0..1.
+ *   represents completion.
  * @param modifier Modifier to be applied to the CircularProgressIndicator.
+ * @param allowProgressOverflow When progress overflow is allowed, values smaller than 0.0 will be
+ *   coerced to 0, while values larger than 1.0 will be wrapped around and shown as overflow with a
+ *   different track color [ProgressIndicatorColors.overflowTrackBrush]. For example values 1.2, 2.2
+ *   etc will be shown as 20% progress with the overflow color. When progress overflow is not
+ *   allowed, progress values will be coerced into the range 0..1.
  * @param startAngle The starting position of the progress arc, measured clockwise in degrees (0
  *   to 360) from the 3 o'clock position. For example, 0 and 360 represent 3 o'clock, 90 and 180
  *   represent 6 o'clock and 9 o'clock respectively. Default is 270 degrees
@@ -79,6 +100,7 @@
 fun CircularProgressIndicator(
     progress: () -> Float,
     modifier: Modifier = Modifier,
+    allowProgressOverflow: Boolean = false,
     startAngle: Float = CircularProgressIndicatorDefaults.StartAngle,
     endAngle: Float = startAngle,
     colors: ProgressIndicatorColors = ProgressIndicatorDefaults.colors(),
@@ -86,7 +108,6 @@
     gapSize: Dp = CircularProgressIndicatorDefaults.calculateRecommendedGapSize(strokeWidth),
     enabled: Boolean = true,
 ) {
-    val coercedProgress = { progress().coerceIn(0f, 1f) }
     // Canvas internally uses Spacer.drawBehind.
     // Using Spacer.drawWithCache to optimize the stroke allocations.
     Spacer(
@@ -95,8 +116,11 @@
             .fillMaxSize()
             .focusable()
             .drawWithCache {
+                val currentProgress = progress()
+                val coercedProgress = coerceProgress(currentProgress, allowProgressOverflow)
                 val fullSweep = 360f - ((startAngle - endAngle) % 360 + 360) % 360
-                var progressSweep = fullSweep * coercedProgress()
+                var progressSweep = fullSweep * coercedProgress
+                val hasOverflow = allowProgressOverflow && currentProgress > 1.0f
                 val stroke = Stroke(width = strokeWidth.toPx(), cap = StrokeCap.Round)
                 val minSize = min(size.height, size.width)
                 // Sweep angle between two progress indicator segments.
@@ -123,7 +147,7 @@
                         startAngle = startAngle + progressSweep,
                         sweep = fullSweep - progressSweep,
                         gapSweep = gapSweep,
-                        brush = colors.trackBrush(enabled),
+                        brush = colors.trackBrush(enabled, hasOverflow),
                         stroke = stroke
                     )
                 }
@@ -131,6 +155,77 @@
     )
 }
 
+/**
+ * Indeterminate Material Design circular progress indicator.
+ *
+ * Indeterminate progress indicator expresses an unspecified wait time and spins indefinitely.
+ *
+ * Example of indeterminate progress indicator:
+ *
+ * @sample androidx.wear.compose.material3.samples.IndeterminateProgressIndicatorSample
+ * @param modifier Modifier to be applied to the CircularProgressIndicator.
+ * @param colors [ProgressIndicatorColors] that will be used to resolve the indicator and track
+ *   color for this progress indicator.
+ * @param strokeWidth The stroke width for the progress indicator. The recommended values is
+ *   [CircularProgressIndicatorDefaults.IndeterminateStrokeWidth].
+ * @param gapSize The size (in Dp) of the gap between the ends of the progress indicator and the
+ *   track. The stroke endcaps are not included in this distance.
+ */
+@Composable
+fun CircularProgressIndicator(
+    modifier: Modifier = Modifier,
+    colors: ProgressIndicatorColors = ProgressIndicatorDefaults.colors(),
+    strokeWidth: Dp = CircularProgressIndicatorDefaults.IndeterminateStrokeWidth,
+    gapSize: Dp = CircularProgressIndicatorDefaults.calculateRecommendedGapSize(strokeWidth),
+) {
+    val stroke =
+        with(LocalDensity.current) { Stroke(width = strokeWidth.toPx(), cap = StrokeCap.Round) }
+
+    val infiniteTransition = rememberInfiniteTransition()
+    // A global rotation that does a 360 degrees rotation in 6 seconds.
+    val globalRotation =
+        infiniteTransition.animateFloat(
+            initialValue = 0f,
+            targetValue = CircularGlobalRotationDegreesTarget,
+            animationSpec = circularIndeterminateGlobalRotationAnimationSpec
+        )
+
+    // An additional rotation that moves by 90 degrees in 500ms and then rest for 1 second.
+    val additionalRotation =
+        infiniteTransition.animateFloat(
+            initialValue = 0f,
+            targetValue = CircularAdditionalRotationDegreesTarget,
+            animationSpec = circularIndeterminateRotationAnimationSpec
+        )
+
+    // Indicator progress animation that will be changing the progress up and down as the indicator
+    // rotates.
+    val progressAnimation =
+        infiniteTransition.animateFloat(
+            initialValue = CircularIndeterminateMinProgress,
+            targetValue = CircularIndeterminateMaxProgress,
+            animationSpec = circularIndeterminateProgressAnimationSpec
+        )
+
+    Canvas(
+        modifier.size(CircularProgressIndicatorDefaults.IndeterminateCircularIndicatorDiameter)
+    ) {
+        val sweep = progressAnimation.value * 360f
+        val adjustedGapSize = gapSize + strokeWidth
+        val gapSizeSweep = (adjustedGapSize.value / (PI * size.width.toDp().value).toFloat()) * 360f
+
+        rotate(globalRotation.value + additionalRotation.value) {
+            drawCircularIndicator(
+                sweep + min(sweep, gapSizeSweep),
+                360f - sweep - min(sweep, gapSizeSweep) * 2,
+                colors.trackBrush,
+                stroke
+            )
+            drawCircularIndicator(startAngle = 0f, sweep, colors.indicatorBrush, stroke)
+        }
+    }
+}
+
 /** Contains default values for [CircularProgressIndicator]. */
 object CircularProgressIndicatorDefaults {
     /** Large stroke width for circular progress indicator. */
@@ -157,4 +252,93 @@
 
     /** Padding used for displaying [CircularProgressIndicator] full screen. */
     val FullScreenPadding = PaddingDefaults.edgePadding
+
+    /** Diameter of the indicator circle for indeterminate progress. */
+    internal val IndeterminateCircularIndicatorDiameter = 24.dp
+
+    /** Default stroke width for indeterminate [CircularProgressIndicator]. */
+    val IndeterminateStrokeWidth = 3.dp
 }
+
+private fun DrawScope.drawCircularIndicator(
+    startAngle: Float,
+    sweep: Float,
+    brush: Brush,
+    stroke: Stroke
+) {
+    // To draw this circle we need a rect with edges that line up with the midpoint of the stroke.
+    // To do this we need to remove half the stroke width from the total diameter for both sides.
+    val diameterOffset = stroke.width / 2
+    val arcDimen = size.width - 2 * diameterOffset
+    drawArc(
+        brush = brush,
+        startAngle = startAngle,
+        sweepAngle = sweep,
+        useCenter = false,
+        topLeft = Offset(diameterOffset, diameterOffset),
+        size = Size(arcDimen, arcDimen),
+        style = stroke
+    )
+}
+
+/** A global animation spec for indeterminate circular progress indicator. */
+internal val circularIndeterminateGlobalRotationAnimationSpec
+    get() =
+        infiniteRepeatable<Float>(
+            animation = tween(CircularAnimationProgressDuration, easing = LinearEasing)
+        )
+
+/**
+ * An animation spec for indeterminate circular progress indicators that infinitely rotates a 360
+ * degrees.
+ */
+internal val circularIndeterminateRotationAnimationSpec
+    get() =
+        infiniteRepeatable(
+            animation =
+                keyframes {
+                    durationMillis = CircularAnimationProgressDuration // 6000ms
+                    90f at
+                        CircularAnimationAdditionalRotationDuration using
+                        MotionTokens
+                            .EasingEmphasizedDecelerate // MotionTokens.EasingEmphasizedDecelerateCubicBezier // 300ms
+                    90f at CircularAnimationAdditionalRotationDelay // hold till 1500ms
+                    180f at
+                        CircularAnimationAdditionalRotationDuration +
+                            CircularAnimationAdditionalRotationDelay // 1800ms
+                    180f at CircularAnimationAdditionalRotationDelay * 2 // hold till 3000ms
+                    270f at
+                        CircularAnimationAdditionalRotationDuration +
+                            CircularAnimationAdditionalRotationDelay * 2 // 3300ms
+                    270f at CircularAnimationAdditionalRotationDelay * 3 // hold till 4500ms
+                    360f at
+                        CircularAnimationAdditionalRotationDuration +
+                            CircularAnimationAdditionalRotationDelay * 3 // 4800ms
+                    360f at CircularAnimationProgressDuration // hold till 6000ms
+                }
+        )
+
+/** An animation spec for indeterminate circular progress indicators progress motion. */
+internal val circularIndeterminateProgressAnimationSpec
+    get() =
+        infiniteRepeatable(
+            animation =
+                keyframes {
+                    durationMillis = CircularAnimationProgressDuration // 6000ms
+                    CircularIndeterminateMaxProgress at
+                        CircularAnimationProgressDuration / 2 using
+                        CircularProgressEasing // 3000ms
+                    CircularIndeterminateMinProgress at CircularAnimationProgressDuration
+                }
+        )
+
+// The indeterminate circular indicator easing constants for its motion
+internal val CircularProgressEasing = MotionTokens.EasingStandard
+internal const val CircularIndeterminateMinProgress = 0.1f
+internal const val CircularIndeterminateMaxProgress = 0.87f
+
+internal const val CircularAnimationProgressDuration = 6000
+internal const val CircularAnimationAdditionalRotationDelay = 1500
+internal const val CircularAnimationAdditionalRotationDuration = 300
+internal const val CircularAdditionalRotationDegreesTarget = 360f
+internal const val CircularGlobalRotationDegreesTarget = 1080f
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 786b647..469df92 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
@@ -55,7 +55,6 @@
 import androidx.compose.ui.node.LayoutModifierNode
 import androidx.compose.ui.node.ModifierNodeElement
 import androidx.compose.ui.platform.InspectorInfo
-import androidx.compose.ui.platform.LocalConfiguration
 import androidx.compose.ui.platform.LocalDensity
 import androidx.compose.ui.semantics.Role
 import androidx.compose.ui.text.style.TextAlign
@@ -70,6 +69,7 @@
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.toSize
 import androidx.compose.ui.util.lerp
+import androidx.wear.compose.materialcore.screenWidthDp
 import kotlin.math.roundToInt
 import kotlin.math.sqrt
 
@@ -140,7 +140,7 @@
     val easing = CubicBezierEasing(0.25f, 0f, 0.75f, 1.0f)
 
     val density = LocalDensity.current
-    val screenWidthDp = LocalConfiguration.current.screenWidthDp.dp
+    val screenWidthDp = screenWidthDp().dp
 
     val contentShapeHelper =
         remember(buttonHeight) {
@@ -221,7 +221,7 @@
                     val alpha =
                         easing
                             .transform(
-                                (height - contentFadeEndPx).toFloat() /
+                                (height - contentFadeEndPx) /
                                     ((contentFadeStartPx - contentFadeEndPx))
                             )
                             .coerceIn(0f, 1f)
@@ -309,8 +309,6 @@
 
     fun contentWidthDp() = with(density) { contentWindow.width.toDp() }
 
-    fun contentHeightDp() = with(density) { contentWindow.height.toDp() }
-
     fun update(size: Size) {
         lastSize.value = size
 
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/IconButton.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/IconButton.kt
index 1d4e8e4..da21502 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/IconButton.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/IconButton.kt
@@ -17,14 +17,17 @@
 package androidx.wear.compose.material3
 
 import androidx.compose.animation.core.AnimationSpec
+import androidx.compose.animation.core.FiniteAnimationSpec
 import androidx.compose.animation.core.tween
 import androidx.compose.foundation.BorderStroke
 import androidx.compose.foundation.interaction.Interaction
 import androidx.compose.foundation.interaction.InteractionSource
 import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.interaction.collectIsPressedAsState
 import androidx.compose.foundation.layout.BoxScope
 import androidx.compose.foundation.layout.size
 import androidx.compose.foundation.shape.CornerBasedShape
+import androidx.compose.foundation.shape.CornerSize
 import androidx.compose.foundation.shape.RoundedCornerShape
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.Immutable
@@ -352,9 +355,14 @@
  * [IconToggleButton] can be enabled or disabled. A disabled button will not respond to click
  * events. When enabled, the checked and unchecked events are propagated by [onCheckedChange].
  *
- * A simple icon toggle button using the default colors
+ * A simple icon toggle button using the default colors, animated when pressed.
  *
  * @sample androidx.wear.compose.material3.samples.IconToggleButtonSample
+ *
+ * A simple icon toggle button using the default colors, animated when pressed and with different
+ * shapes for the checked and unchecked states.
+ *
+ * @sample androidx.wear.compose.material3.samples.IconToggleButtonVariantSample
  * @param checked Boolean flag indicating whether this toggle button is currently checked.
  * @param onCheckedChange Callback to be invoked when this toggle button is clicked.
  * @param modifier Modifier to be applied to the toggle button.
@@ -377,7 +385,7 @@
     onCheckedChange: (Boolean) -> Unit,
     modifier: Modifier = Modifier,
     enabled: Boolean = true,
-    colors: IconToggleButtonColors = IconButtonDefaults.iconToggleButtonColors(),
+    colors: IconToggleButtonColors = IconToggleButtonDefaults.iconToggleButtonColors(),
     interactionSource: MutableInteractionSource? = null,
     shape: Shape = IconButtonDefaults.shape,
     border: BorderStroke? = null,
@@ -417,6 +425,13 @@
     /**
      * Creates a [Shape] with a animation between two CornerBasedShapes.
      *
+     * A simple icon button using the default colors, animated when pressed.
+     *
+     * @sample androidx.wear.compose.material3.samples.IconButtonWithCornerAnimationSample
+     *
+     * A simple icon toggle button using the default colors, animated when pressed.
+     *
+     * @sample androidx.wear.compose.material3.samples.IconToggleButtonSample
      * @param interactionSource the interaction source applied to the Button.
      * @param shape The normal shape of the IconButton.
      * @param pressedShape The pressed shape of the IconButton.
@@ -611,62 +626,6 @@
         )
 
     /**
-     * Creates an [IconToggleButtonColors] for a [IconToggleButton]
-     * - by default, a colored background with a contrasting content color.
-     *
-     * If the button is disabled, then the colors will have an alpha ([DisabledContentAlpha] and
-     * [DisabledContainerAlpha]) value applied.
-     */
-    @Composable
-    fun iconToggleButtonColors() = MaterialTheme.colorScheme.defaultIconToggleButtonColors
-
-    /**
-     * Creates a [IconToggleButtonColors] for a [IconToggleButton]
-     * - by default, a colored background with a contrasting content color.
-     *
-     * If the button is disabled, then the colors will have an alpha ([DisabledContentAlpha] and
-     * [DisabledContainerAlpha]) value applied.
-     *
-     * @param checkedContainerColor The container color of this [IconToggleButton] when enabled and
-     *   checked
-     * @param checkedContentColor The content color of this [IconToggleButton] when enabled and
-     *   checked
-     * @param uncheckedContainerColor The container color of this [IconToggleButton] when enabled
-     *   and unchecked
-     * @param uncheckedContentColor The content color of this [IconToggleButton] when enabled and
-     *   unchecked
-     * @param disabledCheckedContainerColor The container color of this [IconToggleButton] when
-     *   checked and not enabled
-     * @param disabledCheckedContentColor The content color of this [IconToggleButton] when checked
-     *   and not enabled
-     * @param disabledUncheckedContainerColor The container color of this [IconToggleButton] when
-     *   unchecked and not enabled
-     * @param disabledUncheckedContentColor The content color of this [IconToggleButton] when
-     *   unchecked and not enabled
-     */
-    @Composable
-    fun iconToggleButtonColors(
-        checkedContainerColor: Color = Color.Unspecified,
-        checkedContentColor: Color = Color.Unspecified,
-        uncheckedContainerColor: Color = Color.Unspecified,
-        uncheckedContentColor: Color = Color.Unspecified,
-        disabledCheckedContainerColor: Color = Color.Unspecified,
-        disabledCheckedContentColor: Color = Color.Unspecified,
-        disabledUncheckedContainerColor: Color = Color.Unspecified,
-        disabledUncheckedContentColor: Color = Color.Unspecified,
-    ): IconToggleButtonColors =
-        MaterialTheme.colorScheme.defaultIconToggleButtonColors.copy(
-            checkedContainerColor = checkedContainerColor,
-            checkedContentColor = checkedContentColor,
-            uncheckedContainerColor = uncheckedContainerColor,
-            uncheckedContentColor = uncheckedContentColor,
-            disabledCheckedContainerColor = disabledCheckedContainerColor,
-            disabledCheckedContentColor = disabledCheckedContentColor,
-            disabledUncheckedContainerColor = disabledUncheckedContainerColor,
-            disabledUncheckedContentColor = disabledUncheckedContentColor,
-        )
-
-    /**
      * The recommended size of an icon when used inside an icon button with size [SmallButtonSize]
      * or [ExtraSmallButtonSize]. Use [iconSizeFor] to easily determine the icon size.
      */
@@ -801,45 +760,6 @@
                     )
                     .also { defaultIconButtonColorsCached = it }
         }
-
-    private val ColorScheme.defaultIconToggleButtonColors: IconToggleButtonColors
-        get() {
-            return defaultIconToggleButtonColorsCached
-                ?: IconToggleButtonColors(
-                        checkedContainerColor =
-                            fromToken(IconToggleButtonTokens.CheckedContainerColor),
-                        checkedContentColor = fromToken(IconToggleButtonTokens.CheckedContentColor),
-                        uncheckedContainerColor =
-                            fromToken(IconToggleButtonTokens.UncheckedContainerColor),
-                        uncheckedContentColor =
-                            fromToken(IconToggleButtonTokens.UncheckedContentColor),
-                        disabledCheckedContainerColor =
-                            fromToken(IconToggleButtonTokens.DisabledCheckedContainerColor)
-                                .toDisabledColor(
-                                    disabledAlpha =
-                                        IconToggleButtonTokens.DisabledCheckedContainerOpacity
-                                ),
-                        disabledCheckedContentColor =
-                            fromToken(IconToggleButtonTokens.DisabledCheckedContentColor)
-                                .toDisabledColor(
-                                    disabledAlpha =
-                                        IconToggleButtonTokens.DisabledCheckedContentOpacity
-                                ),
-                        disabledUncheckedContainerColor =
-                            fromToken(IconToggleButtonTokens.DisabledUncheckedContainerColor)
-                                .toDisabledColor(
-                                    disabledAlpha =
-                                        IconToggleButtonTokens.DisabledUncheckedContainerOpacity
-                                ),
-                        disabledUncheckedContentColor =
-                            fromToken(IconToggleButtonTokens.DisabledUncheckedContentColor)
-                                .toDisabledColor(
-                                    disabledAlpha =
-                                        IconToggleButtonTokens.DisabledUncheckedContentOpacity
-                                ),
-                    )
-                    .also { defaultIconToggleButtonColorsCached = it }
-        }
 }
 
 /**
@@ -920,6 +840,154 @@
     }
 }
 
+/** Contains the default values used by [IconToggleButton]. */
+object IconToggleButtonDefaults {
+
+    /**
+     * Creates a [Shape] with an animation between three [CornerSize]s based on the pressed state
+     * and checked/unchecked.
+     *
+     * A simple icon toggle button using the default colors, animated on Press and Check/Uncheck:
+     *
+     * @sample androidx.wear.compose.material3.samples.IconToggleButtonVariantSample
+     * @param interactionSource the interaction source applied to the Button.
+     * @param checked the current checked/unchecked state.
+     * @param uncheckedCornerSize the size of the corner when unchecked.
+     * @param checkedCornerSize the size of the corner when checked.
+     * @param pressedCornerSize the size of the corner when pressed.
+     * @param onPressAnimationSpec the spec for press animation.
+     * @param onReleaseAnimationSpec the spec for release animation.
+     */
+    @Composable
+    fun animatedToggleButtonShape(
+        interactionSource: InteractionSource,
+        checked: Boolean,
+        uncheckedCornerSize: CornerSize = UncheckedCornerSize,
+        checkedCornerSize: CornerSize = CheckedCornerSize,
+        pressedCornerSize: CornerSize = PressedCornerSize,
+        onPressAnimationSpec: FiniteAnimationSpec<Float> =
+            MaterialTheme.motionScheme.rememberFastSpatialSpec(),
+        onReleaseAnimationSpec: FiniteAnimationSpec<Float> =
+            MaterialTheme.motionScheme.slowSpatialSpec(),
+    ): Shape {
+        val pressed = interactionSource.collectIsPressedAsState()
+
+        return rememberAnimatedToggleRoundedCornerShape(
+            uncheckedCornerSize = uncheckedCornerSize,
+            checkedCornerSize = checkedCornerSize,
+            pressedCornerSize = pressedCornerSize,
+            pressed = pressed.value,
+            checked = checked,
+            onPressAnimationSpec = onPressAnimationSpec,
+            onReleaseAnimationSpec = onReleaseAnimationSpec,
+        )
+    }
+
+    /** The recommended size for an Unchecked button when animated. */
+    val UncheckedCornerSize: CornerSize = ShapeTokens.CornerFull.topEnd
+
+    /** The recommended size for a Checked button when animated. */
+    val CheckedCornerSize: CornerSize = CornerSize(percent = 30)
+
+    /** The recommended size for a Pressed button when animated. */
+    val PressedCornerSize: CornerSize = ShapeDefaults.Small.topEnd
+
+    /**
+     * Creates an [IconToggleButtonColors] for a [IconToggleButton]
+     * - by default, a colored background with a contrasting content color.
+     *
+     * If the button is disabled, then the colors will have an alpha ([DisabledContentAlpha] and
+     * [DisabledContainerAlpha]) value applied.
+     */
+    @Composable
+    fun iconToggleButtonColors() = MaterialTheme.colorScheme.defaultIconToggleButtonColors
+
+    /**
+     * Creates a [IconToggleButtonColors] for a [IconToggleButton]
+     * - by default, a colored background with a contrasting content color.
+     *
+     * If the button is disabled, then the colors will have an alpha ([DisabledContentAlpha] and
+     * [DisabledContainerAlpha]) value applied.
+     *
+     * @param checkedContainerColor The container color of this [IconToggleButton] when enabled and
+     *   checked
+     * @param checkedContentColor The content color of this [IconToggleButton] when enabled and
+     *   checked
+     * @param uncheckedContainerColor The container color of this [IconToggleButton] when enabled
+     *   and unchecked
+     * @param uncheckedContentColor The content color of this [IconToggleButton] when enabled and
+     *   unchecked
+     * @param disabledCheckedContainerColor The container color of this [IconToggleButton] when
+     *   checked and not enabled
+     * @param disabledCheckedContentColor The content color of this [IconToggleButton] when checked
+     *   and not enabled
+     * @param disabledUncheckedContainerColor The container color of this [IconToggleButton] when
+     *   unchecked and not enabled
+     * @param disabledUncheckedContentColor The content color of this [IconToggleButton] when
+     *   unchecked and not enabled
+     */
+    @Composable
+    fun iconToggleButtonColors(
+        checkedContainerColor: Color = Color.Unspecified,
+        checkedContentColor: Color = Color.Unspecified,
+        uncheckedContainerColor: Color = Color.Unspecified,
+        uncheckedContentColor: Color = Color.Unspecified,
+        disabledCheckedContainerColor: Color = Color.Unspecified,
+        disabledCheckedContentColor: Color = Color.Unspecified,
+        disabledUncheckedContainerColor: Color = Color.Unspecified,
+        disabledUncheckedContentColor: Color = Color.Unspecified,
+    ): IconToggleButtonColors =
+        MaterialTheme.colorScheme.defaultIconToggleButtonColors.copy(
+            checkedContainerColor = checkedContainerColor,
+            checkedContentColor = checkedContentColor,
+            uncheckedContainerColor = uncheckedContainerColor,
+            uncheckedContentColor = uncheckedContentColor,
+            disabledCheckedContainerColor = disabledCheckedContainerColor,
+            disabledCheckedContentColor = disabledCheckedContentColor,
+            disabledUncheckedContainerColor = disabledUncheckedContainerColor,
+            disabledUncheckedContentColor = disabledUncheckedContentColor,
+        )
+
+    private val ColorScheme.defaultIconToggleButtonColors: IconToggleButtonColors
+        get() {
+            return defaultIconToggleButtonColorsCached
+                ?: IconToggleButtonColors(
+                        checkedContainerColor =
+                            fromToken(IconToggleButtonTokens.CheckedContainerColor),
+                        checkedContentColor = fromToken(IconToggleButtonTokens.CheckedContentColor),
+                        uncheckedContainerColor =
+                            fromToken(IconToggleButtonTokens.UncheckedContainerColor),
+                        uncheckedContentColor =
+                            fromToken(IconToggleButtonTokens.UncheckedContentColor),
+                        disabledCheckedContainerColor =
+                            fromToken(IconToggleButtonTokens.DisabledCheckedContainerColor)
+                                .toDisabledColor(
+                                    disabledAlpha =
+                                        IconToggleButtonTokens.DisabledCheckedContainerOpacity
+                                ),
+                        disabledCheckedContentColor =
+                            fromToken(IconToggleButtonTokens.DisabledCheckedContentColor)
+                                .toDisabledColor(
+                                    disabledAlpha =
+                                        IconToggleButtonTokens.DisabledCheckedContentOpacity
+                                ),
+                        disabledUncheckedContainerColor =
+                            fromToken(IconToggleButtonTokens.DisabledUncheckedContainerColor)
+                                .toDisabledColor(
+                                    disabledAlpha =
+                                        IconToggleButtonTokens.DisabledUncheckedContainerOpacity
+                                ),
+                        disabledUncheckedContentColor =
+                            fromToken(IconToggleButtonTokens.DisabledUncheckedContentColor)
+                                .toDisabledColor(
+                                    disabledAlpha =
+                                        IconToggleButtonTokens.DisabledUncheckedContentOpacity
+                                ),
+                    )
+                    .also { defaultIconToggleButtonColorsCached = it }
+        }
+}
+
 /**
  * Represents the different container and content colors used for [IconToggleButton] in various
  * states, that are checked, unchecked, enabled and disabled.
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/OpenOnPhoneDialog.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/OpenOnPhoneDialog.kt
index fa819c7..eec59d0 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/OpenOnPhoneDialog.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/OpenOnPhoneDialog.kt
@@ -140,7 +140,7 @@
                 iconContainer(
                     iconContainerColor = colors.iconContainerColor,
                     progressIndicatorColors =
-                        ProgressIndicatorColors(
+                        ProgressIndicatorDefaults.colors(
                             SolidColor(colors.progressIndicatorColor),
                             SolidColor(colors.progressTrackColor)
                         ),
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/ProgressIndicator.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/ProgressIndicator.kt
index e920b35..31c70c4 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/ProgressIndicator.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/ProgressIndicator.kt
@@ -29,6 +29,7 @@
 import androidx.wear.compose.materialcore.toRadians
 import kotlin.math.cos
 import kotlin.math.min
+import kotlin.math.round
 import kotlin.math.sin
 
 /** Contains defaults for Progress Indicators. */
@@ -37,57 +38,76 @@
     @Composable fun colors() = MaterialTheme.colorScheme.defaultProgressIndicatorColors
 
     /**
-     * Creates a [ProgressIndicatorColors] with modified colors used in [CircularProgressIndicator]
-     * and [LinearProgressIndicator].
+     * Creates a [ProgressIndicatorColors] with modified colors.
      *
      * @param indicatorColor The indicator color.
      * @param trackColor The track color.
+     * @param overflowTrackColor The overflow track color.
      * @param disabledIndicatorColor The disabled indicator color.
      * @param disabledTrackColor The disabled track color.
+     * @param disabledOverflowTrackColor The disabled overflow track color.
      */
     @Composable
     fun colors(
         indicatorColor: Color = Color.Unspecified,
         trackColor: Color = Color.Unspecified,
+        overflowTrackColor: Color = Color.Unspecified,
         disabledIndicatorColor: Color = Color.Unspecified,
         disabledTrackColor: Color = Color.Unspecified,
+        disabledOverflowTrackColor: Color = Color.Unspecified,
     ) =
         MaterialTheme.colorScheme.defaultProgressIndicatorColors.copy(
             indicatorColor = indicatorColor,
             trackColor = trackColor,
+            overflowTrackColor = overflowTrackColor,
             disabledIndicatorColor = disabledIndicatorColor,
             disabledTrackColor = disabledTrackColor,
+            disabledOverflowTrackColor = disabledOverflowTrackColor,
         )
 
     /**
-     * Creates a [ProgressIndicatorColors] with modified brushes used in [CircularProgressIndicator]
-     * and [LinearProgressIndicator].
+     * Creates a [ProgressIndicatorColors] with modified brushes.
      *
      * @param indicatorBrush [Brush] used to draw indicator.
      * @param trackBrush [Brush] used to draw track.
+     * @param overflowTrackBrush [Brush] used to draw track for progress overflow.
      * @param disabledIndicatorBrush [Brush] used to draw the indicator if the progress is disabled.
      * @param disabledTrackBrush [Brush] used to draw the track if the progress is disabled.
+     * @param disabledOverflowTrackBrush [Brush] used to draw the overflow track if the progress is
+     *   disabled.
      */
     @Composable
     fun colors(
         indicatorBrush: Brush? = null,
         trackBrush: Brush? = null,
+        overflowTrackBrush: Brush? = null,
         disabledIndicatorBrush: Brush? = null,
         disabledTrackBrush: Brush? = null,
+        disabledOverflowTrackBrush: Brush? = null,
     ) =
         MaterialTheme.colorScheme.defaultProgressIndicatorColors.copy(
             indicatorBrush = indicatorBrush,
             trackBrush = trackBrush,
+            overflowTrackBrush = overflowTrackBrush,
             disabledIndicatorBrush = disabledIndicatorBrush,
             disabledTrackBrush = disabledTrackBrush,
+            disabledOverflowTrackBrush = disabledOverflowTrackBrush
         )
 
+    // TODO(b/364538891): add color and alpha tokens for ProgressIndicator
+    private const val OverflowTrackColorAlpha = 0.6f
+
     private val ColorScheme.defaultProgressIndicatorColors: ProgressIndicatorColors
         get() {
             return defaultProgressIndicatorColorsCached
                 ?: ProgressIndicatorColors(
                         indicatorBrush = SolidColor(fromToken(ColorSchemeKeyTokens.Primary)),
                         trackBrush = SolidColor(fromToken(ColorSchemeKeyTokens.SurfaceContainer)),
+                        overflowTrackBrush =
+                            SolidColor(
+                                fromToken(ColorSchemeKeyTokens.Primary)
+                                    .copy(alpha = OverflowTrackColorAlpha)
+                            ),
                         disabledIndicatorBrush =
                             SolidColor(
                                 fromToken(ColorSchemeKeyTokens.OnSurface)
@@ -98,6 +118,12 @@
                                 fromToken(ColorSchemeKeyTokens.OnSurface)
                                     .toDisabledColor(disabledAlpha = DisabledContainerAlpha)
                             ),
+                        disabledOverflowTrackBrush =
+                            SolidColor(
+                                fromToken(ColorSchemeKeyTokens.Primary)
+                                    .copy(alpha = OverflowTrackColorAlpha)
+                                    .toDisabledColor(disabledAlpha = DisabledContainerAlpha)
+                            )
                     )
                     .also { defaultProgressIndicatorColorsCached = it }
         }
@@ -108,44 +134,61 @@
  *
  * @param indicatorBrush [Brush] used to draw the indicator of progress indicator.
  * @param trackBrush [Brush] used to draw the track of progress indicator.
+ * @param overflowTrackBrush [Brush] used to draw the track for progress overflow (>100%).
  * @param disabledIndicatorBrush [Brush] used to draw the indicator if the component is disabled.
  * @param disabledTrackBrush [Brush] used to draw the track if the component is disabled.
+ * @param disabledOverflowTrackBrush [Brush] used to draw the track if the component is disabled.
  */
 class ProgressIndicatorColors(
     val indicatorBrush: Brush,
     val trackBrush: Brush,
-    val disabledIndicatorBrush: Brush = indicatorBrush,
-    val disabledTrackBrush: Brush = disabledIndicatorBrush,
+    val overflowTrackBrush: Brush,
+    val disabledIndicatorBrush: Brush,
+    val disabledTrackBrush: Brush,
+    val disabledOverflowTrackBrush: Brush,
 ) {
     internal fun copy(
         indicatorColor: Color = Color.Unspecified,
         trackColor: Color = Color.Unspecified,
+        overflowTrackColor: Color = Color.Unspecified,
         disabledIndicatorColor: Color = Color.Unspecified,
         disabledTrackColor: Color = Color.Unspecified,
+        disabledOverflowTrackColor: Color = Color.Unspecified,
     ) =
         ProgressIndicatorColors(
             indicatorBrush =
                 if (indicatorColor.isSpecified) SolidColor(indicatorColor) else indicatorBrush,
             trackBrush = if (trackColor.isSpecified) SolidColor(trackColor) else trackBrush,
+            overflowTrackBrush =
+                if (overflowTrackColor.isSpecified) SolidColor(overflowTrackColor)
+                else overflowTrackBrush,
             disabledIndicatorBrush =
                 if (disabledIndicatorColor.isSpecified) SolidColor(disabledIndicatorColor)
                 else disabledIndicatorBrush,
             disabledTrackBrush =
                 if (disabledTrackColor.isSpecified) SolidColor(disabledTrackColor)
                 else disabledTrackBrush,
+            disabledOverflowTrackBrush =
+                if (disabledOverflowTrackColor.isSpecified) SolidColor(disabledOverflowTrackColor)
+                else disabledOverflowTrackBrush,
         )
 
     internal fun copy(
         indicatorBrush: Brush? = null,
         trackBrush: Brush? = null,
+        overflowTrackBrush: Brush? = null,
         disabledIndicatorBrush: Brush? = null,
         disabledTrackBrush: Brush? = null,
+        disabledOverflowTrackBrush: Brush? = null,
     ) =
         ProgressIndicatorColors(
             indicatorBrush = indicatorBrush ?: this.indicatorBrush,
             trackBrush = trackBrush ?: this.trackBrush,
+            overflowTrackBrush = overflowTrackBrush ?: this.overflowTrackBrush,
             disabledIndicatorBrush = disabledIndicatorBrush ?: this.disabledIndicatorBrush,
             disabledTrackBrush = disabledTrackBrush ?: this.disabledTrackBrush,
+            disabledOverflowTrackBrush =
+                disabledOverflowTrackBrush ?: this.disabledOverflowTrackBrush,
         )
 
     /**
@@ -158,12 +201,17 @@
     }
 
     /**
-     * Represents the track color, depending on [enabled].
+     * Represents the track color, depending on [enabled] and [hasOverflow] parameters.
      *
      * @param enabled whether the component is enabled.
+     * @param enabled whether the progress has overflow.
      */
-    internal fun trackBrush(enabled: Boolean): Brush {
-        return if (enabled) trackBrush else disabledTrackBrush
+    internal fun trackBrush(enabled: Boolean, hasOverflow: Boolean = false): Brush {
+        return if (enabled) {
+            if (hasOverflow) overflowTrackBrush else trackBrush
+        } else {
+            if (hasOverflow) disabledOverflowTrackBrush else disabledTrackBrush
+        }
     }
 
     override fun equals(other: Any?): Boolean {
@@ -172,8 +220,10 @@
 
         if (indicatorBrush != other.indicatorBrush) return false
         if (trackBrush != other.trackBrush) return false
+        if (overflowTrackBrush != other.overflowTrackBrush) return false
         if (disabledIndicatorBrush != other.disabledIndicatorBrush) return false
         if (disabledTrackBrush != other.disabledTrackBrush) return false
+        if (disabledOverflowTrackBrush != other.disabledOverflowTrackBrush) return false
 
         return true
     }
@@ -181,8 +231,10 @@
     override fun hashCode(): Int {
         var result = indicatorBrush.hashCode()
         result = 31 * result + trackBrush.hashCode()
+        result = 31 * result + overflowTrackBrush.hashCode()
         result = 31 * result + disabledIndicatorBrush.hashCode()
         result = 31 * result + disabledTrackBrush.hashCode()
+        result = 31 * result + disabledOverflowTrackBrush.hashCode()
         return result
     }
 }
@@ -243,3 +295,25 @@
         )
     }
 }
+
+/**
+ * Coerce a [Float] progress value to [0.0..1.0] range.
+ *
+ * If overflow is enabled, truncate overflow values larger than 1.0 to only the fractional part.
+ * Integer values larger than 0.0 always return 1.0 (full progress) and negative values are coerced
+ * to 0.0. For example: 1.2 will be return 0.2, and 2.0 will return 1.0. If overflow is disabled,
+ * simply coerce all values to [0.0..1.0] range. For example, 1.2 and 2.0 will both return 1.0.
+ *
+ * @param progress The progress value to be coerced to [0.0..1.0] range.
+ * @param allowProgressOverflow If overflow is allowed.
+ */
+internal fun coerceProgress(progress: Float, allowProgressOverflow: Boolean): Float {
+    if (!allowProgressOverflow) return progress.coerceIn(0f, 1f)
+    if (progress <= 0.0f) return 0.0f
+    if (progress <= 1.0f) return progress
+
+    val fraction = progress % 1.0f
+    // Round to 5 decimals to avoid floating point errors.
+    val roundedFraction = round(fraction * 100000f) / 100000f
+    return if (roundedFraction == 0.0f) 1.0f else roundedFraction
+}
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/SegmentedCircularProgressIndicator.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/SegmentedCircularProgressIndicator.kt
index 63417be..c212948 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/SegmentedCircularProgressIndicator.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/SegmentedCircularProgressIndicator.kt
@@ -41,9 +41,15 @@
  * @param segmentCount Number of equal segments that the progress indicator should be divided into.
  *   Has to be a number equal or greater to 1.
  * @param progress The progress of this progress indicator where 0.0 represents no progress and 1.0
- *   represents completion. Values outside of this range are coerced into the range 0..1. The
- *   progress is applied to the entire [SegmentedCircularProgressIndicator] across all segments.
+ *   represents completion. Values smaller than 0.0 will be coerced to 0, while values larger than
+ *   1.0 will be wrapped around and shown as overflow with a different track color. The progress is
+ *   applied to the entire [SegmentedCircularProgressIndicator] across all segments.
  * @param modifier Modifier to be applied to the SegmentedCircularProgressIndicator.
+ * @param allowProgressOverflow When progress overflow is allowed, values smaller than 0.0 will be
+ *   coerced to 0, while values larger than 1.0 will be wrapped around and shown as overflow with a
+ *   different track color [ProgressIndicatorColors.overflowTrackBrush]. For example values 1.2, 2.2
+ *   etc will be shown as 20% progress with the overflow color. When progress overflow is not
+ *   allowed, progress values will be coerced into the range 0..1.
  * @param startAngle The starting position of the progress arc, measured clockwise in degrees (0
  *   to 360) from the 3 o'clock position. For example, 0 and 360 represent 3 o'clock, 90 and 180
  *   represent 6 o'clock and 9 o'clock respectively. Default is 270 degrees
@@ -64,6 +70,7 @@
     @IntRange(from = 1) segmentCount: Int,
     progress: () -> Float,
     modifier: Modifier = Modifier,
+    allowProgressOverflow: Boolean = false,
     startAngle: Float = CircularProgressIndicatorDefaults.StartAngle,
     endAngle: Float = startAngle,
     colors: ProgressIndicatorColors = ProgressIndicatorDefaults.colors(),
@@ -72,7 +79,7 @@
     enabled: Boolean = true,
 ) =
     SegmentedCircularProgressIndicatorImpl(
-        segmentParams = SegmentParams.Progress(progress),
+        segmentParams = SegmentParams.Progress(progress, allowProgressOverflow),
         modifier = modifier,
         segmentCount = segmentCount,
         startAngle = startAngle,
@@ -191,15 +198,23 @@
                                 )
                             }
                             is SegmentParams.Progress -> {
-                                val progressInSegments =
-                                    segmentCount * segmentParams.progress().coerceIn(0f, 1f)
+                                val currentProgress = segmentParams.progress()
+                                val coercedProgress =
+                                    coerceProgress(currentProgress, segmentParams.allowOverflow)
+                                val progressInSegments = segmentCount * coercedProgress
+                                val hasOverflow =
+                                    segmentParams.allowOverflow && currentProgress > 1.0f
 
                                 if (segment >= floor(progressInSegments)) {
                                     drawIndicatorSegment(
                                         startAngle = segmentStartAngle,
                                         sweep = segmentSweepAngle,
                                         gapSweep = 0f, // Overlay, no gap
-                                        brush = colors.trackBrush(enabled),
+                                        brush =
+                                            colors.trackBrush(
+                                                enabled = enabled,
+                                                hasOverflow = hasOverflow
+                                            ),
                                         stroke = stroke
                                     )
                                 }
@@ -227,5 +242,5 @@
 private sealed interface SegmentParams {
     data class Completed(val completed: (segmentIndex: Int) -> Boolean) : SegmentParams
 
-    data class Progress(val progress: () -> Float) : SegmentParams
+    data class Progress(val progress: () -> Float, val allowOverflow: Boolean) : SegmentParams
 }
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/TextButton.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/TextButton.kt
index 20e8047..5753520 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/TextButton.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/TextButton.kt
@@ -17,14 +17,17 @@
 package androidx.wear.compose.material3
 
 import androidx.compose.animation.core.AnimationSpec
+import androidx.compose.animation.core.FiniteAnimationSpec
 import androidx.compose.animation.core.tween
 import androidx.compose.foundation.BorderStroke
 import androidx.compose.foundation.interaction.Interaction
 import androidx.compose.foundation.interaction.InteractionSource
 import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.interaction.collectIsPressedAsState
 import androidx.compose.foundation.layout.BoxScope
 import androidx.compose.foundation.layout.size
 import androidx.compose.foundation.shape.CornerBasedShape
+import androidx.compose.foundation.shape.CornerSize
 import androidx.compose.foundation.shape.RoundedCornerShape
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.Immutable
@@ -146,10 +149,15 @@
  * [TextToggleButton] can be enabled or disabled. A disabled button will not respond to click
  * events. When enabled, the checked and unchecked events are propagated by [onCheckedChange].
  *
- * A simple text toggle button using the default colors:
+ * A simple text toggle button using the default colors, animated when pressed.
  *
  * @sample androidx.wear.compose.material3.samples.TextToggleButtonSample
  *
+ * A simple text toggle button using the default colors, animated when pressed and with different
+ * shapes for the checked and unchecked states.
+ *
+ * @sample androidx.wear.compose.material3.samples.TextToggleButtonVariantSample
+ *
  * Example of a large text toggle button:
  *
  * @sample androidx.wear.compose.material3.samples.LargeTextToggleButtonSample
@@ -158,8 +166,8 @@
  * @param modifier Modifier to be applied to the toggle button.
  * @param enabled Controls the enabled state of the toggle button. When `false`, this toggle button
  *   will not be clickable.
- * @param colors [ToggleButtonColors] that will be used to resolve the container and content color
- *   for this toggle button.
+ * @param colors [TextToggleButtonColors] that will be used to resolve the container and content
+ *   color for this toggle button.
  * @param interactionSource an optional hoisted [MutableInteractionSource] for observing and
  *   emitting [Interaction]s for this toggle button. You can use this to change the toggle button's
  *   appearance or preview the toggle button in different states. Note that if `null` is provided,
@@ -175,7 +183,7 @@
     onCheckedChange: (Boolean) -> Unit,
     modifier: Modifier = Modifier,
     enabled: Boolean = true,
-    colors: TextToggleButtonColors = TextButtonDefaults.textToggleButtonColors(),
+    colors: TextToggleButtonColors = TextToggleButtonDefaults.textToggleButtonColors(),
     interactionSource: MutableInteractionSource? = null,
     shape: Shape = TextButtonDefaults.shape,
     border: BorderStroke? = null,
@@ -216,9 +224,16 @@
     /**
      * Creates a [Shape] with a animation between two CornerBasedShapes.
      *
+     * A simple text button using the default colors, animated when pressed.
+     *
+     * @sample androidx.wear.compose.material3.samples.TextButtonWithCornerAnimationSample
+     *
+     * A simple text toggle button using the default colors, animated when pressed.
+     *
+     * @sample androidx.wear.compose.material3.samples.TextToggleButtonSample
      * @param interactionSource the interaction source applied to the Button.
-     * @param shape The normal shape of the IconButton.
-     * @param pressedShape The pressed shape of the IconButton.
+     * @param shape The normal shape of the TextButton.
+     * @param pressedShape The pressed shape of the TextButton.
      */
     @Composable
     fun animatedShape(
@@ -265,7 +280,7 @@
     /**
      * Creates a [TextButtonColors] as an alternative to the [filledTonal TextButtonColors], giving
      * a surface with more chroma to indicate selected or highlighted states that are not primary
-     * calls-to-action. If the icon button is disabled then the colors will default to the
+     * calls-to-action. If the text button is disabled then the colors will default to the
      * MaterialTheme onSurface color with suitable alpha values applied.
      *
      * Example of creating a [TextButton] with [filledVariantTextButtonColors]:
@@ -279,7 +294,7 @@
     /**
      * Creates a [TextButtonColors] as an alternative to the [filledTonal TextButtonColors], giving
      * a surface with more chroma to indicate selected or highlighted states that are not primary
-     * calls-to-action. If the icon button is disabled then the colors will default to the
+     * calls-to-action. If the text button is disabled then the colors will default to the
      * MaterialTheme onSurface color with suitable alpha values applied.
      *
      * Example of creating a [TextButton] with [filledVariantTextButtonColors]:
@@ -403,60 +418,6 @@
         )
 
     /**
-     * Creates a [TextToggleButtonColors] for a [TextToggleButton]
-     * - by default, a colored background with a contrasting content color. If the button is
-     *   disabled, then the colors will have an alpha ([DisabledContainerAlpha] or
-     *   [DisabledContentAlpha]) value applied.
-     */
-    @Composable
-    fun textToggleButtonColors() = MaterialTheme.colorScheme.defaultTextToggleButtonColors
-
-    /**
-     * Creates a [TextToggleButtonColors] for a [TextToggleButton]
-     * - by default, a colored background with a contrasting content color. If the button is
-     *   disabled, then the colors will have an alpha ([DisabledContainerAlpha] or
-     *   [DisabledContentAlpha]) value applied.
-     *
-     * @param checkedContainerColor the container color of this [TextToggleButton] when enabled and
-     *   checked
-     * @param checkedContentColor the content color of this [TextToggleButton] when enabled and
-     *   checked
-     * @param uncheckedContainerColor the container color of this [TextToggleButton] when enabled
-     *   and unchecked
-     * @param uncheckedContentColor the content color of this [TextToggleButton] when enabled and
-     *   unchecked
-     * @param disabledCheckedContainerColor the container color of this [TextToggleButton] when
-     *   checked and not enabled
-     * @param disabledCheckedContentColor the content color of this [TextToggleButton] when checked
-     *   and not enabled
-     * @param disabledUncheckedContainerColor the container color of this [TextToggleButton] when
-     *   unchecked and not enabled
-     * @param disabledUncheckedContentColor the content color of this [TextToggleButton] when
-     *   unchecked and not enabled
-     */
-    @Composable
-    fun textToggleButtonColors(
-        checkedContainerColor: Color = Color.Unspecified,
-        checkedContentColor: Color = Color.Unspecified,
-        uncheckedContainerColor: Color = Color.Unspecified,
-        uncheckedContentColor: Color = Color.Unspecified,
-        disabledCheckedContainerColor: Color = Color.Unspecified,
-        disabledCheckedContentColor: Color = Color.Unspecified,
-        disabledUncheckedContainerColor: Color = Color.Unspecified,
-        disabledUncheckedContentColor: Color = Color.Unspecified,
-    ): TextToggleButtonColors =
-        MaterialTheme.colorScheme.defaultTextToggleButtonColors.copy(
-            checkedContainerColor = checkedContainerColor,
-            checkedContentColor = checkedContentColor,
-            uncheckedContainerColor = uncheckedContainerColor,
-            uncheckedContentColor = uncheckedContentColor,
-            disabledCheckedContainerColor = disabledCheckedContainerColor,
-            disabledCheckedContentColor = disabledCheckedContentColor,
-            disabledUncheckedContainerColor = disabledUncheckedContainerColor,
-            disabledUncheckedContentColor = disabledUncheckedContentColor,
-        )
-
-    /**
      * The recommended size for a small button. It is recommended to apply this size using
      * [Modifier.touchTargetAwareSize].
      */
@@ -579,45 +540,6 @@
                     )
                     .also { defaultTextButtonColorsCached = it }
         }
-
-    private val ColorScheme.defaultTextToggleButtonColors: TextToggleButtonColors
-        get() {
-            return defaultTextToggleButtonColorsCached
-                ?: TextToggleButtonColors(
-                        checkedContainerColor =
-                            fromToken(TextToggleButtonTokens.CheckedContainerColor),
-                        checkedContentColor = fromToken(TextToggleButtonTokens.CheckedContentColor),
-                        uncheckedContainerColor =
-                            fromToken(TextToggleButtonTokens.UncheckedContainerColor),
-                        uncheckedContentColor =
-                            fromToken(TextToggleButtonTokens.UncheckedContentColor),
-                        disabledCheckedContainerColor =
-                            fromToken(TextToggleButtonTokens.DisabledCheckedContainerColor)
-                                .toDisabledColor(
-                                    disabledAlpha =
-                                        TextToggleButtonTokens.DisabledCheckedContainerOpacity
-                                ),
-                        disabledCheckedContentColor =
-                            fromToken(TextToggleButtonTokens.DisabledCheckedContentColor)
-                                .toDisabledColor(
-                                    disabledAlpha =
-                                        TextToggleButtonTokens.DisabledCheckedContentOpacity
-                                ),
-                        disabledUncheckedContainerColor =
-                            fromToken(TextToggleButtonTokens.DisabledUncheckedContainerColor)
-                                .toDisabledColor(
-                                    disabledAlpha =
-                                        TextToggleButtonTokens.DisabledUncheckedContainerOpacity
-                                ),
-                        disabledUncheckedContentColor =
-                            fromToken(TextToggleButtonTokens.DisabledUncheckedContentColor)
-                                .toDisabledColor(
-                                    disabledAlpha =
-                                        TextToggleButtonTokens.DisabledUncheckedContentOpacity
-                                ),
-                    )
-                    .also { defaultTextToggleButtonColorsCached = it }
-        }
 }
 
 /**
@@ -697,6 +619,152 @@
     }
 }
 
+/** Contains the default values used by [TextToggleButton]. */
+object TextToggleButtonDefaults {
+
+    /**
+     * Creates a [Shape] with an animation between three [CornerSize]s based on the pressed state
+     * and checked/unchecked.
+     *
+     * A simple text toggle button using the default colors, animated on Press and Check/Uncheck:
+     *
+     * @sample androidx.wear.compose.material3.samples.TextToggleButtonVariantSample
+     * @param interactionSource the interaction source applied to the Button.
+     * @param checked the current checked/unchecked state.
+     * @param uncheckedCornerSize the size of the corner when unchecked.
+     * @param checkedCornerSize the size of the corner when checked.
+     * @param pressedCornerSize the size of the corner when pressed.
+     * @param onPressAnimationSpec the spec for press animation.
+     * @param onReleaseAnimationSpec the spec for release animation.
+     */
+    @Composable
+    fun animatedToggleButtonShape(
+        interactionSource: InteractionSource,
+        checked: Boolean,
+        uncheckedCornerSize: CornerSize = UncheckedCornerSize,
+        checkedCornerSize: CornerSize = CheckedCornerSize,
+        pressedCornerSize: CornerSize = PressedCornerSize,
+        onPressAnimationSpec: FiniteAnimationSpec<Float> =
+            MaterialTheme.motionScheme.rememberFastSpatialSpec(),
+        onReleaseAnimationSpec: FiniteAnimationSpec<Float> =
+            MaterialTheme.motionScheme.slowSpatialSpec(),
+    ): Shape {
+        val pressed = interactionSource.collectIsPressedAsState()
+
+        return rememberAnimatedToggleRoundedCornerShape(
+            uncheckedCornerSize = uncheckedCornerSize,
+            checkedCornerSize = checkedCornerSize,
+            pressedCornerSize = pressedCornerSize,
+            pressed = pressed.value,
+            checked = checked,
+            onPressAnimationSpec = onPressAnimationSpec,
+            onReleaseAnimationSpec = onReleaseAnimationSpec,
+        )
+    }
+
+    /** The recommended size for an Unchecked button when animated. */
+    val UncheckedCornerSize: CornerSize = ShapeTokens.CornerFull.topEnd
+
+    /** The recommended size for a Checked button when animated. */
+    val CheckedCornerSize: CornerSize = CornerSize(percent = 30)
+
+    /** The recommended size for a Pressed button when animated. */
+    val PressedCornerSize: CornerSize = ShapeDefaults.Small.topEnd
+
+    /**
+     * Creates a [TextToggleButtonColors] for a [TextToggleButton]
+     * - by default, a colored background with a contrasting content color. If the button is
+     *   disabled, then the colors will have an alpha ([DisabledContainerAlpha] or
+     *   [DisabledContentAlpha]) value applied.
+     */
+    @Composable
+    fun textToggleButtonColors() = MaterialTheme.colorScheme.defaultTextToggleButtonColors
+
+    /**
+     * Creates a [TextToggleButtonColors] for a [TextToggleButton]
+     * - by default, a colored background with a contrasting content color. If the button is
+     *   disabled, then the colors will have an alpha ([DisabledContainerAlpha] or
+     *   [DisabledContentAlpha]) value applied.
+     *
+     * @param checkedContainerColor the container color of this [TextToggleButton] when enabled and
+     *   checked
+     * @param checkedContentColor the content color of this [TextToggleButton] when enabled and
+     *   checked
+     * @param uncheckedContainerColor the container color of this [TextToggleButton] when enabled
+     *   and unchecked
+     * @param uncheckedContentColor the content color of this [TextToggleButton] when enabled and
+     *   unchecked
+     * @param disabledCheckedContainerColor the container color of this [TextToggleButton] when
+     *   checked and not enabled
+     * @param disabledCheckedContentColor the content color of this [TextToggleButton] when checked
+     *   and not enabled
+     * @param disabledUncheckedContainerColor the container color of this [TextToggleButton] when
+     *   unchecked and not enabled
+     * @param disabledUncheckedContentColor the content color of this [TextToggleButton] when
+     *   unchecked and not enabled
+     */
+    @Composable
+    fun textToggleButtonColors(
+        checkedContainerColor: Color = Color.Unspecified,
+        checkedContentColor: Color = Color.Unspecified,
+        uncheckedContainerColor: Color = Color.Unspecified,
+        uncheckedContentColor: Color = Color.Unspecified,
+        disabledCheckedContainerColor: Color = Color.Unspecified,
+        disabledCheckedContentColor: Color = Color.Unspecified,
+        disabledUncheckedContainerColor: Color = Color.Unspecified,
+        disabledUncheckedContentColor: Color = Color.Unspecified,
+    ): TextToggleButtonColors =
+        MaterialTheme.colorScheme.defaultTextToggleButtonColors.copy(
+            checkedContainerColor = checkedContainerColor,
+            checkedContentColor = checkedContentColor,
+            uncheckedContainerColor = uncheckedContainerColor,
+            uncheckedContentColor = uncheckedContentColor,
+            disabledCheckedContainerColor = disabledCheckedContainerColor,
+            disabledCheckedContentColor = disabledCheckedContentColor,
+            disabledUncheckedContainerColor = disabledUncheckedContainerColor,
+            disabledUncheckedContentColor = disabledUncheckedContentColor,
+        )
+
+    private val ColorScheme.defaultTextToggleButtonColors: TextToggleButtonColors
+        get() {
+            return defaultTextToggleButtonColorsCached
+                ?: TextToggleButtonColors(
+                        checkedContainerColor =
+                            fromToken(TextToggleButtonTokens.CheckedContainerColor),
+                        checkedContentColor = fromToken(TextToggleButtonTokens.CheckedContentColor),
+                        uncheckedContainerColor =
+                            fromToken(TextToggleButtonTokens.UncheckedContainerColor),
+                        uncheckedContentColor =
+                            fromToken(TextToggleButtonTokens.UncheckedContentColor),
+                        disabledCheckedContainerColor =
+                            fromToken(TextToggleButtonTokens.DisabledCheckedContainerColor)
+                                .toDisabledColor(
+                                    disabledAlpha =
+                                        TextToggleButtonTokens.DisabledCheckedContainerOpacity
+                                ),
+                        disabledCheckedContentColor =
+                            fromToken(TextToggleButtonTokens.DisabledCheckedContentColor)
+                                .toDisabledColor(
+                                    disabledAlpha =
+                                        TextToggleButtonTokens.DisabledCheckedContentOpacity
+                                ),
+                        disabledUncheckedContainerColor =
+                            fromToken(TextToggleButtonTokens.DisabledUncheckedContainerColor)
+                                .toDisabledColor(
+                                    disabledAlpha =
+                                        TextToggleButtonTokens.DisabledUncheckedContainerOpacity
+                                ),
+                        disabledUncheckedContentColor =
+                            fromToken(TextToggleButtonTokens.DisabledUncheckedContentColor)
+                                .toDisabledColor(
+                                    disabledAlpha =
+                                        TextToggleButtonTokens.DisabledUncheckedContentOpacity
+                                ),
+                    )
+                    .also { defaultTextToggleButtonColorsCached = it }
+        }
+}
+
 /**
  * Represents the different container and content colors used for [TextToggleButton] in various
  * states, that are checked, unchecked, enabled and disabled.
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/FilledTonalIconButtonTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/FilledTonalIconButtonTokens.kt
index fa7cca4..681e78f 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/FilledTonalIconButtonTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/FilledTonalIconButtonTokens.kt
@@ -20,7 +20,7 @@
 package androidx.wear.compose.material3.tokens
 internal object FilledTonalIconButtonTokens {
     val ContainerColor = ColorSchemeKeyTokens.SurfaceContainer
-    val ContentColor = ColorSchemeKeyTokens.OnSurfaceVariant
+    val ContentColor = ColorSchemeKeyTokens.Primary
     val DisabledContainerColor = ColorSchemeKeyTokens.OnSurface
     val DisabledContainerOpacity = 0.12f
     val DisabledContentColor = ColorSchemeKeyTokens.OnSurface
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/FilledTonalTextButtonTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/FilledTonalTextButtonTokens.kt
index a7c42e8..a220a6a 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/FilledTonalTextButtonTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/FilledTonalTextButtonTokens.kt
@@ -21,7 +21,7 @@
 internal object FilledTonalTextButtonTokens {
     val ContainerColor = ColorSchemeKeyTokens.SurfaceContainer
     val ContainerShape = ShapeKeyTokens.CornerFull
-    val ContentColor = ColorSchemeKeyTokens.OnSurfaceVariant
+    val ContentColor = ColorSchemeKeyTokens.OnSurface
     val ContentFont = TypographyKeyTokens.LabelMedium
     val DisabledContainerColor = ColorSchemeKeyTokens.OnSurface
     val DisabledContainerOpacity = 0.12f
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/IconButtonTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/IconButtonTokens.kt
index 34cb9fc..c54e56e 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/IconButtonTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/IconButtonTokens.kt
@@ -27,7 +27,7 @@
     val ContainerLargeSize = 60.0.dp
     val ContainerShape = ShapeKeyTokens.CornerFull
     val ContainerSmallSize = 48.0.dp
-    val ContentColor = ColorSchemeKeyTokens.OnSurface
+    val ContentColor = ColorSchemeKeyTokens.Primary
     val DisabledContentColor = ColorSchemeKeyTokens.OnSurface
     val DisabledContentOpacity = 0.38f
     val IconDefaultSize = 26.0.dp
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/OutlinedIconButtonTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/OutlinedIconButtonTokens.kt
index d0e84d0..02f0728 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/OutlinedIconButtonTokens.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/OutlinedIconButtonTokens.kt
@@ -19,7 +19,7 @@
 
 package androidx.wear.compose.material3.tokens
 internal object OutlinedIconButtonTokens {
-    val ContentColor = ColorSchemeKeyTokens.OnSurface
+    val ContentColor = ColorSchemeKeyTokens.Primary
     val DisabledContentColor = ColorSchemeKeyTokens.OnSurface
     val DisabledContentOpacity = 0.38f
 }
diff --git a/wear/compose/compose-material3/src/test/kotlin/androidx/wear/compose/material3/ProgressIndicatorTest.kt b/wear/compose/compose-material3/src/test/kotlin/androidx/wear/compose/material3/ProgressIndicatorTest.kt
new file mode 100644
index 0000000..5d231b0
--- /dev/null
+++ b/wear/compose/compose-material3/src/test/kotlin/androidx/wear/compose/material3/ProgressIndicatorTest.kt
@@ -0,0 +1,76 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.wear.compose.material3
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+
+@RunWith(JUnit4::class)
+class ProgressIndicatorTest {
+
+    @Test
+    fun coerce_progress_fraction_overflow_enabled() {
+        assertEquals(0.2f, coerceProgress(0.2f, true))
+    }
+
+    @Test
+    fun coerce_progress_fraction_greater_than_one_overflow_enabled() {
+        assertEquals(0.2f, coerceProgress(1.2f, true))
+    }
+
+    @Test
+    fun coerce_progress_integer_greater_than_one_overflow_enabled() {
+        assertEquals(1.0f, coerceProgress(2.0f, true))
+    }
+
+    @Test
+    fun coerce_progress_zero_overflow_enabled() {
+        assertEquals(0.0f, coerceProgress(0.0f, true))
+    }
+
+    @Test
+    fun coerce_progress_negative_overflow_enabled() {
+        assertEquals(0.0f, coerceProgress(-1.0f, true))
+    }
+
+    @Test
+    fun coerce_progress_fraction_overflow_disabled() {
+        assertEquals(0.2f, coerceProgress(0.2f, false))
+    }
+
+    @Test
+    fun coerce_progress_fraction_greater_than_one_overflow_disabled() {
+        assertEquals(1.0f, coerceProgress(1.2f, false))
+    }
+
+    @Test
+    fun coerce_progress_integer_greater_than_one_overflow_disabled() {
+        assertEquals(1.0f, coerceProgress(2.0f, false))
+    }
+
+    @Test
+    fun coerce_progress_zero_overflow_disabled() {
+        assertEquals(0.0f, coerceProgress(0.0f, false))
+    }
+
+    @Test
+    fun coerce_progress_negative_overflow_disabled() {
+        assertEquals(0.0f, coerceProgress(-1.0f, false))
+    }
+}
diff --git a/wear/compose/compose-navigation/build.gradle b/wear/compose/compose-navigation/build.gradle
index 1e61bdbc..8d27898 100644
--- a/wear/compose/compose-navigation/build.gradle
+++ b/wear/compose/compose-navigation/build.gradle
@@ -41,7 +41,7 @@
     implementation(libs.kotlinStdlib)
     implementation("androidx.navigation:navigation-common:2.6.0")
     implementation("androidx.navigation:navigation-compose:2.6.0")
-    implementation("androidx.profileinstaller:profileinstaller:1.3.1")
+    implementation("androidx.profileinstaller:profileinstaller:1.4.0")
 
     androidTestImplementation(project(":compose:test-utils"))
     androidTestImplementation(project(":compose:ui:ui-test-junit4"))
diff --git a/wear/compose/integration-tests/demos/build.gradle b/wear/compose/integration-tests/demos/build.gradle
index 502cc258..d0a3eb0 100644
--- a/wear/compose/integration-tests/demos/build.gradle
+++ b/wear/compose/integration-tests/demos/build.gradle
@@ -26,8 +26,8 @@
     defaultConfig {
         applicationId "androidx.wear.compose.integration.demos"
         minSdk 25
-        versionCode 38
-        versionName "1.38"
+        versionCode 39
+        versionName "1.39"
     }
 
     buildTypes {
diff --git a/wear/protolayout/protolayout-expression/src/main/java/androidx/wear/protolayout/expression/util/DynamicDateFormat.kt b/wear/protolayout/protolayout-expression/src/main/java/androidx/wear/protolayout/expression/util/DynamicDateFormat.kt
index 8a6e447..653b51fb 100644
--- a/wear/protolayout/protolayout-expression/src/main/java/androidx/wear/protolayout/expression/util/DynamicDateFormat.kt
+++ b/wear/protolayout/protolayout-expression/src/main/java/androidx/wear/protolayout/expression/util/DynamicDateFormat.kt
@@ -60,6 +60,9 @@
  *       .format(DynamicInt32.IntFormatter.Builder().setMinIntegerDigits(2).build())
  *   )
  * ```
+ *
+ * @property timeZone The time zone used when extracting time parts from the [DynamicInstant]
+ *   provided to [format], defaults to [ZoneId.systemDefault].
  */
 @RequiresSchemaVersion(major = 1, minor = 300)
 public class DynamicDateFormat
@@ -71,7 +74,15 @@
     private val locale: Locale?,
     public var timeZone: ZoneId,
 ) {
-
+    /**
+     * Constructs a [DynamicDateFormat].
+     *
+     * @param pattern The pattern to use when calling [format], see
+     *   [android.icu.text.SimpleDateFormat] for general syntax and [DynamicDateFormat] for the
+     *   supported subset of features.
+     * @param timeZone The time zone used when extracting time parts from the [DynamicInstant]
+     *   provided to [format], defaults to [ZoneId.systemDefault].
+     */
     @JvmOverloads
     public constructor(
         pattern: String,
@@ -88,8 +99,10 @@
     private val patternParts: List<Part> = extractPatternParts().mergeConstants().toList()
 
     /**
-     * Formats the [DynamicInstant] (defaults to [DynamicInstant.platformTimeWithSecondsPrecision])
-     * into a date/time [DynamicString].
+     * Formats the [DynamicInstant] into a date/time [DynamicString].
+     *
+     * @param instant The [DynamicInstant] to format, defaults to
+     *   [DynamicInstant.platformTimeWithSecondsPrecision].
      */
     @RequiresSchemaVersion(major = 1, minor = 300)
     @JvmOverloads
diff --git a/wear/watchface/watchface-style/api/current.txt b/wear/watchface/watchface-style/api/current.txt
index f980193..d5a9d8f 100644
--- a/wear/watchface/watchface-style/api/current.txt
+++ b/wear/watchface/watchface-style/api/current.txt
@@ -112,6 +112,8 @@
   public static final class UserStyleSetting.BooleanUserStyleSetting extends androidx.wear.watchface.style.UserStyleSetting {
     ctor public UserStyleSetting.BooleanUserStyleSetting(androidx.wear.watchface.style.UserStyleSetting.Id id, android.content.res.Resources resources, @StringRes int displayNameResourceId, @StringRes int descriptionResourceId, android.graphics.drawable.Icon? icon, java.util.Collection<? extends androidx.wear.watchface.style.WatchFaceLayer> affectsWatchFaceLayers, boolean defaultValue);
     ctor public UserStyleSetting.BooleanUserStyleSetting(androidx.wear.watchface.style.UserStyleSetting.Id id, android.content.res.Resources resources, @StringRes int displayNameResourceId, @StringRes int descriptionResourceId, android.graphics.drawable.Icon? icon, java.util.Collection<? extends androidx.wear.watchface.style.WatchFaceLayer> affectsWatchFaceLayers, boolean defaultValue, optional androidx.wear.watchface.style.UserStyleSetting.WatchFaceEditorData? watchFaceEditorData);
+    ctor public UserStyleSetting.BooleanUserStyleSetting(androidx.wear.watchface.style.UserStyleSetting.Id id, android.content.res.Resources resources, @StringRes int displayNameResourceId, @StringRes int descriptionResourceId, kotlin.jvm.functions.Function0<android.graphics.drawable.Icon?> iconProvider, java.util.Collection<? extends androidx.wear.watchface.style.WatchFaceLayer> affectsWatchFaceLayers, boolean defaultValue);
+    ctor public UserStyleSetting.BooleanUserStyleSetting(androidx.wear.watchface.style.UserStyleSetting.Id id, android.content.res.Resources resources, @StringRes int displayNameResourceId, @StringRes int descriptionResourceId, kotlin.jvm.functions.Function0<android.graphics.drawable.Icon?> iconProvider, java.util.Collection<? extends androidx.wear.watchface.style.WatchFaceLayer> affectsWatchFaceLayers, boolean defaultValue, optional androidx.wear.watchface.style.UserStyleSetting.WatchFaceEditorData? watchFaceEditorData);
     method public boolean getDefaultValue();
   }
 
@@ -135,6 +137,9 @@
     ctor public UserStyleSetting.ComplicationSlotsUserStyleSetting(androidx.wear.watchface.style.UserStyleSetting.Id id, android.content.res.Resources resources, @StringRes int displayNameResourceId, @StringRes int descriptionResourceId, android.graphics.drawable.Icon? icon, java.util.List<androidx.wear.watchface.style.UserStyleSetting.ComplicationSlotsUserStyleSetting.ComplicationSlotsOption> complicationConfig, java.util.Collection<? extends androidx.wear.watchface.style.WatchFaceLayer> affectsWatchFaceLayers);
     ctor public UserStyleSetting.ComplicationSlotsUserStyleSetting(androidx.wear.watchface.style.UserStyleSetting.Id id, android.content.res.Resources resources, @StringRes int displayNameResourceId, @StringRes int descriptionResourceId, android.graphics.drawable.Icon? icon, java.util.List<androidx.wear.watchface.style.UserStyleSetting.ComplicationSlotsUserStyleSetting.ComplicationSlotsOption> complicationConfig, java.util.Collection<? extends androidx.wear.watchface.style.WatchFaceLayer> affectsWatchFaceLayers, optional androidx.wear.watchface.style.UserStyleSetting.ComplicationSlotsUserStyleSetting.ComplicationSlotsOption defaultOption);
     ctor public UserStyleSetting.ComplicationSlotsUserStyleSetting(androidx.wear.watchface.style.UserStyleSetting.Id id, android.content.res.Resources resources, @StringRes int displayNameResourceId, @StringRes int descriptionResourceId, android.graphics.drawable.Icon? icon, java.util.List<androidx.wear.watchface.style.UserStyleSetting.ComplicationSlotsUserStyleSetting.ComplicationSlotsOption> complicationConfig, java.util.Collection<? extends androidx.wear.watchface.style.WatchFaceLayer> affectsWatchFaceLayers, optional androidx.wear.watchface.style.UserStyleSetting.ComplicationSlotsUserStyleSetting.ComplicationSlotsOption defaultOption, optional androidx.wear.watchface.style.UserStyleSetting.WatchFaceEditorData? watchFaceEditorData);
+    ctor public UserStyleSetting.ComplicationSlotsUserStyleSetting(androidx.wear.watchface.style.UserStyleSetting.Id id, android.content.res.Resources resources, @StringRes int displayNameResourceId, @StringRes int descriptionResourceId, kotlin.jvm.functions.Function0<android.graphics.drawable.Icon?> iconProvider, java.util.List<androidx.wear.watchface.style.UserStyleSetting.ComplicationSlotsUserStyleSetting.ComplicationSlotsOption> complicationConfig, java.util.Collection<? extends androidx.wear.watchface.style.WatchFaceLayer> affectsWatchFaceLayers);
+    ctor public UserStyleSetting.ComplicationSlotsUserStyleSetting(androidx.wear.watchface.style.UserStyleSetting.Id id, android.content.res.Resources resources, @StringRes int displayNameResourceId, @StringRes int descriptionResourceId, kotlin.jvm.functions.Function0<android.graphics.drawable.Icon?> iconProvider, java.util.List<androidx.wear.watchface.style.UserStyleSetting.ComplicationSlotsUserStyleSetting.ComplicationSlotsOption> complicationConfig, java.util.Collection<? extends androidx.wear.watchface.style.WatchFaceLayer> affectsWatchFaceLayers, optional androidx.wear.watchface.style.UserStyleSetting.ComplicationSlotsUserStyleSetting.ComplicationSlotsOption defaultOption);
+    ctor public UserStyleSetting.ComplicationSlotsUserStyleSetting(androidx.wear.watchface.style.UserStyleSetting.Id id, android.content.res.Resources resources, @StringRes int displayNameResourceId, @StringRes int descriptionResourceId, kotlin.jvm.functions.Function0<android.graphics.drawable.Icon?> iconProvider, java.util.List<androidx.wear.watchface.style.UserStyleSetting.ComplicationSlotsUserStyleSetting.ComplicationSlotsOption> complicationConfig, java.util.Collection<? extends androidx.wear.watchface.style.WatchFaceLayer> affectsWatchFaceLayers, optional androidx.wear.watchface.style.UserStyleSetting.ComplicationSlotsUserStyleSetting.ComplicationSlotsOption defaultOption, optional androidx.wear.watchface.style.UserStyleSetting.WatchFaceEditorData? watchFaceEditorData);
   }
 
   public static final class UserStyleSetting.ComplicationSlotsUserStyleSetting.ComplicationSlotOverlay {
@@ -169,6 +174,8 @@
     ctor @Deprecated public UserStyleSetting.ComplicationSlotsUserStyleSetting.ComplicationSlotsOption(androidx.wear.watchface.style.UserStyleSetting.Option.Id id, android.content.res.Resources resources, @StringRes int displayNameResourceId, android.graphics.drawable.Icon? icon, java.util.Collection<androidx.wear.watchface.style.UserStyleSetting.ComplicationSlotsUserStyleSetting.ComplicationSlotOverlay> complicationSlotOverlays, optional androidx.wear.watchface.style.UserStyleSetting.WatchFaceEditorData? watchFaceEditorData);
     ctor public UserStyleSetting.ComplicationSlotsUserStyleSetting.ComplicationSlotsOption(androidx.wear.watchface.style.UserStyleSetting.Option.Id id, android.content.res.Resources resources, @StringRes int displayNameResourceId, @StringRes int screenReaderNameResourceId, android.graphics.drawable.Icon? icon, java.util.Collection<androidx.wear.watchface.style.UserStyleSetting.ComplicationSlotsUserStyleSetting.ComplicationSlotOverlay> complicationSlotOverlays);
     ctor public UserStyleSetting.ComplicationSlotsUserStyleSetting.ComplicationSlotsOption(androidx.wear.watchface.style.UserStyleSetting.Option.Id id, android.content.res.Resources resources, @StringRes int displayNameResourceId, @StringRes int screenReaderNameResourceId, android.graphics.drawable.Icon? icon, java.util.Collection<androidx.wear.watchface.style.UserStyleSetting.ComplicationSlotsUserStyleSetting.ComplicationSlotOverlay> complicationSlotOverlays, optional androidx.wear.watchface.style.UserStyleSetting.WatchFaceEditorData? watchFaceEditorData);
+    ctor public UserStyleSetting.ComplicationSlotsUserStyleSetting.ComplicationSlotsOption(androidx.wear.watchface.style.UserStyleSetting.Option.Id id, android.content.res.Resources resources, @StringRes int displayNameResourceId, @StringRes int screenReaderNameResourceId, kotlin.jvm.functions.Function0<android.graphics.drawable.Icon?> iconProvider, java.util.Collection<androidx.wear.watchface.style.UserStyleSetting.ComplicationSlotsUserStyleSetting.ComplicationSlotOverlay> complicationSlotOverlays);
+    ctor public UserStyleSetting.ComplicationSlotsUserStyleSetting.ComplicationSlotsOption(androidx.wear.watchface.style.UserStyleSetting.Option.Id id, android.content.res.Resources resources, @StringRes int displayNameResourceId, @StringRes int screenReaderNameResourceId, kotlin.jvm.functions.Function0<android.graphics.drawable.Icon?> iconProvider, java.util.Collection<androidx.wear.watchface.style.UserStyleSetting.ComplicationSlotsUserStyleSetting.ComplicationSlotOverlay> complicationSlotOverlays, optional androidx.wear.watchface.style.UserStyleSetting.WatchFaceEditorData? watchFaceEditorData);
     method public java.util.Collection<androidx.wear.watchface.style.UserStyleSetting.ComplicationSlotsUserStyleSetting.ComplicationSlotOverlay> getComplicationSlotOverlays();
     method public CharSequence getDisplayName();
     method public android.graphics.drawable.Icon? getIcon();
@@ -194,6 +201,8 @@
   public static final class UserStyleSetting.DoubleRangeUserStyleSetting extends androidx.wear.watchface.style.UserStyleSetting {
     ctor public UserStyleSetting.DoubleRangeUserStyleSetting(androidx.wear.watchface.style.UserStyleSetting.Id id, android.content.res.Resources resources, @StringRes int displayNameResourceId, @StringRes int descriptionResourceId, android.graphics.drawable.Icon? icon, double minimumValue, double maximumValue, java.util.Collection<? extends androidx.wear.watchface.style.WatchFaceLayer> affectsWatchFaceLayers, double defaultValue);
     ctor public UserStyleSetting.DoubleRangeUserStyleSetting(androidx.wear.watchface.style.UserStyleSetting.Id id, android.content.res.Resources resources, @StringRes int displayNameResourceId, @StringRes int descriptionResourceId, android.graphics.drawable.Icon? icon, double minimumValue, double maximumValue, java.util.Collection<? extends androidx.wear.watchface.style.WatchFaceLayer> affectsWatchFaceLayers, double defaultValue, optional androidx.wear.watchface.style.UserStyleSetting.WatchFaceEditorData? watchFaceEditorData);
+    ctor public UserStyleSetting.DoubleRangeUserStyleSetting(androidx.wear.watchface.style.UserStyleSetting.Id id, android.content.res.Resources resources, @StringRes int displayNameResourceId, @StringRes int descriptionResourceId, kotlin.jvm.functions.Function0<android.graphics.drawable.Icon?> iconProvider, double minimumValue, double maximumValue, java.util.Collection<? extends androidx.wear.watchface.style.WatchFaceLayer> affectsWatchFaceLayers, double defaultValue);
+    ctor public UserStyleSetting.DoubleRangeUserStyleSetting(androidx.wear.watchface.style.UserStyleSetting.Id id, android.content.res.Resources resources, @StringRes int displayNameResourceId, @StringRes int descriptionResourceId, kotlin.jvm.functions.Function0<android.graphics.drawable.Icon?> iconProvider, double minimumValue, double maximumValue, java.util.Collection<? extends androidx.wear.watchface.style.WatchFaceLayer> affectsWatchFaceLayers, double defaultValue, optional androidx.wear.watchface.style.UserStyleSetting.WatchFaceEditorData? watchFaceEditorData);
     method public double getDefaultValue();
     method public double getMaximumValue();
     method public double getMinimumValue();
@@ -238,6 +247,9 @@
     ctor public UserStyleSetting.ListUserStyleSetting(androidx.wear.watchface.style.UserStyleSetting.Id id, android.content.res.Resources resources, @StringRes int displayNameResourceId, @StringRes int descriptionResourceId, android.graphics.drawable.Icon? icon, java.util.List<androidx.wear.watchface.style.UserStyleSetting.ListUserStyleSetting.ListOption> options, java.util.Collection<? extends androidx.wear.watchface.style.WatchFaceLayer> affectsWatchFaceLayers);
     ctor public UserStyleSetting.ListUserStyleSetting(androidx.wear.watchface.style.UserStyleSetting.Id id, android.content.res.Resources resources, @StringRes int displayNameResourceId, @StringRes int descriptionResourceId, android.graphics.drawable.Icon? icon, java.util.List<androidx.wear.watchface.style.UserStyleSetting.ListUserStyleSetting.ListOption> options, java.util.Collection<? extends androidx.wear.watchface.style.WatchFaceLayer> affectsWatchFaceLayers, optional androidx.wear.watchface.style.UserStyleSetting.ListUserStyleSetting.ListOption defaultOption);
     ctor public UserStyleSetting.ListUserStyleSetting(androidx.wear.watchface.style.UserStyleSetting.Id id, android.content.res.Resources resources, @StringRes int displayNameResourceId, @StringRes int descriptionResourceId, android.graphics.drawable.Icon? icon, java.util.List<androidx.wear.watchface.style.UserStyleSetting.ListUserStyleSetting.ListOption> options, java.util.Collection<? extends androidx.wear.watchface.style.WatchFaceLayer> affectsWatchFaceLayers, optional androidx.wear.watchface.style.UserStyleSetting.ListUserStyleSetting.ListOption defaultOption, optional androidx.wear.watchface.style.UserStyleSetting.WatchFaceEditorData? watchFaceEditorData);
+    ctor public UserStyleSetting.ListUserStyleSetting(androidx.wear.watchface.style.UserStyleSetting.Id id, android.content.res.Resources resources, @StringRes int displayNameResourceId, @StringRes int descriptionResourceId, kotlin.jvm.functions.Function0<android.graphics.drawable.Icon?> iconProvider, java.util.List<androidx.wear.watchface.style.UserStyleSetting.ListUserStyleSetting.ListOption> options, java.util.Collection<? extends androidx.wear.watchface.style.WatchFaceLayer> affectsWatchFaceLayers);
+    ctor public UserStyleSetting.ListUserStyleSetting(androidx.wear.watchface.style.UserStyleSetting.Id id, android.content.res.Resources resources, @StringRes int displayNameResourceId, @StringRes int descriptionResourceId, kotlin.jvm.functions.Function0<android.graphics.drawable.Icon?> iconProvider, java.util.List<androidx.wear.watchface.style.UserStyleSetting.ListUserStyleSetting.ListOption> options, java.util.Collection<? extends androidx.wear.watchface.style.WatchFaceLayer> affectsWatchFaceLayers, optional androidx.wear.watchface.style.UserStyleSetting.ListUserStyleSetting.ListOption defaultOption);
+    ctor public UserStyleSetting.ListUserStyleSetting(androidx.wear.watchface.style.UserStyleSetting.Id id, android.content.res.Resources resources, @StringRes int displayNameResourceId, @StringRes int descriptionResourceId, kotlin.jvm.functions.Function0<android.graphics.drawable.Icon?> iconProvider, java.util.List<androidx.wear.watchface.style.UserStyleSetting.ListUserStyleSetting.ListOption> options, java.util.Collection<? extends androidx.wear.watchface.style.WatchFaceLayer> affectsWatchFaceLayers, optional androidx.wear.watchface.style.UserStyleSetting.ListUserStyleSetting.ListOption defaultOption, optional androidx.wear.watchface.style.UserStyleSetting.WatchFaceEditorData? watchFaceEditorData);
   }
 
   public static final class UserStyleSetting.ListUserStyleSetting.ListOption extends androidx.wear.watchface.style.UserStyleSetting.Option {
@@ -247,6 +259,9 @@
     ctor public UserStyleSetting.ListUserStyleSetting.ListOption(androidx.wear.watchface.style.UserStyleSetting.Option.Id id, android.content.res.Resources resources, @StringRes int displayNameResourceId, @StringRes int screenReaderNameResourceId, android.graphics.drawable.Icon? icon);
     ctor public UserStyleSetting.ListUserStyleSetting.ListOption(androidx.wear.watchface.style.UserStyleSetting.Option.Id id, android.content.res.Resources resources, @StringRes int displayNameResourceId, @StringRes int screenReaderNameResourceId, android.graphics.drawable.Icon? icon, optional java.util.Collection<? extends androidx.wear.watchface.style.UserStyleSetting> childSettings);
     ctor public UserStyleSetting.ListUserStyleSetting.ListOption(androidx.wear.watchface.style.UserStyleSetting.Option.Id id, android.content.res.Resources resources, @StringRes int displayNameResourceId, @StringRes int screenReaderNameResourceId, android.graphics.drawable.Icon? icon, optional java.util.Collection<? extends androidx.wear.watchface.style.UserStyleSetting> childSettings, optional androidx.wear.watchface.style.UserStyleSetting.WatchFaceEditorData? watchFaceEditorData);
+    ctor public UserStyleSetting.ListUserStyleSetting.ListOption(androidx.wear.watchface.style.UserStyleSetting.Option.Id id, android.content.res.Resources resources, @StringRes int displayNameResourceId, @StringRes int screenReaderNameResourceId, kotlin.jvm.functions.Function0<android.graphics.drawable.Icon?> iconProvider);
+    ctor public UserStyleSetting.ListUserStyleSetting.ListOption(androidx.wear.watchface.style.UserStyleSetting.Option.Id id, android.content.res.Resources resources, @StringRes int displayNameResourceId, @StringRes int screenReaderNameResourceId, kotlin.jvm.functions.Function0<android.graphics.drawable.Icon?> iconProvider, optional java.util.Collection<? extends androidx.wear.watchface.style.UserStyleSetting> childSettings);
+    ctor public UserStyleSetting.ListUserStyleSetting.ListOption(androidx.wear.watchface.style.UserStyleSetting.Option.Id id, android.content.res.Resources resources, @StringRes int displayNameResourceId, @StringRes int screenReaderNameResourceId, kotlin.jvm.functions.Function0<android.graphics.drawable.Icon?> iconProvider, optional java.util.Collection<? extends androidx.wear.watchface.style.UserStyleSetting> childSettings, optional androidx.wear.watchface.style.UserStyleSetting.WatchFaceEditorData? watchFaceEditorData);
     method public CharSequence getDisplayName();
     method public android.graphics.drawable.Icon? getIcon();
     method public CharSequence? getScreenReaderName();
@@ -260,6 +275,8 @@
   public static final class UserStyleSetting.LongRangeUserStyleSetting extends androidx.wear.watchface.style.UserStyleSetting {
     ctor public UserStyleSetting.LongRangeUserStyleSetting(androidx.wear.watchface.style.UserStyleSetting.Id id, android.content.res.Resources resources, @StringRes int displayNameResourceId, @StringRes int descriptionResourceId, android.graphics.drawable.Icon? icon, long minimumValue, long maximumValue, java.util.Collection<? extends androidx.wear.watchface.style.WatchFaceLayer> affectsWatchFaceLayers, long defaultValue);
     ctor public UserStyleSetting.LongRangeUserStyleSetting(androidx.wear.watchface.style.UserStyleSetting.Id id, android.content.res.Resources resources, @StringRes int displayNameResourceId, @StringRes int descriptionResourceId, android.graphics.drawable.Icon? icon, long minimumValue, long maximumValue, java.util.Collection<? extends androidx.wear.watchface.style.WatchFaceLayer> affectsWatchFaceLayers, long defaultValue, optional androidx.wear.watchface.style.UserStyleSetting.WatchFaceEditorData? watchFaceEditorData);
+    ctor public UserStyleSetting.LongRangeUserStyleSetting(androidx.wear.watchface.style.UserStyleSetting.Id id, android.content.res.Resources resources, @StringRes int displayNameResourceId, @StringRes int descriptionResourceId, kotlin.jvm.functions.Function0<android.graphics.drawable.Icon?> iconProvider, long minimumValue, long maximumValue, java.util.Collection<? extends androidx.wear.watchface.style.WatchFaceLayer> affectsWatchFaceLayers, long defaultValue);
+    ctor public UserStyleSetting.LongRangeUserStyleSetting(androidx.wear.watchface.style.UserStyleSetting.Id id, android.content.res.Resources resources, @StringRes int displayNameResourceId, @StringRes int descriptionResourceId, kotlin.jvm.functions.Function0<android.graphics.drawable.Icon?> iconProvider, long minimumValue, long maximumValue, java.util.Collection<? extends androidx.wear.watchface.style.WatchFaceLayer> affectsWatchFaceLayers, long defaultValue, optional androidx.wear.watchface.style.UserStyleSetting.WatchFaceEditorData? watchFaceEditorData);
     method public long getDefaultValue();
     method public long getMaximumValue();
     method public long getMinimumValue();
diff --git a/wear/watchface/watchface-style/api/restricted_current.txt b/wear/watchface/watchface-style/api/restricted_current.txt
index f980193..d5a9d8f 100644
--- a/wear/watchface/watchface-style/api/restricted_current.txt
+++ b/wear/watchface/watchface-style/api/restricted_current.txt
@@ -112,6 +112,8 @@
   public static final class UserStyleSetting.BooleanUserStyleSetting extends androidx.wear.watchface.style.UserStyleSetting {
     ctor public UserStyleSetting.BooleanUserStyleSetting(androidx.wear.watchface.style.UserStyleSetting.Id id, android.content.res.Resources resources, @StringRes int displayNameResourceId, @StringRes int descriptionResourceId, android.graphics.drawable.Icon? icon, java.util.Collection<? extends androidx.wear.watchface.style.WatchFaceLayer> affectsWatchFaceLayers, boolean defaultValue);
     ctor public UserStyleSetting.BooleanUserStyleSetting(androidx.wear.watchface.style.UserStyleSetting.Id id, android.content.res.Resources resources, @StringRes int displayNameResourceId, @StringRes int descriptionResourceId, android.graphics.drawable.Icon? icon, java.util.Collection<? extends androidx.wear.watchface.style.WatchFaceLayer> affectsWatchFaceLayers, boolean defaultValue, optional androidx.wear.watchface.style.UserStyleSetting.WatchFaceEditorData? watchFaceEditorData);
+    ctor public UserStyleSetting.BooleanUserStyleSetting(androidx.wear.watchface.style.UserStyleSetting.Id id, android.content.res.Resources resources, @StringRes int displayNameResourceId, @StringRes int descriptionResourceId, kotlin.jvm.functions.Function0<android.graphics.drawable.Icon?> iconProvider, java.util.Collection<? extends androidx.wear.watchface.style.WatchFaceLayer> affectsWatchFaceLayers, boolean defaultValue);
+    ctor public UserStyleSetting.BooleanUserStyleSetting(androidx.wear.watchface.style.UserStyleSetting.Id id, android.content.res.Resources resources, @StringRes int displayNameResourceId, @StringRes int descriptionResourceId, kotlin.jvm.functions.Function0<android.graphics.drawable.Icon?> iconProvider, java.util.Collection<? extends androidx.wear.watchface.style.WatchFaceLayer> affectsWatchFaceLayers, boolean defaultValue, optional androidx.wear.watchface.style.UserStyleSetting.WatchFaceEditorData? watchFaceEditorData);
     method public boolean getDefaultValue();
   }
 
@@ -135,6 +137,9 @@
     ctor public UserStyleSetting.ComplicationSlotsUserStyleSetting(androidx.wear.watchface.style.UserStyleSetting.Id id, android.content.res.Resources resources, @StringRes int displayNameResourceId, @StringRes int descriptionResourceId, android.graphics.drawable.Icon? icon, java.util.List<androidx.wear.watchface.style.UserStyleSetting.ComplicationSlotsUserStyleSetting.ComplicationSlotsOption> complicationConfig, java.util.Collection<? extends androidx.wear.watchface.style.WatchFaceLayer> affectsWatchFaceLayers);
     ctor public UserStyleSetting.ComplicationSlotsUserStyleSetting(androidx.wear.watchface.style.UserStyleSetting.Id id, android.content.res.Resources resources, @StringRes int displayNameResourceId, @StringRes int descriptionResourceId, android.graphics.drawable.Icon? icon, java.util.List<androidx.wear.watchface.style.UserStyleSetting.ComplicationSlotsUserStyleSetting.ComplicationSlotsOption> complicationConfig, java.util.Collection<? extends androidx.wear.watchface.style.WatchFaceLayer> affectsWatchFaceLayers, optional androidx.wear.watchface.style.UserStyleSetting.ComplicationSlotsUserStyleSetting.ComplicationSlotsOption defaultOption);
     ctor public UserStyleSetting.ComplicationSlotsUserStyleSetting(androidx.wear.watchface.style.UserStyleSetting.Id id, android.content.res.Resources resources, @StringRes int displayNameResourceId, @StringRes int descriptionResourceId, android.graphics.drawable.Icon? icon, java.util.List<androidx.wear.watchface.style.UserStyleSetting.ComplicationSlotsUserStyleSetting.ComplicationSlotsOption> complicationConfig, java.util.Collection<? extends androidx.wear.watchface.style.WatchFaceLayer> affectsWatchFaceLayers, optional androidx.wear.watchface.style.UserStyleSetting.ComplicationSlotsUserStyleSetting.ComplicationSlotsOption defaultOption, optional androidx.wear.watchface.style.UserStyleSetting.WatchFaceEditorData? watchFaceEditorData);
+    ctor public UserStyleSetting.ComplicationSlotsUserStyleSetting(androidx.wear.watchface.style.UserStyleSetting.Id id, android.content.res.Resources resources, @StringRes int displayNameResourceId, @StringRes int descriptionResourceId, kotlin.jvm.functions.Function0<android.graphics.drawable.Icon?> iconProvider, java.util.List<androidx.wear.watchface.style.UserStyleSetting.ComplicationSlotsUserStyleSetting.ComplicationSlotsOption> complicationConfig, java.util.Collection<? extends androidx.wear.watchface.style.WatchFaceLayer> affectsWatchFaceLayers);
+    ctor public UserStyleSetting.ComplicationSlotsUserStyleSetting(androidx.wear.watchface.style.UserStyleSetting.Id id, android.content.res.Resources resources, @StringRes int displayNameResourceId, @StringRes int descriptionResourceId, kotlin.jvm.functions.Function0<android.graphics.drawable.Icon?> iconProvider, java.util.List<androidx.wear.watchface.style.UserStyleSetting.ComplicationSlotsUserStyleSetting.ComplicationSlotsOption> complicationConfig, java.util.Collection<? extends androidx.wear.watchface.style.WatchFaceLayer> affectsWatchFaceLayers, optional androidx.wear.watchface.style.UserStyleSetting.ComplicationSlotsUserStyleSetting.ComplicationSlotsOption defaultOption);
+    ctor public UserStyleSetting.ComplicationSlotsUserStyleSetting(androidx.wear.watchface.style.UserStyleSetting.Id id, android.content.res.Resources resources, @StringRes int displayNameResourceId, @StringRes int descriptionResourceId, kotlin.jvm.functions.Function0<android.graphics.drawable.Icon?> iconProvider, java.util.List<androidx.wear.watchface.style.UserStyleSetting.ComplicationSlotsUserStyleSetting.ComplicationSlotsOption> complicationConfig, java.util.Collection<? extends androidx.wear.watchface.style.WatchFaceLayer> affectsWatchFaceLayers, optional androidx.wear.watchface.style.UserStyleSetting.ComplicationSlotsUserStyleSetting.ComplicationSlotsOption defaultOption, optional androidx.wear.watchface.style.UserStyleSetting.WatchFaceEditorData? watchFaceEditorData);
   }
 
   public static final class UserStyleSetting.ComplicationSlotsUserStyleSetting.ComplicationSlotOverlay {
@@ -169,6 +174,8 @@
     ctor @Deprecated public UserStyleSetting.ComplicationSlotsUserStyleSetting.ComplicationSlotsOption(androidx.wear.watchface.style.UserStyleSetting.Option.Id id, android.content.res.Resources resources, @StringRes int displayNameResourceId, android.graphics.drawable.Icon? icon, java.util.Collection<androidx.wear.watchface.style.UserStyleSetting.ComplicationSlotsUserStyleSetting.ComplicationSlotOverlay> complicationSlotOverlays, optional androidx.wear.watchface.style.UserStyleSetting.WatchFaceEditorData? watchFaceEditorData);
     ctor public UserStyleSetting.ComplicationSlotsUserStyleSetting.ComplicationSlotsOption(androidx.wear.watchface.style.UserStyleSetting.Option.Id id, android.content.res.Resources resources, @StringRes int displayNameResourceId, @StringRes int screenReaderNameResourceId, android.graphics.drawable.Icon? icon, java.util.Collection<androidx.wear.watchface.style.UserStyleSetting.ComplicationSlotsUserStyleSetting.ComplicationSlotOverlay> complicationSlotOverlays);
     ctor public UserStyleSetting.ComplicationSlotsUserStyleSetting.ComplicationSlotsOption(androidx.wear.watchface.style.UserStyleSetting.Option.Id id, android.content.res.Resources resources, @StringRes int displayNameResourceId, @StringRes int screenReaderNameResourceId, android.graphics.drawable.Icon? icon, java.util.Collection<androidx.wear.watchface.style.UserStyleSetting.ComplicationSlotsUserStyleSetting.ComplicationSlotOverlay> complicationSlotOverlays, optional androidx.wear.watchface.style.UserStyleSetting.WatchFaceEditorData? watchFaceEditorData);
+    ctor public UserStyleSetting.ComplicationSlotsUserStyleSetting.ComplicationSlotsOption(androidx.wear.watchface.style.UserStyleSetting.Option.Id id, android.content.res.Resources resources, @StringRes int displayNameResourceId, @StringRes int screenReaderNameResourceId, kotlin.jvm.functions.Function0<android.graphics.drawable.Icon?> iconProvider, java.util.Collection<androidx.wear.watchface.style.UserStyleSetting.ComplicationSlotsUserStyleSetting.ComplicationSlotOverlay> complicationSlotOverlays);
+    ctor public UserStyleSetting.ComplicationSlotsUserStyleSetting.ComplicationSlotsOption(androidx.wear.watchface.style.UserStyleSetting.Option.Id id, android.content.res.Resources resources, @StringRes int displayNameResourceId, @StringRes int screenReaderNameResourceId, kotlin.jvm.functions.Function0<android.graphics.drawable.Icon?> iconProvider, java.util.Collection<androidx.wear.watchface.style.UserStyleSetting.ComplicationSlotsUserStyleSetting.ComplicationSlotOverlay> complicationSlotOverlays, optional androidx.wear.watchface.style.UserStyleSetting.WatchFaceEditorData? watchFaceEditorData);
     method public java.util.Collection<androidx.wear.watchface.style.UserStyleSetting.ComplicationSlotsUserStyleSetting.ComplicationSlotOverlay> getComplicationSlotOverlays();
     method public CharSequence getDisplayName();
     method public android.graphics.drawable.Icon? getIcon();
@@ -194,6 +201,8 @@
   public static final class UserStyleSetting.DoubleRangeUserStyleSetting extends androidx.wear.watchface.style.UserStyleSetting {
     ctor public UserStyleSetting.DoubleRangeUserStyleSetting(androidx.wear.watchface.style.UserStyleSetting.Id id, android.content.res.Resources resources, @StringRes int displayNameResourceId, @StringRes int descriptionResourceId, android.graphics.drawable.Icon? icon, double minimumValue, double maximumValue, java.util.Collection<? extends androidx.wear.watchface.style.WatchFaceLayer> affectsWatchFaceLayers, double defaultValue);
     ctor public UserStyleSetting.DoubleRangeUserStyleSetting(androidx.wear.watchface.style.UserStyleSetting.Id id, android.content.res.Resources resources, @StringRes int displayNameResourceId, @StringRes int descriptionResourceId, android.graphics.drawable.Icon? icon, double minimumValue, double maximumValue, java.util.Collection<? extends androidx.wear.watchface.style.WatchFaceLayer> affectsWatchFaceLayers, double defaultValue, optional androidx.wear.watchface.style.UserStyleSetting.WatchFaceEditorData? watchFaceEditorData);
+    ctor public UserStyleSetting.DoubleRangeUserStyleSetting(androidx.wear.watchface.style.UserStyleSetting.Id id, android.content.res.Resources resources, @StringRes int displayNameResourceId, @StringRes int descriptionResourceId, kotlin.jvm.functions.Function0<android.graphics.drawable.Icon?> iconProvider, double minimumValue, double maximumValue, java.util.Collection<? extends androidx.wear.watchface.style.WatchFaceLayer> affectsWatchFaceLayers, double defaultValue);
+    ctor public UserStyleSetting.DoubleRangeUserStyleSetting(androidx.wear.watchface.style.UserStyleSetting.Id id, android.content.res.Resources resources, @StringRes int displayNameResourceId, @StringRes int descriptionResourceId, kotlin.jvm.functions.Function0<android.graphics.drawable.Icon?> iconProvider, double minimumValue, double maximumValue, java.util.Collection<? extends androidx.wear.watchface.style.WatchFaceLayer> affectsWatchFaceLayers, double defaultValue, optional androidx.wear.watchface.style.UserStyleSetting.WatchFaceEditorData? watchFaceEditorData);
     method public double getDefaultValue();
     method public double getMaximumValue();
     method public double getMinimumValue();
@@ -238,6 +247,9 @@
     ctor public UserStyleSetting.ListUserStyleSetting(androidx.wear.watchface.style.UserStyleSetting.Id id, android.content.res.Resources resources, @StringRes int displayNameResourceId, @StringRes int descriptionResourceId, android.graphics.drawable.Icon? icon, java.util.List<androidx.wear.watchface.style.UserStyleSetting.ListUserStyleSetting.ListOption> options, java.util.Collection<? extends androidx.wear.watchface.style.WatchFaceLayer> affectsWatchFaceLayers);
     ctor public UserStyleSetting.ListUserStyleSetting(androidx.wear.watchface.style.UserStyleSetting.Id id, android.content.res.Resources resources, @StringRes int displayNameResourceId, @StringRes int descriptionResourceId, android.graphics.drawable.Icon? icon, java.util.List<androidx.wear.watchface.style.UserStyleSetting.ListUserStyleSetting.ListOption> options, java.util.Collection<? extends androidx.wear.watchface.style.WatchFaceLayer> affectsWatchFaceLayers, optional androidx.wear.watchface.style.UserStyleSetting.ListUserStyleSetting.ListOption defaultOption);
     ctor public UserStyleSetting.ListUserStyleSetting(androidx.wear.watchface.style.UserStyleSetting.Id id, android.content.res.Resources resources, @StringRes int displayNameResourceId, @StringRes int descriptionResourceId, android.graphics.drawable.Icon? icon, java.util.List<androidx.wear.watchface.style.UserStyleSetting.ListUserStyleSetting.ListOption> options, java.util.Collection<? extends androidx.wear.watchface.style.WatchFaceLayer> affectsWatchFaceLayers, optional androidx.wear.watchface.style.UserStyleSetting.ListUserStyleSetting.ListOption defaultOption, optional androidx.wear.watchface.style.UserStyleSetting.WatchFaceEditorData? watchFaceEditorData);
+    ctor public UserStyleSetting.ListUserStyleSetting(androidx.wear.watchface.style.UserStyleSetting.Id id, android.content.res.Resources resources, @StringRes int displayNameResourceId, @StringRes int descriptionResourceId, kotlin.jvm.functions.Function0<android.graphics.drawable.Icon?> iconProvider, java.util.List<androidx.wear.watchface.style.UserStyleSetting.ListUserStyleSetting.ListOption> options, java.util.Collection<? extends androidx.wear.watchface.style.WatchFaceLayer> affectsWatchFaceLayers);
+    ctor public UserStyleSetting.ListUserStyleSetting(androidx.wear.watchface.style.UserStyleSetting.Id id, android.content.res.Resources resources, @StringRes int displayNameResourceId, @StringRes int descriptionResourceId, kotlin.jvm.functions.Function0<android.graphics.drawable.Icon?> iconProvider, java.util.List<androidx.wear.watchface.style.UserStyleSetting.ListUserStyleSetting.ListOption> options, java.util.Collection<? extends androidx.wear.watchface.style.WatchFaceLayer> affectsWatchFaceLayers, optional androidx.wear.watchface.style.UserStyleSetting.ListUserStyleSetting.ListOption defaultOption);
+    ctor public UserStyleSetting.ListUserStyleSetting(androidx.wear.watchface.style.UserStyleSetting.Id id, android.content.res.Resources resources, @StringRes int displayNameResourceId, @StringRes int descriptionResourceId, kotlin.jvm.functions.Function0<android.graphics.drawable.Icon?> iconProvider, java.util.List<androidx.wear.watchface.style.UserStyleSetting.ListUserStyleSetting.ListOption> options, java.util.Collection<? extends androidx.wear.watchface.style.WatchFaceLayer> affectsWatchFaceLayers, optional androidx.wear.watchface.style.UserStyleSetting.ListUserStyleSetting.ListOption defaultOption, optional androidx.wear.watchface.style.UserStyleSetting.WatchFaceEditorData? watchFaceEditorData);
   }
 
   public static final class UserStyleSetting.ListUserStyleSetting.ListOption extends androidx.wear.watchface.style.UserStyleSetting.Option {
@@ -247,6 +259,9 @@
     ctor public UserStyleSetting.ListUserStyleSetting.ListOption(androidx.wear.watchface.style.UserStyleSetting.Option.Id id, android.content.res.Resources resources, @StringRes int displayNameResourceId, @StringRes int screenReaderNameResourceId, android.graphics.drawable.Icon? icon);
     ctor public UserStyleSetting.ListUserStyleSetting.ListOption(androidx.wear.watchface.style.UserStyleSetting.Option.Id id, android.content.res.Resources resources, @StringRes int displayNameResourceId, @StringRes int screenReaderNameResourceId, android.graphics.drawable.Icon? icon, optional java.util.Collection<? extends androidx.wear.watchface.style.UserStyleSetting> childSettings);
     ctor public UserStyleSetting.ListUserStyleSetting.ListOption(androidx.wear.watchface.style.UserStyleSetting.Option.Id id, android.content.res.Resources resources, @StringRes int displayNameResourceId, @StringRes int screenReaderNameResourceId, android.graphics.drawable.Icon? icon, optional java.util.Collection<? extends androidx.wear.watchface.style.UserStyleSetting> childSettings, optional androidx.wear.watchface.style.UserStyleSetting.WatchFaceEditorData? watchFaceEditorData);
+    ctor public UserStyleSetting.ListUserStyleSetting.ListOption(androidx.wear.watchface.style.UserStyleSetting.Option.Id id, android.content.res.Resources resources, @StringRes int displayNameResourceId, @StringRes int screenReaderNameResourceId, kotlin.jvm.functions.Function0<android.graphics.drawable.Icon?> iconProvider);
+    ctor public UserStyleSetting.ListUserStyleSetting.ListOption(androidx.wear.watchface.style.UserStyleSetting.Option.Id id, android.content.res.Resources resources, @StringRes int displayNameResourceId, @StringRes int screenReaderNameResourceId, kotlin.jvm.functions.Function0<android.graphics.drawable.Icon?> iconProvider, optional java.util.Collection<? extends androidx.wear.watchface.style.UserStyleSetting> childSettings);
+    ctor public UserStyleSetting.ListUserStyleSetting.ListOption(androidx.wear.watchface.style.UserStyleSetting.Option.Id id, android.content.res.Resources resources, @StringRes int displayNameResourceId, @StringRes int screenReaderNameResourceId, kotlin.jvm.functions.Function0<android.graphics.drawable.Icon?> iconProvider, optional java.util.Collection<? extends androidx.wear.watchface.style.UserStyleSetting> childSettings, optional androidx.wear.watchface.style.UserStyleSetting.WatchFaceEditorData? watchFaceEditorData);
     method public CharSequence getDisplayName();
     method public android.graphics.drawable.Icon? getIcon();
     method public CharSequence? getScreenReaderName();
@@ -260,6 +275,8 @@
   public static final class UserStyleSetting.LongRangeUserStyleSetting extends androidx.wear.watchface.style.UserStyleSetting {
     ctor public UserStyleSetting.LongRangeUserStyleSetting(androidx.wear.watchface.style.UserStyleSetting.Id id, android.content.res.Resources resources, @StringRes int displayNameResourceId, @StringRes int descriptionResourceId, android.graphics.drawable.Icon? icon, long minimumValue, long maximumValue, java.util.Collection<? extends androidx.wear.watchface.style.WatchFaceLayer> affectsWatchFaceLayers, long defaultValue);
     ctor public UserStyleSetting.LongRangeUserStyleSetting(androidx.wear.watchface.style.UserStyleSetting.Id id, android.content.res.Resources resources, @StringRes int displayNameResourceId, @StringRes int descriptionResourceId, android.graphics.drawable.Icon? icon, long minimumValue, long maximumValue, java.util.Collection<? extends androidx.wear.watchface.style.WatchFaceLayer> affectsWatchFaceLayers, long defaultValue, optional androidx.wear.watchface.style.UserStyleSetting.WatchFaceEditorData? watchFaceEditorData);
+    ctor public UserStyleSetting.LongRangeUserStyleSetting(androidx.wear.watchface.style.UserStyleSetting.Id id, android.content.res.Resources resources, @StringRes int displayNameResourceId, @StringRes int descriptionResourceId, kotlin.jvm.functions.Function0<android.graphics.drawable.Icon?> iconProvider, long minimumValue, long maximumValue, java.util.Collection<? extends androidx.wear.watchface.style.WatchFaceLayer> affectsWatchFaceLayers, long defaultValue);
+    ctor public UserStyleSetting.LongRangeUserStyleSetting(androidx.wear.watchface.style.UserStyleSetting.Id id, android.content.res.Resources resources, @StringRes int displayNameResourceId, @StringRes int descriptionResourceId, kotlin.jvm.functions.Function0<android.graphics.drawable.Icon?> iconProvider, long minimumValue, long maximumValue, java.util.Collection<? extends androidx.wear.watchface.style.WatchFaceLayer> affectsWatchFaceLayers, long defaultValue, optional androidx.wear.watchface.style.UserStyleSetting.WatchFaceEditorData? watchFaceEditorData);
     method public long getDefaultValue();
     method public long getMaximumValue();
     method public long getMinimumValue();
diff --git a/wear/watchface/watchface-style/src/androidTest/java/androidx/wear/watchface/style/UserStyleSettingWithStringResourcesTest.kt b/wear/watchface/watchface-style/src/androidTest/java/androidx/wear/watchface/style/UserStyleSettingWithStringResourcesTest.kt
index fa41a807..45a7dc9 100644
--- a/wear/watchface/watchface-style/src/androidTest/java/androidx/wear/watchface/style/UserStyleSettingWithStringResourcesTest.kt
+++ b/wear/watchface/watchface-style/src/androidTest/java/androidx/wear/watchface/style/UserStyleSettingWithStringResourcesTest.kt
@@ -17,6 +17,8 @@
 package androidx.wear.watchface.style
 
 import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.drawable.Icon
 import androidx.test.core.app.ApplicationProvider
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.MediumTest
@@ -25,7 +27,7 @@
 import androidx.wear.watchface.style.UserStyleSetting.ListUserStyleSetting
 import androidx.wear.watchface.style.UserStyleSetting.ListUserStyleSetting.ListOption
 import androidx.wear.watchface.style.test.R
-import com.google.common.truth.Truth
+import com.google.common.truth.Truth.assertThat
 import java.util.Locale
 import org.junit.Test
 import org.junit.runner.RunWith
@@ -42,6 +44,9 @@
                 }
             )
 
+    private val icon_10x10 =
+        Icon.createWithBitmap(Bitmap.createBitmap(10, 10, Bitmap.Config.ARGB_8888))
+
     private val colorStyleSetting =
         UserStyleSetting.ListUserStyleSetting(
             UserStyleSetting.Id("color_style_setting"),
@@ -71,17 +76,17 @@
 
     @Test
     public fun stringResources_en() {
-        Truth.assertThat(colorStyleSetting.displayName).isEqualTo("Colors")
-        Truth.assertThat(colorStyleSetting.description).isEqualTo("Watchface colorization")
+        assertThat(colorStyleSetting.displayName).isEqualTo("Colors")
+        assertThat(colorStyleSetting.description).isEqualTo("Watchface colorization")
 
-        Truth.assertThat(
+        assertThat(
                 (colorStyleSetting.getOptionForId(UserStyleSetting.Option.Id("red_style"))
                         as UserStyleSetting.ListUserStyleSetting.ListOption)
                     .displayName
             )
             .isEqualTo("Red Style")
 
-        Truth.assertThat(
+        assertThat(
                 (colorStyleSetting.getOptionForId(UserStyleSetting.Option.Id("green_style"))
                         as UserStyleSetting.ListUserStyleSetting.ListOption)
                     .displayName
@@ -97,17 +102,17 @@
             context.resources.configuration.apply { setLocale(Locale.ITALIAN) },
             context.resources.displayMetrics
         )
-        Truth.assertThat(colorStyleSetting.displayName).isEqualTo("Colori")
-        Truth.assertThat(colorStyleSetting.description).isEqualTo("Colorazione del quadrante")
+        assertThat(colorStyleSetting.displayName).isEqualTo("Colori")
+        assertThat(colorStyleSetting.description).isEqualTo("Colorazione del quadrante")
 
-        Truth.assertThat(
+        assertThat(
                 (colorStyleSetting.getOptionForId(UserStyleSetting.Option.Id("red_style"))
                         as UserStyleSetting.ListUserStyleSetting.ListOption)
                     .displayName
             )
             .isEqualTo("Stile rosso")
 
-        Truth.assertThat(
+        assertThat(
                 (colorStyleSetting.getOptionForId(UserStyleSetting.Option.Id("green_style"))
                         as UserStyleSetting.ListUserStyleSetting.ListOption)
                     .displayName
@@ -152,16 +157,16 @@
             )
 
         val option0 = listUserStyleSetting.options[0] as ListOption
-        Truth.assertThat(option0.displayName).isEqualTo("1st option")
-        Truth.assertThat(option0.screenReaderName).isEqualTo("1st list option")
+        assertThat(option0.displayName).isEqualTo("1st option")
+        assertThat(option0.screenReaderName).isEqualTo("1st list option")
 
         val option1 = listUserStyleSetting.options[1] as ListOption
-        Truth.assertThat(option1.displayName).isEqualTo("2nd option")
-        Truth.assertThat(option1.screenReaderName).isEqualTo("2nd list option")
+        assertThat(option1.displayName).isEqualTo("2nd option")
+        assertThat(option1.screenReaderName).isEqualTo("2nd list option")
 
         val option2 = listUserStyleSetting.options[2] as ListOption
-        Truth.assertThat(option2.displayName).isEqualTo("3rd option")
-        Truth.assertThat(option2.screenReaderName).isEqualTo("3rd list option")
+        assertThat(option2.displayName).isEqualTo("3rd option")
+        assertThat(option2.screenReaderName).isEqualTo("3rd list option")
     }
 
     @Test
@@ -190,9 +195,9 @@
             ListUserStyleSetting(listUserStyleSetting.toWireFormat())
 
         val option0 = listUserStyleSettingAfterRoundTrip.options[0] as ListOption
-        Truth.assertThat(option0.displayName).isEqualTo("1st option")
+        assertThat(option0.displayName).isEqualTo("1st option")
         // We expect screenReaderName to be back filled by the displayName.
-        Truth.assertThat(option0.screenReaderName).isEqualTo("1st option")
+        assertThat(option0.screenReaderName).isEqualTo("1st option")
     }
 
     @Test
@@ -234,16 +239,16 @@
             )
 
         val option0 = complicationSetting.options[0] as ComplicationSlotsOption
-        Truth.assertThat(option0.displayName).isEqualTo("1st option")
-        Truth.assertThat(option0.screenReaderName).isEqualTo("1st list option")
+        assertThat(option0.displayName).isEqualTo("1st option")
+        assertThat(option0.screenReaderName).isEqualTo("1st list option")
 
         val option1 = complicationSetting.options[1] as ComplicationSlotsOption
-        Truth.assertThat(option1.displayName).isEqualTo("2nd option")
-        Truth.assertThat(option1.screenReaderName).isEqualTo("2nd list option")
+        assertThat(option1.displayName).isEqualTo("2nd option")
+        assertThat(option1.screenReaderName).isEqualTo("2nd list option")
 
         val option2 = complicationSetting.options[2] as ComplicationSlotsOption
-        Truth.assertThat(option2.displayName).isEqualTo("3rd option")
-        Truth.assertThat(option2.screenReaderName).isEqualTo("3rd list option")
+        assertThat(option2.displayName).isEqualTo("3rd option")
+        assertThat(option2.screenReaderName).isEqualTo("3rd list option")
     }
 
     @Test
@@ -293,10 +298,10 @@
                 )
             )
 
-        Truth.assertThat(schema[one]!!.displayName).isEqualTo("1st style")
-        Truth.assertThat(schema[one]!!.description).isEqualTo("1st style setting")
-        Truth.assertThat(schema[two]!!.displayName).isEqualTo("2nd style")
-        Truth.assertThat(schema[two]!!.description).isEqualTo("2nd style setting")
+        assertThat(schema[one]!!.displayName).isEqualTo("1st style")
+        assertThat(schema[one]!!.description).isEqualTo("1st style setting")
+        assertThat(schema[two]!!.displayName).isEqualTo("2nd style")
+        assertThat(schema[two]!!.description).isEqualTo("2nd style setting")
     }
 
     @Test
@@ -325,8 +330,140 @@
             ComplicationSlotsUserStyleSetting(complicationSetting.toWireFormat())
 
         val option0 = complicationSettingAfterRoundTrip.options[0] as ComplicationSlotsOption
-        Truth.assertThat(option0.displayName).isEqualTo("1st option")
+        assertThat(option0.displayName).isEqualTo("1st option")
         // We expect screenReaderName to be back filled by the displayName.
-        Truth.assertThat(option0.screenReaderName).isEqualTo("1st option")
+        assertThat(option0.screenReaderName).isEqualTo("1st option")
+    }
+
+    @Test
+    public fun booleanUserStyleSetting_lazyIcon() {
+        val userStyleSetting =
+            UserStyleSetting.BooleanUserStyleSetting(
+                UserStyleSetting.Id("setting"),
+                context.resources,
+                displayNameResourceId = 10,
+                descriptionResourceId = 11,
+                iconProvider = { icon_10x10 },
+                affectsWatchFaceLayers = listOf(WatchFaceLayer.COMPLICATIONS),
+                defaultValue = true
+            )
+
+        assertThat(userStyleSetting.icon).isEqualTo(icon_10x10)
+    }
+
+    @Test
+    public fun complicationSlotsUserStyleSetting_lazyIcon() {
+        val userStyleSetting =
+            ComplicationSlotsUserStyleSetting(
+                UserStyleSetting.Id("complications_style_setting1"),
+                context.resources,
+                displayNameResourceId = 10,
+                descriptionResourceId = 11,
+                iconProvider = { icon_10x10 },
+                complicationConfig =
+                    listOf(
+                        ComplicationSlotsOption(
+                            UserStyleSetting.Option.Id("one"),
+                            context.resources,
+                            displayNameResourceId = R.string.ith_option,
+                            screenReaderNameResourceId = R.string.ith_option_screen_reader_name,
+                            iconProvider = { icon_10x10 },
+                            emptyList()
+                        )
+                    ),
+                listOf(WatchFaceLayer.COMPLICATIONS)
+            )
+
+        assertThat(userStyleSetting.icon).isEqualTo(icon_10x10)
+    }
+
+    @Test
+    public fun complicationSlotsOption_lazyIcon() {
+        val userStyleOption =
+            ComplicationSlotsOption(
+                UserStyleSetting.Option.Id("one"),
+                context.resources,
+                displayNameResourceId = R.string.ith_option,
+                screenReaderNameResourceId = R.string.ith_option_screen_reader_name,
+                iconProvider = { icon_10x10 },
+                emptyList()
+            )
+
+        assertThat(userStyleOption.icon).isEqualTo(icon_10x10)
+    }
+
+    @Test
+    public fun doubleRangeUserStyleSetting_lazyIcon() {
+        val userStyleSetting =
+            UserStyleSetting.DoubleRangeUserStyleSetting(
+                UserStyleSetting.Id("setting"),
+                context.resources,
+                displayNameResourceId = 10,
+                descriptionResourceId = 11,
+                iconProvider = { icon_10x10 },
+                0.0,
+                1.0,
+                listOf(WatchFaceLayer.BASE),
+                defaultValue = 0.75
+            )
+
+        assertThat(userStyleSetting.icon).isEqualTo(icon_10x10)
+    }
+
+    @Test
+    public fun longRangeUserStyleSetting_lazyIcon() {
+        val userStyleSetting =
+            UserStyleSetting.LongRangeUserStyleSetting(
+                UserStyleSetting.Id("setting"),
+                context.resources,
+                displayNameResourceId = 10,
+                descriptionResourceId = 11,
+                iconProvider = { icon_10x10 },
+                0,
+                100,
+                listOf(WatchFaceLayer.BASE),
+                defaultValue = 75
+            )
+
+        assertThat(userStyleSetting.icon).isEqualTo(icon_10x10)
+    }
+
+    @Test
+    public fun listUserStyleSetting_lazyIcon() {
+        val userStyleSetting =
+            UserStyleSetting.ListUserStyleSetting(
+                UserStyleSetting.Id("setting"),
+                context.resources,
+                displayNameResourceId = 10,
+                descriptionResourceId = 11,
+                iconProvider = { icon_10x10 },
+                options =
+                    listOf(
+                        ListOption(
+                            UserStyleSetting.Option.Id("red_style"),
+                            context.resources,
+                            displayNameResourceId = R.string.red_style_name,
+                            screenReaderNameResourceId = R.string.red_style_name,
+                            iconProvider = { null }
+                        )
+                    ),
+                listOf(WatchFaceLayer.BASE, WatchFaceLayer.COMPLICATIONS_OVERLAY)
+            )
+
+        assertThat(userStyleSetting.icon).isEqualTo(icon_10x10)
+    }
+
+    @Test
+    public fun listOption_lazyIcon() {
+        val userStyleOption =
+            ListOption(
+                UserStyleSetting.Option.Id("red_style"),
+                context.resources,
+                displayNameResourceId = R.string.red_style_name,
+                screenReaderNameResourceId = R.string.red_style_name,
+                iconProvider = { icon_10x10 }
+            )
+
+        assertThat(userStyleOption.icon).isEqualTo(icon_10x10)
     }
 }
diff --git a/wear/watchface/watchface-style/src/main/java/androidx/wear/watchface/style/UserStyleSetting.kt b/wear/watchface/watchface-style/src/main/java/androidx/wear/watchface/style/UserStyleSetting.kt
index 847a4ee..bd54388 100644
--- a/wear/watchface/watchface-style/src/main/java/androidx/wear/watchface/style/UserStyleSetting.kt
+++ b/wear/watchface/watchface-style/src/main/java/androidx/wear/watchface/style/UserStyleSetting.kt
@@ -62,6 +62,7 @@
 import androidx.wear.watchface.style.data.OptionWireFormat
 import androidx.wear.watchface.style.data.PerComplicationTypeMargins
 import androidx.wear.watchface.style.data.UserStyleSettingWireFormat
+import androidx.wear.watchface.utility.TraceEvent
 import java.io.DataOutputStream
 import java.io.InputStream
 import java.nio.ByteBuffer
@@ -101,13 +102,23 @@
         resources: Resources,
         @StringRes id: Int,
     ) : ResourceDisplayText(resources, id) {
+        private var index: Int? = null
         private var indexString: String = ""
 
         fun setIndex(index: Int) {
-            indexString = MessageFormat("{0,ordinal}", Locale.getDefault()).format(arrayOf(index))
+            this.index = index
         }
 
-        override fun toCharSequence() = resources.getString(id, indexString)
+        override fun toCharSequence(): String {
+            if (indexString.isEmpty()) {
+                index?.let {
+                    indexString =
+                        MessageFormat("{0,ordinal}", Locale.getDefault()).format(arrayOf(it))
+                }
+            }
+
+            return resources.getString(id, indexString)
+        }
     }
 }
 
@@ -151,12 +162,15 @@
     public val id: Id,
     private val displayNameInternal: DisplayText,
     private val descriptionInternal: DisplayText,
-    public val icon: Icon?,
+    /** To avoid upfront costs, icons are lazily evaluated. */
+    private val iconProvider: () -> Icon?,
     public val watchFaceEditorData: WatchFaceEditorData?,
     public val options: List<Option>,
     public val defaultOptionIndex: Int,
     public val affectedWatchFaceLayers: Collection<WatchFaceLayer>
 ) {
+    public val icon by lazy { TraceEvent("invoke iconProvider").use { iconProvider() } }
+
     init {
         require(defaultOptionIndex >= 0 && defaultOptionIndex < options.size) {
             "defaultOptionIndex must be within the range of the options list"
@@ -249,32 +263,35 @@
         @Px maxWidth: Int,
         @Px maxHeight: Int
     ): Int {
-        var sizeEstimate =
-            id.value.length +
-                displayName.length +
-                description.length +
-                4 +
-                /** [defaultOptionIndex] */
-                affectedWatchFaceLayers.size * 4
-        icon?.getWireSizeAndDimensions(context)?.let { wireSizeAndDimensions ->
-            wireSizeAndDimensions.wireSizeBytes?.let { sizeEstimate += it }
-            require(
-                wireSizeAndDimensions.width <= maxWidth && wireSizeAndDimensions.height <= maxHeight
-            ) {
-                "UserStyleSetting id $id has a ${wireSizeAndDimensions.width} x " +
-                    "${wireSizeAndDimensions.height} icon. This is too big, the maximum size is " +
-                    "$maxWidth x $maxHeight."
+        TraceEvent("estimateWireSizeInBytesAndValidateIconDimensions").use {
+            var sizeEstimate =
+                id.value.length +
+                    displayName.length +
+                    description.length +
+                    4 +
+                    /** [defaultOptionIndex] */
+                    affectedWatchFaceLayers.size * 4
+            icon?.getWireSizeAndDimensions(context)?.let { wireSizeAndDimensions ->
+                wireSizeAndDimensions.wireSizeBytes?.let { sizeEstimate += it }
+                require(
+                    wireSizeAndDimensions.width <= maxWidth &&
+                        wireSizeAndDimensions.height <= maxHeight
+                ) {
+                    "UserStyleSetting id $id has a ${wireSizeAndDimensions.width} x " +
+                        "${wireSizeAndDimensions.height} icon. This is too big, the maximum size is " +
+                        "$maxWidth x $maxHeight."
+                }
             }
+            for (option in options) {
+                sizeEstimate +=
+                    option.estimateWireSizeInBytesAndValidateIconDimensions(
+                        context,
+                        maxWidth,
+                        maxHeight
+                    )
+            }
+            return sizeEstimate
         }
-        for (option in options) {
-            sizeEstimate +=
-                option.estimateWireSizeInBytesAndValidateIconDimensions(
-                    context,
-                    maxWidth,
-                    maxHeight
-                )
-        }
-        return sizeEstimate
     }
 
     /**
@@ -376,6 +393,15 @@
             }
         }
 
+        internal fun createLazyIcon(resources: Resources, parser: XmlResourceParser): () -> Icon? {
+            val iconId = parser.getAttributeResourceValue(NAMESPACE_ANDROID, "icon", -1)
+            if (iconId != -1) {
+                return { Icon.createWithResource(resources.getResourcePackageName(iconId), iconId) }
+            } else {
+                return { null }
+            }
+        }
+
         /** Creates appropriate UserStyleSetting base on parent="@xml/..." resource reference. */
         internal fun <T> createParent(
             resources: Resources,
@@ -397,7 +423,7 @@
             val id: Id,
             val displayName: DisplayText,
             val description: DisplayText,
-            val icon: Icon?,
+            val iconProvider: () -> Icon?,
             val watchFaceEditorData: WatchFaceEditorData?,
             val options: List<Option>,
             val defaultOptionIndex: Int?,
@@ -426,7 +452,7 @@
                 createDisplayText(resources, parser, "displayName", parent?.displayNameInternal)
             val description =
                 createDisplayText(resources, parser, "description", parent?.descriptionInternal)
-            val icon = createIcon(resources, parser) ?: parent?.icon
+            val iconProvider = createLazyIcon(resources, parser)
 
             val defaultOptionIndex =
                 if (inflateDefault) {
@@ -468,7 +494,7 @@
                 Id(id),
                 displayName,
                 description,
-                icon,
+                { iconProvider() ?: parent?.icon },
                 watchFaceEditorData ?: parent?.watchFaceEditorData,
                 if (parent == null || options.isNotEmpty()) options else parent.options,
                 defaultOptionIndex,
@@ -490,7 +516,7 @@
         Id(wireFormat.mId),
         DisplayText.CharSequenceDisplayText(wireFormat.mDisplayName),
         DisplayText.CharSequenceDisplayText(wireFormat.mDescription),
-        wireFormat.mIcon,
+        { wireFormat.mIcon },
         wireFormat.mOnWatchFaceEditorBundle?.let { WatchFaceEditorData(it) },
         wireFormat.mOptions.map { Option.createFromWireFormat(it) },
         wireFormat.mDefaultOptionIndex,
@@ -747,7 +773,49 @@
             id,
             DisplayText.CharSequenceDisplayText(displayName),
             DisplayText.CharSequenceDisplayText(description),
-            icon,
+            { icon },
+            watchFaceEditorData,
+            listOf(BooleanOption.TRUE, BooleanOption.FALSE),
+            when (defaultValue) {
+                true -> 0
+                false -> 1
+            },
+            affectsWatchFaceLayers
+        )
+
+        /**
+         * Constructs a BooleanUserStyleSetting, with a lazily evaluated [icon].
+         *
+         * @param id [Id] for the element, must be unique.
+         * @param displayName Localized human readable name for the element, used in the userStyle
+         *   selection UI.
+         * @param description Localized description string displayed under the displayName.
+         * @param iconProvider A provider of an [Icon] for use in the companion userStyle selection
+         *   UI. This gets lazily evaluated and is sent to the companion over bluetooth and should
+         *   be small (ideally a few kb in size). Note this is not guaranteed to be called on the
+         *   calling thread.
+         * @param affectsWatchFaceLayers Used by the style configuration UI. Describes which watch
+         *   face rendering layers this style affects.
+         * @param defaultValue The default value for this BooleanUserStyleSetting.
+         * @param watchFaceEditorData Optional data for an on watch face editor, this will not be
+         *   sent to the companion and its contents may be used in preference to other fields by an
+         *   on watch face editor.
+         */
+        @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+        @JvmOverloads
+        public constructor(
+            id: Id,
+            displayName: CharSequence,
+            description: CharSequence,
+            iconProvider: () -> Icon?,
+            affectsWatchFaceLayers: Collection<WatchFaceLayer>,
+            defaultValue: Boolean,
+            watchFaceEditorData: WatchFaceEditorData? = null
+        ) : super(
+            id,
+            DisplayText.CharSequenceDisplayText(displayName),
+            DisplayText.CharSequenceDisplayText(description),
+            iconProvider,
             watchFaceEditorData,
             listOf(BooleanOption.TRUE, BooleanOption.FALSE),
             when (defaultValue) {
@@ -791,7 +859,54 @@
             id,
             DisplayText.ResourceDisplayTextWithIndex(resources, displayNameResourceId),
             DisplayText.ResourceDisplayTextWithIndex(resources, descriptionResourceId),
-            icon,
+            { icon },
+            watchFaceEditorData,
+            listOf(BooleanOption.TRUE, BooleanOption.FALSE),
+            when (defaultValue) {
+                true -> 0
+                false -> 1
+            },
+            affectsWatchFaceLayers
+        )
+
+        /**
+         * Constructs a BooleanUserStyleSetting with a lazily evaluated [icon], where
+         * [BooleanUserStyleSetting.displayName] and [BooleanUserStyleSetting.description] are
+         * specified as resources.
+         *
+         * @param id [Id] for the element, must be unique.
+         * @param resources The [Resources] from which [displayNameResourceId] and
+         *   [descriptionResourceId] are loaded.
+         * @param displayNameResourceId String resource id for a human readable name for the
+         *   element, used in the userStyle selection UI.
+         * @param descriptionResourceId String resource id for a human readable description string
+         *   displayed under the displayName.
+         * @param iconProvider A provider of an [Icon] for use in the companion userStyle selection
+         *   UI. This gets lazily evaluated and is sent to the companion over bluetooth and should
+         *   be small (ideally a few kb in size). Note this is not guaranteed to be called on the
+         *   calling thread.
+         * @param affectsWatchFaceLayers Used by the style configuration UI. Describes which watch
+         *   face rendering layers this style affects.
+         * @param defaultValue The default value for this BooleanUserStyleSetting.
+         * @param watchFaceEditorData Optional data for an on watch face editor, this will not be
+         *   sent to the companion and its contents may be used in preference to other fields by an
+         *   on watch face editor.
+         */
+        @JvmOverloads
+        public constructor(
+            id: Id,
+            resources: Resources,
+            @StringRes displayNameResourceId: Int,
+            @StringRes descriptionResourceId: Int,
+            iconProvider: () -> Icon?,
+            affectsWatchFaceLayers: Collection<WatchFaceLayer>,
+            defaultValue: Boolean,
+            watchFaceEditorData: WatchFaceEditorData? = null
+        ) : super(
+            id,
+            DisplayText.ResourceDisplayTextWithIndex(resources, displayNameResourceId),
+            DisplayText.ResourceDisplayTextWithIndex(resources, descriptionResourceId),
+            iconProvider,
             watchFaceEditorData,
             listOf(BooleanOption.TRUE, BooleanOption.FALSE),
             when (defaultValue) {
@@ -805,7 +920,7 @@
             id: Id,
             displayName: DisplayText,
             description: DisplayText,
-            icon: Icon?,
+            iconProvider: () -> Icon?,
             watchFaceEditorData: WatchFaceEditorData?,
             affectsWatchFaceLayers: Collection<WatchFaceLayer>,
             defaultValue: Boolean
@@ -813,7 +928,7 @@
             id,
             displayName,
             description,
-            icon,
+            iconProvider,
             watchFaceEditorData,
             listOf(BooleanOption.TRUE, BooleanOption.FALSE),
             when (defaultValue) {
@@ -860,7 +975,7 @@
                     params.id,
                     params.displayName,
                     params.description,
-                    params.icon,
+                    params.iconProvider,
                     params.watchFaceEditorData,
                     params.affectedWatchFaceLayers,
                     defaultValue
@@ -929,7 +1044,7 @@
             id: Id,
             displayNameInternal: DisplayText,
             descriptionInternal: DisplayText,
-            icon: Icon?,
+            iconProvider: () -> Icon?,
             watchFaceEditorData: WatchFaceEditorData?,
             options: List<ComplicationSlotsOption>,
             defaultOptionIndex: Int,
@@ -938,7 +1053,7 @@
             id,
             displayNameInternal,
             descriptionInternal,
-            icon,
+            iconProvider,
             watchFaceEditorData,
             options,
             defaultOptionIndex,
@@ -1272,7 +1387,7 @@
             id,
             DisplayText.CharSequenceDisplayText(displayName),
             DisplayText.CharSequenceDisplayText(description),
-            icon,
+            { icon },
             watchFaceEditorData,
             complicationConfig,
             complicationConfig.indexOf(defaultOption),
@@ -1317,7 +1432,54 @@
             id,
             DisplayText.ResourceDisplayTextWithIndex(resources, displayNameResourceId),
             DisplayText.ResourceDisplayTextWithIndex(resources, descriptionResourceId),
-            icon,
+            { icon },
+            watchFaceEditorData,
+            complicationConfig,
+            complicationConfig.indexOf(defaultOption),
+            affectsWatchFaceLayers
+        )
+
+        /**
+         * Constructs a ComplicationSlotsUserStyleSetting with a lazily evaluated [icon], where
+         * [ComplicationSlotsUserStyleSetting.displayName] and
+         * [ComplicationSlotsUserStyleSetting.description] are specified as resources.
+         *
+         * @param id [Id] for the element, must be unique.
+         * @param resources The [Resources] from which [displayNameResourceId] and
+         *   [descriptionResourceId] are loaded.
+         * @param displayNameResourceId String resource id for a human readable name for the
+         *   element, used in the userStyle selection UI.
+         * @param descriptionResourceId String resource id for a human readable description string
+         *   displayed under the displayName.
+         * @param iconProvider A provider of an [Icon] for use in the companion userStyle selection
+         *   UI. This gets lazily evaluated and is sent to the companion over bluetooth and should
+         *   be small (ideally a few kb in size). Note this is not guaranteed to be called on the
+         *   calling thread.
+         * @param complicationConfig The configuration for affected complications.
+         * @param affectsWatchFaceLayers Used by the style configuration UI. Describes which watch
+         *   face rendering layers this style affects, must include [WatchFaceLayer.COMPLICATIONS].
+         * @param defaultOption The default option, used when data isn't persisted. Optional
+         *   parameter which defaults to the first element of [complicationConfig].
+         * @param watchFaceEditorData Optional data for an on watch face editor, this will not be
+         *   sent to the companion and its contents may be used in preference to other fields by an
+         *   on watch face editor.
+         */
+        @JvmOverloads
+        public constructor(
+            id: Id,
+            resources: Resources,
+            @StringRes displayNameResourceId: Int,
+            @StringRes descriptionResourceId: Int,
+            iconProvider: () -> Icon?,
+            complicationConfig: List<ComplicationSlotsOption>,
+            affectsWatchFaceLayers: Collection<WatchFaceLayer>,
+            defaultOption: ComplicationSlotsOption = complicationConfig.first(),
+            watchFaceEditorData: WatchFaceEditorData? = null
+        ) : this(
+            id,
+            DisplayText.ResourceDisplayTextWithIndex(resources, displayNameResourceId),
+            DisplayText.ResourceDisplayTextWithIndex(resources, descriptionResourceId),
+            iconProvider,
             watchFaceEditorData,
             complicationConfig,
             complicationConfig.indexOf(defaultOption),
@@ -1328,7 +1490,7 @@
             id: Id,
             displayName: DisplayText,
             description: DisplayText,
-            icon: Icon?,
+            iconProvider: () -> Icon?,
             watchFaceEditorData: WatchFaceEditorData? = null,
             options: List<ComplicationSlotsOption>,
             affectsWatchFaceLayers: Collection<WatchFaceLayer>,
@@ -1337,7 +1499,7 @@
             id,
             displayName,
             description,
-            icon,
+            iconProvider,
             watchFaceEditorData,
             options,
             defaultOptionIndex,
@@ -1434,7 +1596,7 @@
                     params.id,
                     params.displayName,
                     params.description,
-                    params.icon,
+                    params.iconProvider,
                     params.watchFaceEditorData,
                     params.options as List<ComplicationSlotsOption>,
                     params.affectedWatchFaceLayers,
@@ -1476,7 +1638,9 @@
                 get() = screenReaderNameInternal?.toCharSequence()
 
             /** Icon for use in the companion style selection UI. */
-            public val icon: Icon?
+            public val icon by lazy { iconProvider() }
+
+            private val iconProvider: () -> Icon?
 
             /**
              * Optional data for an on watch face editor, this will not be sent to the companion and
@@ -1515,7 +1679,7 @@
                 this.complicationSlotOverlays = complicationSlotOverlays
                 displayNameInternal = DisplayText.CharSequenceDisplayText(displayName)
                 screenReaderNameInternal = DisplayText.CharSequenceDisplayText(screenReaderName)
-                this.icon = icon
+                this.iconProvider = { icon }
                 this.watchFaceEditorData = watchFaceEditorData
             }
 
@@ -1553,7 +1717,7 @@
                 displayNameInternal =
                     DisplayText.ResourceDisplayTextWithIndex(resources, displayNameResourceId)
                 screenReaderNameInternal = null
-                this.icon = icon
+                this.iconProvider = { icon }
                 this.watchFaceEditorData = watchFaceEditorData
             }
 
@@ -1598,7 +1762,54 @@
                     DisplayText.ResourceDisplayTextWithIndex(resources, displayNameResourceId)
                 this.screenReaderNameInternal =
                     DisplayText.ResourceDisplayTextWithIndex(resources, screenReaderNameResourceId)
-                this.icon = icon
+                this.iconProvider = { icon }
+                this.watchFaceEditorData = watchFaceEditorData
+            }
+
+            /**
+             * Constructs a ComplicationSlotsUserStyleSetting with a lazily evaluated [icon], where
+             * [displayName] is constructed from Resources.
+             *
+             * @param id [Id] for the element, must be unique.
+             * @param resources The [Resources] from which [displayNameResourceId] is load.
+             * @param displayNameResourceId String resource id for a human readable name for the
+             *   element, used in the userStyle selection UI. This should be short, ideally < 20
+             *   characters. Note if the resource string contains `%1$s` that will get replaced with
+             *   the 1-based ordinal (1st, 2nd, 3rd etc...) of the ComplicationSlotsOption in the
+             *   list of ComplicationSlotsOptions.
+             * @param screenReaderNameResourceId String resource id for a human readable name for
+             *   the element, used by screen readers. This should be more descriptive than
+             *   [displayNameResourceId]. Note if the resource string contains `%1$s` that will get
+             *   replaced with the 1-based ordinal (1st, 2nd, 3rd etc...) of the
+             *   ComplicationSlotsOption in the list of ComplicationSlotsOptions. Note prior to
+             *   android T this is ignored by companion editors.
+             * @param iconProvider A provider of an [Icon] for use in the companion userStyle
+             *   selection UI. This gets lazily evaluated and is sent to the companion over
+             *   bluetooth and should be small (ideally a few kb in size). Note this is not
+             *   guaranteed to be called on the calling thread.
+             * @param complicationSlotOverlays Overlays to be applied when this
+             *   ComplicationSlotsOption is selected. If this is empty then the net result is the
+             *   initial complication configuration.
+             * @param watchFaceEditorData Optional data for an on watch face editor, this will not
+             *   be sent to the companion and its contents may be used in preference to other fields
+             *   by an on watch face editor.
+             */
+            @JvmOverloads
+            public constructor(
+                id: Id,
+                resources: Resources,
+                @StringRes displayNameResourceId: Int,
+                @StringRes screenReaderNameResourceId: Int,
+                iconProvider: () -> Icon?,
+                complicationSlotOverlays: Collection<ComplicationSlotOverlay>,
+                watchFaceEditorData: WatchFaceEditorData? = null
+            ) : super(id, emptyList()) {
+                this.complicationSlotOverlays = complicationSlotOverlays
+                this.displayNameInternal =
+                    DisplayText.ResourceDisplayTextWithIndex(resources, displayNameResourceId)
+                this.screenReaderNameInternal =
+                    DisplayText.ResourceDisplayTextWithIndex(resources, screenReaderNameResourceId)
+                this.iconProvider = iconProvider
                 this.watchFaceEditorData = watchFaceEditorData
             }
 
@@ -1606,14 +1817,14 @@
                 id: Id,
                 displayName: DisplayText,
                 screenReaderName: DisplayText,
-                icon: Icon?,
+                iconProvider: () -> Icon?,
                 watchFaceEditorData: WatchFaceEditorData?,
                 complicationSlotOverlays: Collection<ComplicationSlotOverlay>
             ) : super(id, emptyList()) {
                 this.complicationSlotOverlays = complicationSlotOverlays
                 this.displayNameInternal = displayName
                 this.screenReaderNameInternal = screenReaderName
-                this.icon = icon
+                this.iconProvider = iconProvider
                 this.watchFaceEditorData = watchFaceEditorData
             }
 
@@ -1635,7 +1846,7 @@
                     }
                 displayNameInternal = DisplayText.CharSequenceDisplayText(wireFormat.mDisplayName)
                 screenReaderNameInternal = null // This will get overwritten.
-                icon = wireFormat.mIcon
+                iconProvider = { wireFormat.mIcon }
                 watchFaceEditorData = null // This will get overwritten.
             }
 
@@ -1720,7 +1931,7 @@
                             defaultValue = displayName,
                             indexedResourceNamesSupported = true
                         )
-                    val icon = createIcon(resources, parser)
+                    val iconProvider = createLazyIcon(resources, parser)
 
                     var watchFaceEditorData: WatchFaceEditorData? = null
                     val complicationSlotOverlays = ArrayList<ComplicationSlotOverlay>()
@@ -1751,7 +1962,7 @@
                         Id(id),
                         displayName,
                         screenReaderName,
-                        icon,
+                        iconProvider,
                         watchFaceEditorData,
                         complicationSlotOverlays
                     )
@@ -1823,7 +2034,7 @@
                     params.id,
                     params.displayName,
                     params.description,
-                    params.icon,
+                    params.iconProvider,
                     params.watchFaceEditorData,
                     minDouble,
                     maxDouble,
@@ -1867,7 +2078,7 @@
             id,
             DisplayText.CharSequenceDisplayText(displayName),
             DisplayText.CharSequenceDisplayText(description),
-            icon,
+            { icon },
             watchFaceEditorData,
             createOptionsList(minimumValue, maximumValue, defaultValue),
             // The index of defaultValue can only ever be 0 or 1.
@@ -1916,7 +2127,59 @@
             id,
             DisplayText.ResourceDisplayTextWithIndex(resources, displayNameResourceId),
             DisplayText.ResourceDisplayTextWithIndex(resources, descriptionResourceId),
-            icon,
+            { icon },
+            watchFaceEditorData,
+            createOptionsList(minimumValue, maximumValue, defaultValue),
+            // The index of defaultValue can only ever be 0 or 1.
+            when (defaultValue) {
+                minimumValue -> 0
+                else -> 1
+            },
+            affectsWatchFaceLayers
+        )
+
+        /**
+         * Constructs a DoubleRangeUserStyleSetting with a lazily evaluated [Icon], where
+         * [DoubleRangeUserStyleSetting.displayName] and [DoubleRangeUserStyleSetting.description]
+         * are specified as resources.
+         *
+         * @param id [Id] for the element, must be unique.
+         * @param resources The [Resources] from which [displayNameResourceId] and
+         *   [descriptionResourceId] are loaded.
+         * @param displayNameResourceId String resource id for a human readable name for the
+         *   element, used in the userStyle selection UI.
+         * @param descriptionResourceId String resource id for a human readable description string
+         *   displayed under the displayName.
+         * @param iconProvider A provider of an [Icon] for use in the companion userStyle selection
+         *   UI. This gets lazily evaluated and is sent to the companion over bluetooth and should
+         *   be small (ideally a few kb in size). Note this is not guaranteed to be called on the
+         *   calling thread.
+         * @param minimumValue Minimum value (inclusive).
+         * @param maximumValue Maximum value (inclusive).
+         * @param affectsWatchFaceLayers Used by the style configuration UI. Describes which watch
+         *   face rendering layers this style affects.
+         * @param defaultValue The default value for this DoubleRangeUserStyleSetting.
+         * @param watchFaceEditorData Optional data for an on watch face editor, this will not be
+         *   sent to the companion and its contents may be used in preference to other fields by an
+         *   on watch face editor.
+         */
+        @JvmOverloads
+        public constructor(
+            id: Id,
+            resources: Resources,
+            @StringRes displayNameResourceId: Int,
+            @StringRes descriptionResourceId: Int,
+            iconProvider: () -> Icon?,
+            minimumValue: Double,
+            maximumValue: Double,
+            affectsWatchFaceLayers: Collection<WatchFaceLayer>,
+            defaultValue: Double,
+            watchFaceEditorData: WatchFaceEditorData? = null
+        ) : super(
+            id,
+            DisplayText.ResourceDisplayTextWithIndex(resources, displayNameResourceId),
+            DisplayText.ResourceDisplayTextWithIndex(resources, descriptionResourceId),
+            iconProvider,
             watchFaceEditorData,
             createOptionsList(minimumValue, maximumValue, defaultValue),
             // The index of defaultValue can only ever be 0 or 1.
@@ -1931,7 +2194,7 @@
             id: Id,
             displayName: DisplayText,
             description: DisplayText,
-            icon: Icon?,
+            iconProvider: () -> Icon?,
             watchFaceEditorData: WatchFaceEditorData?,
             minimumValue: Double,
             maximumValue: Double,
@@ -1941,7 +2204,7 @@
             id,
             displayName,
             description,
-            icon,
+            iconProvider,
             watchFaceEditorData,
             createOptionsList(minimumValue, maximumValue, defaultValue),
             // The index of defaultValue can only ever be 0 or 1.
@@ -2074,7 +2337,48 @@
             id,
             DisplayText.CharSequenceDisplayText(displayName),
             DisplayText.CharSequenceDisplayText(description),
-            icon,
+            { icon },
+            watchFaceEditorData,
+            options,
+            options.indexOf(defaultOption),
+            affectsWatchFaceLayers
+        )
+
+        /**
+         * Constructs a ListUserStyleSetting with a lazily evaluated [Icon].
+         *
+         * @param id [Id] for the element, must be unique.
+         * @param displayName Localized human readable name for the element, used in the userStyle
+         *   selection UI.
+         * @param description Localized description string displayed under the displayName.
+         * @param iconProvider A provider of an [Icon] for use in the companion userStyle selection
+         *   UI. This gets lazily evaluated and is sent to the companion over bluetooth and should
+         *   be small (ideally a few kb in size). Note this is not guaranteed to be called on the
+         *   calling thread.
+         * @param options List of all options for this ListUserStyleSetting.
+         * @param affectsWatchFaceLayers Used by the style configuration UI. Describes which watch
+         *   face rendering layers this style affects.
+         * @param defaultOption The default option, used when data isn't persisted.
+         * @param watchFaceEditorData Optional data for an on watch face editor, this will not be
+         *   sent to the companion and its contents may be used in preference to other fields by an
+         *   on watch face editor.
+         */
+        @JvmOverloads
+        @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+        public constructor(
+            id: Id,
+            displayName: CharSequence,
+            description: CharSequence,
+            iconProvider: () -> Icon?,
+            options: List<ListOption>,
+            affectsWatchFaceLayers: Collection<WatchFaceLayer>,
+            defaultOption: ListOption = options.first(),
+            watchFaceEditorData: WatchFaceEditorData? = null
+        ) : super(
+            id,
+            DisplayText.CharSequenceDisplayText(displayName),
+            DisplayText.CharSequenceDisplayText(description),
+            iconProvider,
             watchFaceEditorData,
             options,
             options.indexOf(defaultOption),
@@ -2117,7 +2421,53 @@
             id,
             DisplayText.ResourceDisplayTextWithIndex(resources, displayNameResourceId),
             DisplayText.ResourceDisplayTextWithIndex(resources, descriptionResourceId),
-            icon,
+            { icon },
+            watchFaceEditorData,
+            options,
+            options.indexOf(defaultOption),
+            affectsWatchFaceLayers
+        )
+
+        /**
+         * Constructs a ListUserStyleSetting with a lazily evaluated [Icon], where
+         * [ListUserStyleSetting.displayName] and [ListUserStyleSetting.description] are specified
+         * as resources.
+         *
+         * @param id [Id] for the element, must be unique.
+         * @param resources The [Resources] from which [displayNameResourceId] and
+         *   [descriptionResourceId] are loaded.
+         * @param displayNameResourceId String resource id for a human readable name for the
+         *   element, used in the userStyle selection UI.
+         * @param descriptionResourceId String resource id for a human readable description string
+         *   displayed under the displayName.
+         * @param iconProvider A provider of an [Icon] for use in the companion userStyle selection
+         *   UI. This gets lazily evaluated and is sent to the companion over bluetooth and should
+         *   be small (ideally a few kb in size). Note this is not guaranteed to be called on the
+         *   calling thread.
+         * @param options List of all options for this ListUserStyleSetting.
+         * @param affectsWatchFaceLayers Used by the style configuration UI. Describes which watch
+         *   face rendering layers this style affects.
+         * @param defaultOption The default option, used when data isn't persisted.
+         * @param watchFaceEditorData Optional data for an on watch face editor, this will not be
+         *   sent to the companion and its contents may be used in preference to other fields by an
+         *   on watch face editor.
+         */
+        @JvmOverloads
+        public constructor(
+            id: Id,
+            resources: Resources,
+            @StringRes displayNameResourceId: Int,
+            @StringRes descriptionResourceId: Int,
+            iconProvider: () -> Icon?,
+            options: List<ListOption>,
+            affectsWatchFaceLayers: Collection<WatchFaceLayer>,
+            defaultOption: ListOption = options.first(),
+            watchFaceEditorData: WatchFaceEditorData? = null
+        ) : super(
+            id,
+            DisplayText.ResourceDisplayTextWithIndex(resources, displayNameResourceId),
+            DisplayText.ResourceDisplayTextWithIndex(resources, descriptionResourceId),
+            iconProvider,
             watchFaceEditorData,
             options,
             options.indexOf(defaultOption),
@@ -2128,7 +2478,7 @@
             id: Id,
             displayName: DisplayText,
             description: DisplayText,
-            icon: Icon?,
+            iconProvider: () -> Icon?,
             watchFaceEditorData: WatchFaceEditorData?,
             options: List<ListOption>,
             affectsWatchFaceLayers: Collection<WatchFaceLayer>,
@@ -2137,7 +2487,7 @@
             id,
             displayName,
             description,
-            icon,
+            iconProvider,
             watchFaceEditorData,
             options,
             defaultOptionIndex,
@@ -2221,7 +2571,7 @@
                     params.id,
                     params.displayName,
                     params.description,
-                    params.icon,
+                    params.iconProvider,
                     params.watchFaceEditorData,
                     params.options as List<ListOption>,
                     params.affectedWatchFaceLayers,
@@ -2259,7 +2609,10 @@
                 get() = screenReaderNameInternal?.toCharSequence()
 
             /** Icon for use in the companion style selection UI. */
-            public val icon: Icon?
+            public val icon by lazy { TraceEvent("invoke iconProvider").use { iconProvider() } }
+
+            /** To avoid upfront costs, icons are lazily evaluated. */
+            private val iconProvider: () -> Icon?
 
             /**
              * Optional data for an on watch face editor, this will not be sent to the companion and
@@ -2297,7 +2650,23 @@
             ) : super(id, childSettings) {
                 displayNameInternal = DisplayText.CharSequenceDisplayText(displayName)
                 screenReaderNameInternal = DisplayText.CharSequenceDisplayText(screenReaderName)
-                this.icon = icon
+                this.iconProvider = { icon }
+                this.watchFaceEditorData = watchFaceEditorData
+            }
+
+            @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+            @JvmOverloads
+            constructor(
+                id: Id,
+                displayName: CharSequence,
+                screenReaderName: CharSequence,
+                iconProvider: () -> Icon?,
+                childSettings: Collection<UserStyleSetting> = emptyList(),
+                watchFaceEditorData: WatchFaceEditorData? = null
+            ) : super(id, childSettings) {
+                displayNameInternal = DisplayText.CharSequenceDisplayText(displayName)
+                screenReaderNameInternal = DisplayText.CharSequenceDisplayText(screenReaderName)
+                this.iconProvider = iconProvider
                 this.watchFaceEditorData = watchFaceEditorData
             }
 
@@ -2330,7 +2699,7 @@
                 displayNameInternal =
                     DisplayText.ResourceDisplayTextWithIndex(resources, displayNameResourceId)
                 screenReaderNameInternal = null
-                this.icon = icon
+                this.iconProvider = { icon }
                 this.watchFaceEditorData = watchFaceEditorData
             }
 
@@ -2363,7 +2732,7 @@
                 displayNameInternal =
                     DisplayText.ResourceDisplayTextWithIndex(resources, displayNameResourceId)
                 screenReaderNameInternal = null
-                this.icon = icon
+                this.iconProvider = { icon }
                 this.watchFaceEditorData = watchFaceEditorData
             }
 
@@ -2405,7 +2774,51 @@
                     DisplayText.ResourceDisplayTextWithIndex(resources, displayNameResourceId)
                 screenReaderNameInternal =
                     DisplayText.ResourceDisplayTextWithIndex(resources, screenReaderNameResourceId)
-                this.icon = icon
+                this.iconProvider = { icon }
+                this.watchFaceEditorData = watchFaceEditorData
+            }
+
+            /**
+             * Constructs a ListOption with a lazily evaluated [icon].
+             *
+             * @param id The [Id] of this ListOption, must be unique within the
+             *   [ListUserStyleSetting].
+             * @param resources The [Resources] used to load [displayNameResourceId].
+             * @param displayNameResourceId String resource id for a human readable name for the
+             *   element, used in the userStyle selection UI. This should be short, ideally < 20
+             *   characters. Note if the resource string contains `%1$s` that will get replaced with
+             *   the 1-based ordinal (1st, 2nd, 3rd etc...) of the ListOption in the list of
+             *   ListOptions.
+             * @param screenReaderNameResourceId String resource id for a human readable name for
+             *   the element, used by screen readers. This should be more descriptive than
+             *   [displayNameResourceId]. Note if the resource string contains `%1$s` that will get
+             *   replaced with the 1-based ordinal (1st, 2nd, 3rd etc...) of the ListOption in the
+             *   list of ListOptions. Note prior to android T this is ignored by companion editors.
+             * @param iconProvider A provider of an [Icon] for use in the companion userStyle
+             *   selection UI. This gets lazily evaluated and is sent to the companion over
+             *   bluetooth and should be small (ideally a few kb in size). Note this is not
+             *   guaranteed to be called on the calling thread.
+             * @param childSettings The list of child [UserStyleSetting]s, which may be empty. Any
+             *   child settings must be listed in [UserStyleSchema.userStyleSettings].
+             * @param watchFaceEditorData Optional data for an on watch face editor, this will not
+             *   be sent to the companion and its contents may be used in preference to other fields
+             *   by an on watch face editor.
+             */
+            @JvmOverloads
+            constructor(
+                id: Id,
+                resources: Resources,
+                @StringRes displayNameResourceId: Int,
+                @StringRes screenReaderNameResourceId: Int,
+                iconProvider: () -> Icon?,
+                childSettings: Collection<UserStyleSetting> = emptyList(),
+                watchFaceEditorData: WatchFaceEditorData? = null
+            ) : super(id, childSettings) {
+                displayNameInternal =
+                    DisplayText.ResourceDisplayTextWithIndex(resources, displayNameResourceId)
+                screenReaderNameInternal =
+                    DisplayText.ResourceDisplayTextWithIndex(resources, screenReaderNameResourceId)
+                this.iconProvider = iconProvider
                 this.watchFaceEditorData = watchFaceEditorData
             }
 
@@ -2413,13 +2826,13 @@
                 id: Id,
                 displayName: DisplayText,
                 screenReaderName: DisplayText,
-                icon: Icon?,
+                iconProvider: () -> Icon?,
                 watchFaceEditorData: WatchFaceEditorData?,
                 childSettings: Collection<UserStyleSetting> = emptyList()
             ) : super(id, childSettings) {
                 displayNameInternal = displayName
                 screenReaderNameInternal = screenReaderName
-                this.icon = icon
+                this.iconProvider = iconProvider
                 this.watchFaceEditorData = watchFaceEditorData
             }
 
@@ -2428,7 +2841,7 @@
             ) : super(Id(wireFormat.mId), ArrayList()) {
                 displayNameInternal = DisplayText.CharSequenceDisplayText(wireFormat.mDisplayName)
                 screenReaderNameInternal = null // This will get overwritten.
-                icon = wireFormat.mIcon
+                iconProvider = { wireFormat.mIcon }
                 watchFaceEditorData = null // This gets overwritten.
             }
 
@@ -2492,7 +2905,7 @@
                             defaultValue = displayName,
                             indexedResourceNamesSupported = true
                         )
-                    val icon = createIcon(resources, parser)
+                    val iconProvider = createLazyIcon(resources, parser)
 
                     var watchFaceEditorData: WatchFaceEditorData? = null
                     val childSettings = ArrayList<UserStyleSetting>()
@@ -2524,7 +2937,7 @@
                         Id(id),
                         displayName,
                         screenReaderName,
-                        icon,
+                        iconProvider,
                         watchFaceEditorData,
                         childSettings
                     )
@@ -2596,7 +3009,7 @@
                     params.id,
                     params.displayName,
                     params.description,
-                    params.icon,
+                    params.iconProvider,
                     params.watchFaceEditorData,
                     minInteger,
                     maxInteger,
@@ -2640,7 +3053,7 @@
             id,
             DisplayText.CharSequenceDisplayText(displayName),
             DisplayText.CharSequenceDisplayText(description),
-            icon,
+            { icon },
             watchFaceEditorData,
             createOptionsList(minimumValue, maximumValue, defaultValue),
             // The index of defaultValue can only ever be 0 or 1.
@@ -2689,7 +3102,59 @@
             id,
             DisplayText.ResourceDisplayTextWithIndex(resources, displayNameResourceId),
             DisplayText.ResourceDisplayTextWithIndex(resources, descriptionResourceId),
-            icon,
+            { icon },
+            watchFaceEditorData,
+            createOptionsList(minimumValue, maximumValue, defaultValue),
+            // The index of defaultValue can only ever be 0 or 1.
+            when (defaultValue) {
+                minimumValue -> 0
+                else -> 1
+            },
+            affectsWatchFaceLayers
+        )
+
+        /**
+         * Constructs a LongRangeUserStyleSetting where [LongRangeUserStyleSetting.displayName] and
+         * [LongRangeUserStyleSetting.description] are specified as resources, with a lazily
+         * constructed [icon].
+         *
+         * @param id [Id] for the element, must be unique.
+         * @param resources The [Resources] from which [displayNameResourceId] and
+         *   [descriptionResourceId] are loaded.
+         * @param displayNameResourceId String resource id for a human readable name for the
+         *   element, used in the userStyle selection UI.
+         * @param descriptionResourceId String resource id for a human readable description string
+         *   displayed under the displayName.
+         * @param iconProvider A provider of an [Icon] for use in the companion userStyle selection
+         *   UI. This gets lazily evaluated and is sent to the companion over bluetooth and should
+         *   be small (ideally a few kb in size). Note this is not guaranteed to be called on the
+         *   calling thread.
+         * @param minimumValue Minimum value (inclusive).
+         * @param maximumValue Maximum value (inclusive).
+         * @param affectsWatchFaceLayers Used by the style configuration UI. Describes which watch
+         *   face rendering layers this style affects.
+         * @param defaultValue The default value for this LongRangeUserStyleSetting.
+         * @param watchFaceEditorData Optional data for an on watch face editor, this will not be
+         *   sent to the companion and its contents may be used in preference to other fields by an
+         *   on watch face editor.
+         */
+        @JvmOverloads
+        public constructor(
+            id: Id,
+            resources: Resources,
+            @StringRes displayNameResourceId: Int,
+            @StringRes descriptionResourceId: Int,
+            iconProvider: () -> Icon?,
+            minimumValue: Long,
+            maximumValue: Long,
+            affectsWatchFaceLayers: Collection<WatchFaceLayer>,
+            defaultValue: Long,
+            watchFaceEditorData: WatchFaceEditorData? = null
+        ) : super(
+            id,
+            DisplayText.ResourceDisplayTextWithIndex(resources, displayNameResourceId),
+            DisplayText.ResourceDisplayTextWithIndex(resources, descriptionResourceId),
+            iconProvider,
             watchFaceEditorData,
             createOptionsList(minimumValue, maximumValue, defaultValue),
             // The index of defaultValue can only ever be 0 or 1.
@@ -2704,7 +3169,7 @@
             id: Id,
             displayName: DisplayText,
             description: DisplayText,
-            icon: Icon?,
+            iconProvider: () -> Icon?,
             watchFaceEditorData: WatchFaceEditorData?,
             minimumValue: Long,
             maximumValue: Long,
@@ -2714,7 +3179,7 @@
             id,
             displayName,
             description,
-            icon,
+            iconProvider,
             watchFaceEditorData,
             createOptionsList(minimumValue, maximumValue, defaultValue),
             // The index of defaultValue can only ever be 0 or 1.
@@ -2840,7 +3305,7 @@
             Id(CUSTOM_VALUE_USER_STYLE_SETTING_ID),
             DisplayText.CharSequenceDisplayText(""),
             DisplayText.CharSequenceDisplayText(""),
-            null,
+            { null },
             null,
             listOf(CustomValueOption(defaultValue)),
             0,
@@ -2934,7 +3399,7 @@
             Id(CUSTOM_VALUE_USER_STYLE_SETTING_ID),
             DisplayText.CharSequenceDisplayText(""),
             DisplayText.CharSequenceDisplayText(""),
-            null,
+            { null },
             null,
             listOf(CustomValueOption(defaultValue)),
             0,
diff --git a/wear/watchface/watchface/samples/src/main/java/androidx/wear/watchface/samples/ExampleCanvasAnalogWatchFaceService.kt b/wear/watchface/watchface/samples/src/main/java/androidx/wear/watchface/samples/ExampleCanvasAnalogWatchFaceService.kt
index 138bcd1..6331e3b 100644
--- a/wear/watchface/watchface/samples/src/main/java/androidx/wear/watchface/samples/ExampleCanvasAnalogWatchFaceService.kt
+++ b/wear/watchface/watchface/samples/src/main/java/androidx/wear/watchface/samples/ExampleCanvasAnalogWatchFaceService.kt
@@ -92,21 +92,21 @@
                         resources,
                         R.string.colors_style_red,
                         R.string.colors_style_red_screen_reader,
-                        Icon.createWithResource(this, R.drawable.red_style)
+                        { Icon.createWithResource(this, R.drawable.red_style) }
                     ),
                     ListUserStyleSetting.ListOption(
                         Option.Id(GREEN_STYLE),
                         resources,
                         R.string.colors_style_green,
                         R.string.colors_style_green_screen_reader,
-                        Icon.createWithResource(this, R.drawable.green_style)
+                        { Icon.createWithResource(this, R.drawable.green_style) }
                     ),
                     ListUserStyleSetting.ListOption(
                         Option.Id(BLUE_STYLE),
                         resources,
                         R.string.colors_style_blue,
                         R.string.colors_style_blue_screen_reader,
-                        Icon.createWithResource(this, R.drawable.blue_style)
+                        { Icon.createWithResource(this, R.drawable.blue_style) }
                     )
                 ),
             listOf(
@@ -161,8 +161,7 @@
                         R.string.watchface_complications_setting_both,
                         null,
                         // NB this list is empty because each [ComplicationSlotOverlay] is applied
-                        // on
-                        // top of the initial config.
+                        // on top of the initial config.
                         listOf()
                     ),
                     ComplicationSlotsUserStyleSetting.ComplicationSlotsOption(
diff --git a/wear/watchface/watchface/samples/src/main/java/androidx/wear/watchface/samples/ExampleCanvasDigitalWatchFaceService.kt b/wear/watchface/watchface/samples/src/main/java/androidx/wear/watchface/samples/ExampleCanvasDigitalWatchFaceService.kt
index 96757db..eab0525 100644
--- a/wear/watchface/watchface/samples/src/main/java/androidx/wear/watchface/samples/ExampleCanvasDigitalWatchFaceService.kt
+++ b/wear/watchface/watchface/samples/src/main/java/androidx/wear/watchface/samples/ExampleCanvasDigitalWatchFaceService.kt
@@ -90,21 +90,21 @@
                         resources,
                         R.string.colors_style_red,
                         R.string.colors_style_red_screen_reader,
-                        Icon.createWithResource(this, R.drawable.red_style)
+                        { Icon.createWithResource(this, R.drawable.red_style) }
                     ),
                     UserStyleSetting.ListUserStyleSetting.ListOption(
                         Option.Id(GREEN_STYLE),
                         resources,
                         R.string.colors_style_green,
                         R.string.colors_style_green_screen_reader,
-                        Icon.createWithResource(this, R.drawable.green_style)
+                        { Icon.createWithResource(this, R.drawable.green_style) }
                     ),
                     UserStyleSetting.ListUserStyleSetting.ListOption(
                         Option.Id(BLUE_STYLE),
                         resources,
                         R.string.colors_style_blue,
                         R.string.colors_style_blue_screen_reader,
-                        Icon.createWithResource(this, R.drawable.blue_style)
+                        { Icon.createWithResource(this, R.drawable.blue_style) }
                     )
                 ),
             listOf(
diff --git a/wear/watchface/watchface/samples/src/main/java/androidx/wear/watchface/samples/ExampleHierarchicalStyleWatchFaceService.kt b/wear/watchface/watchface/samples/src/main/java/androidx/wear/watchface/samples/ExampleHierarchicalStyleWatchFaceService.kt
index 205e03f..2db1132 100644
--- a/wear/watchface/watchface/samples/src/main/java/androidx/wear/watchface/samples/ExampleHierarchicalStyleWatchFaceService.kt
+++ b/wear/watchface/watchface/samples/src/main/java/androidx/wear/watchface/samples/ExampleHierarchicalStyleWatchFaceService.kt
@@ -63,7 +63,7 @@
             resources,
             R.string.digital_clock_style_12,
             R.string.digital_clock_style_12_screen_reader,
-            Icon.createWithResource(this, R.drawable.red_style)
+            { Icon.createWithResource(this, R.drawable.red_style) }
         )
     }
 
@@ -73,7 +73,7 @@
             resources,
             R.string.digital_clock_style_24,
             R.string.digital_clock_style_24_screen_reader,
-            Icon.createWithResource(this, R.drawable.red_style)
+            { Icon.createWithResource(this, R.drawable.red_style) }
         )
     }
 
@@ -91,7 +91,8 @@
                         UserStyleSetting.Option.Id("On"),
                         resources,
                         R.string.digital_complication_on_screen_name,
-                        Icon.createWithResource(this, R.drawable.on),
+                        R.string.digital_complication_on_screen_name,
+                        { Icon.createWithResource(this, R.drawable.on) },
                         listOf(
                             ComplicationSlotOverlay(
                                 COMPLICATION1_ID,
@@ -107,7 +108,8 @@
                         UserStyleSetting.Option.Id("Off"),
                         resources,
                         R.string.digital_complication_off_screen_name,
-                        Icon.createWithResource(this, R.drawable.off),
+                        R.string.digital_complication_on_screen_name,
+                        { Icon.createWithResource(this, R.drawable.off) },
                         listOf(
                             ComplicationSlotOverlay(COMPLICATION1_ID, enabled = false),
                             ComplicationSlotOverlay(COMPLICATION2_ID, enabled = false),
@@ -137,7 +139,7 @@
             resources,
             R.string.colors_style_red,
             R.string.colors_style_red_screen_reader,
-            Icon.createWithResource(this, R.drawable.red_style)
+            { Icon.createWithResource(this, R.drawable.red_style) }
         )
     }
 
@@ -147,7 +149,7 @@
             resources,
             R.string.colors_style_green,
             R.string.colors_style_green_screen_reader,
-            Icon.createWithResource(this, R.drawable.green_style)
+            { Icon.createWithResource(this, R.drawable.green_style) }
         )
     }
 
@@ -157,7 +159,7 @@
             resources,
             R.string.colors_style_blue,
             R.string.colors_style_blue_screen_reader,
-            Icon.createWithResource(this, R.drawable.blue_style)
+            { Icon.createWithResource(this, R.drawable.blue_style) }
         )
     }
 
@@ -204,7 +206,8 @@
                         UserStyleSetting.Option.Id("One"),
                         resources,
                         R.string.analog_complication_one_screen_name,
-                        Icon.createWithResource(this, R.drawable.one),
+                        R.string.analog_complication_one_screen_name,
+                        { Icon.createWithResource(this, R.drawable.one) },
                         listOf(
                             ComplicationSlotOverlay(COMPLICATION1_ID, enabled = true),
                             ComplicationSlotOverlay(COMPLICATION2_ID, enabled = false),
@@ -215,7 +218,8 @@
                         UserStyleSetting.Option.Id("Two"),
                         resources,
                         R.string.analog_complication_two_screen_name,
-                        Icon.createWithResource(this, R.drawable.two),
+                        R.string.analog_complication_two_screen_name,
+                        { Icon.createWithResource(this, R.drawable.two) },
                         listOf(
                             ComplicationSlotOverlay(COMPLICATION1_ID, enabled = true),
                             ComplicationSlotOverlay(COMPLICATION2_ID, enabled = true),
@@ -226,7 +230,8 @@
                         UserStyleSetting.Option.Id("Three"),
                         resources,
                         R.string.analog_complication_three_screen_name,
-                        Icon.createWithResource(this, R.drawable.three),
+                        R.string.analog_complication_three_screen_name,
+                        { Icon.createWithResource(this, R.drawable.three) },
                         listOf(
                             ComplicationSlotOverlay(COMPLICATION1_ID, enabled = true),
                             ComplicationSlotOverlay(COMPLICATION2_ID, enabled = true),
@@ -244,7 +249,7 @@
             resources,
             R.string.style_digital_watch,
             R.string.style_digital_watch_screen_reader,
-            icon = Icon.createWithResource(this, R.drawable.d),
+            iconProvider = { Icon.createWithResource(this, R.drawable.d) },
             childSettings =
                 listOf(digitalClockStyleSetting, colorStyleSetting, digitalComplicationSettings)
         )
@@ -256,7 +261,7 @@
             resources,
             R.string.style_analog_watch,
             R.string.style_analog_watch_screen_reader,
-            icon = Icon.createWithResource(this, R.drawable.a),
+            iconProvider = { Icon.createWithResource(this, R.drawable.a) },
             childSettings = listOf(colorStyleSetting, drawHoursSetting, analogComplicationSettings)
         )
     }
diff --git a/wear/watchface/watchface/samples/src/main/java/androidx/wear/watchface/samples/ExampleOpenGLBackgroundInitWatchFaceService.kt b/wear/watchface/watchface/samples/src/main/java/androidx/wear/watchface/samples/ExampleOpenGLBackgroundInitWatchFaceService.kt
index f61f2b5..7c13751 100644
--- a/wear/watchface/watchface/samples/src/main/java/androidx/wear/watchface/samples/ExampleOpenGLBackgroundInitWatchFaceService.kt
+++ b/wear/watchface/watchface/samples/src/main/java/androidx/wear/watchface/samples/ExampleOpenGLBackgroundInitWatchFaceService.kt
@@ -57,14 +57,14 @@
                         resources,
                         R.string.colors_style_yellow,
                         R.string.colors_style_yellow_screen_reader,
-                        Icon.createWithResource(this, R.drawable.yellow_style)
+                        { Icon.createWithResource(this, R.drawable.yellow_style) }
                     ),
                     UserStyleSetting.ListUserStyleSetting.ListOption(
                         UserStyleSetting.Option.Id("blue_style"),
                         resources,
                         R.string.colors_style_blue,
                         R.string.colors_style_blue_screen_reader,
-                        Icon.createWithResource(this, R.drawable.blue_style)
+                        { Icon.createWithResource(this, R.drawable.blue_style) }
                     )
                 ),
             listOf(WatchFaceLayer.BASE, WatchFaceLayer.COMPLICATIONS_OVERLAY)
diff --git a/wear/watchface/watchface/samples/src/main/java/androidx/wear/watchface/samples/ExampleOpenGLWatchFaceService.kt b/wear/watchface/watchface/samples/src/main/java/androidx/wear/watchface/samples/ExampleOpenGLWatchFaceService.kt
index 3831a29..623ade9 100644
--- a/wear/watchface/watchface/samples/src/main/java/androidx/wear/watchface/samples/ExampleOpenGLWatchFaceService.kt
+++ b/wear/watchface/watchface/samples/src/main/java/androidx/wear/watchface/samples/ExampleOpenGLWatchFaceService.kt
@@ -81,14 +81,14 @@
                         resources,
                         R.string.colors_style_red,
                         R.string.colors_style_red_screen_reader,
-                        Icon.createWithResource(this, R.drawable.red_style)
+                        { Icon.createWithResource(this, R.drawable.red_style) }
                     ),
                     ListUserStyleSetting.ListOption(
                         Option.Id("green_style"),
                         resources,
                         R.string.colors_style_green,
                         R.string.colors_style_green_screen_reader,
-                        Icon.createWithResource(this, R.drawable.green_style)
+                        { Icon.createWithResource(this, R.drawable.green_style) }
                     )
                 ),
             listOf(WatchFaceLayer.BASE, WatchFaceLayer.COMPLICATIONS_OVERLAY)
diff --git a/wear/watchface/watchface/src/main/java/androidx/wear/watchface/WatchFaceService.kt b/wear/watchface/watchface/src/main/java/androidx/wear/watchface/WatchFaceService.kt
index 6c60aae..0888ae7 100644
--- a/wear/watchface/watchface/src/main/java/androidx/wear/watchface/WatchFaceService.kt
+++ b/wear/watchface/watchface/src/main/java/androidx/wear/watchface/WatchFaceService.kt
@@ -1267,6 +1267,8 @@
         @get:RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
         public val deferredWatchFaceImpl = CompletableDeferred<WatchFaceImpl>()
 
+        public val deferredFirstFrame = CompletableDeferred<Unit>()
+
         @VisibleForTesting public var deferredValidation = CompletableDeferred<Unit>()
 
         /**
@@ -2447,9 +2449,14 @@
                     // Now init has completed, it's OK to complete deferredWatchFaceImpl.
                     initComplicationsDone.complete(Unit)
 
-                    // validateSchemaWireSize is fairly expensive so only perform it for
-                    // interactive watch faces.
+                    // validateSchemaWireSize is fairly expensive so only perform it for interactive
+                    // watch faces.
                     if (!watchState.isHeadless) {
+                        // Wait until the first frame has been rendered since
+                        // validateSchemaWireSize is computationally expensive and it may trigger
+                        // lazy Icon construction and we want to avoid CPU contention with user
+                        // visible tasks.
+                        deferredFirstFrame.await()
                         validateSchemaWireSize(currentUserStyleRepository.schema)
                     }
                 } catch (e: CancellationException) {
@@ -2530,6 +2537,7 @@
                         }
                     }
                 }
+                deferredFirstFrame.complete(Unit)
 
                 Log.d(TAG, "init complete ${watchState.watchFaceInstanceId.value}")
             }
diff --git a/window/window-core/src/commonMain/kotlin/androidx/window/core/layout/WindowSizeClassSelectors.kt b/window/window-core/src/commonMain/kotlin/androidx/window/core/layout/WindowSizeClassSelectors.kt
index c631d20..5215cdb 100644
--- a/window/window-core/src/commonMain/kotlin/androidx/window/core/layout/WindowSizeClassSelectors.kt
+++ b/window/window-core/src/commonMain/kotlin/androidx/window/core/layout/WindowSizeClassSelectors.kt
@@ -60,7 +60,7 @@
         if (
             bucket.minWidthDp == maxWidth &&
                 bucket.minHeightDp <= heightDp &&
-                match.minHeightDp < bucket.minHeightDp
+                match.minHeightDp <= bucket.minHeightDp
         ) {
             match = bucket
         }
@@ -94,7 +94,7 @@
         if (
             bucket.minHeightDp == maxHeight &&
                 bucket.minWidthDp <= widthDp &&
-                match.minWidthDp < bucket.minWidthDp
+                match.minWidthDp <= bucket.minWidthDp
         ) {
             match = bucket
         }
diff --git a/window/window-core/src/commonTest/kotlin/androidx/window/core/layout/WindowSizeClassSelectorsTest.kt b/window/window-core/src/commonTest/kotlin/androidx/window/core/layout/WindowSizeClassSelectorsTest.kt
index e157d73..c928fc1 100644
--- a/window/window-core/src/commonTest/kotlin/androidx/window/core/layout/WindowSizeClassSelectorsTest.kt
+++ b/window/window-core/src/commonTest/kotlin/androidx/window/core/layout/WindowSizeClassSelectorsTest.kt
@@ -16,14 +16,17 @@
 
 package androidx.window.core.layout
 
+import androidx.window.core.layout.WindowSizeClass.Companion.BREAKPOINTS_V1
+import androidx.window.core.layout.WindowSizeClass.Companion.HEIGHT_DP_EXPANDED_LOWER_BOUND
 import androidx.window.core.layout.WindowSizeClass.Companion.HEIGHT_DP_MEDIUM_LOWER_BOUND
+import androidx.window.core.layout.WindowSizeClass.Companion.WIDTH_DP_EXPANDED_LOWER_BOUND
 import androidx.window.core.layout.WindowSizeClass.Companion.WIDTH_DP_MEDIUM_LOWER_BOUND
 import kotlin.test.Test
 import kotlin.test.assertEquals
 
 class WindowSizeClassSelectorsTest {
 
-    val coreSet = WindowSizeClass.BREAKPOINTS_V1
+    val coreSet = BREAKPOINTS_V1
 
     @Test
     fun compute_window_size_class_with_floats_truncates() {
@@ -169,4 +172,20 @@
 
         assertEquals(expected, actual)
     }
+
+    @Test
+    fun edge_case_matching_bucket_has_min_height_0() {
+        val expected = WindowSizeClass(WIDTH_DP_EXPANDED_LOWER_BOUND, 0)
+        val actual = BREAKPOINTS_V1.computeWindowSizeClass(1290, 400)
+
+        assertEquals(expected, actual)
+    }
+
+    @Test
+    fun edge_case_matching_bucket_has_min_width_0() {
+        val expected = WindowSizeClass(0, HEIGHT_DP_EXPANDED_LOWER_BOUND)
+        val actual = BREAKPOINTS_V1.computeWindowSizeClassPreferHeight(400, 1290)
+
+        assertEquals(expected, actual)
+    }
 }