Merge "KspEnumEntry does not extend KspTypeElement" into androidx-main
diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml
index 2ca2d60..0a7ba29 100644
--- a/.idea/codeStyles/Project.xml
+++ b/.idea/codeStyles/Project.xml
@@ -15,18 +15,24 @@
           <emptyLine />
           <package name="dalvik" withSubpackages="true" static="true" />
           <emptyLine />
-          <package name="libcore" withSubpackages="true" static="true" />
-          <emptyLine />
           <package name="com" withSubpackages="true" static="true" />
           <emptyLine />
+          <package name="dagger" withSubpackages="true" static="true" />
+          <emptyLine />
           <package name="gov" withSubpackages="true" static="true" />
           <emptyLine />
           <package name="junit" withSubpackages="true" static="true" />
           <emptyLine />
+          <package name="libcore" withSubpackages="true" static="true" />
+          <emptyLine />
           <package name="net" withSubpackages="true" static="true" />
           <emptyLine />
+          <package name="kotlin" withSubpackages="true" static="true" />
+          <emptyLine />
           <package name="org" withSubpackages="true" static="true" />
           <emptyLine />
+          <package name="perfetto" withSubpackages="true" static="true" />
+          <emptyLine />
           <package name="java" withSubpackages="true" static="true" />
           <emptyLine />
           <package name="javax" withSubpackages="true" static="true" />
@@ -41,18 +47,24 @@
           <emptyLine />
           <package name="dalvik" withSubpackages="true" static="false" />
           <emptyLine />
-          <package name="libcore" withSubpackages="true" static="false" />
-          <emptyLine />
           <package name="com" withSubpackages="true" static="false" />
           <emptyLine />
+          <package name="dagger" withSubpackages="true" static="false" />
+          <emptyLine />
           <package name="gov" withSubpackages="true" static="false" />
           <emptyLine />
           <package name="junit" withSubpackages="true" static="false" />
           <emptyLine />
+          <package name="libcore" withSubpackages="true" static="false" />
+          <emptyLine />
           <package name="net" withSubpackages="true" static="false" />
           <emptyLine />
+          <package name="kotlin" withSubpackages="true" static="false" />
+          <emptyLine />
           <package name="org" withSubpackages="true" static="false" />
           <emptyLine />
+          <package name="perfetto" withSubpackages="true" static="false" />
+          <emptyLine />
           <package name="java" withSubpackages="true" static="false" />
           <emptyLine />
           <package name="javax" withSubpackages="true" static="false" />
diff --git a/activity/activity-ktx/build.gradle b/activity/activity-ktx/build.gradle
index 652d52d..3a0bba3 100644
--- a/activity/activity-ktx/build.gradle
+++ b/activity/activity-ktx/build.gradle
@@ -24,10 +24,6 @@
 }
 
 dependencies {
-    // Atomic group
-    constraints {
-        implementation(project(":activity:activity"))
-    }
 
     api(project(":activity:activity"))
     api("androidx.core:core-ktx:1.1.0") {
diff --git a/activity/activity/build.gradle b/activity/activity/build.gradle
index d2131b7..42f0332 100644
--- a/activity/activity/build.gradle
+++ b/activity/activity/build.gradle
@@ -14,10 +14,6 @@
 }
 
 dependencies {
-    // Atomic group
-    constraints {
-        implementation(project(":activity:activity-ktx"))
-    }
 
     api("androidx.annotation:annotation:1.1.0")
     implementation("androidx.collection:collection:1.0.0")
diff --git a/appactions/interaction/interaction-capabilities-core/build.gradle b/appactions/interaction/interaction-capabilities-core/build.gradle
index 2fccf45..0e13319 100644
--- a/appactions/interaction/interaction-capabilities-core/build.gradle
+++ b/appactions/interaction/interaction-capabilities-core/build.gradle
@@ -13,7 +13,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-
 import androidx.build.LibraryType
 import androidx.build.Publish
 
@@ -26,13 +25,12 @@
 dependencies {
     api(libs.kotlinStdlib)
     api(libs.autoValueAnnotations)
-    api(libs.kotlinStdlib)
     annotationProcessor(libs.autoValue)
-    implementation(libs.protobufLite)
+
+    implementation(project(":appactions:interaction:interaction-proto"))
     implementation(libs.guavaListenableFuture)
     implementation(libs.kotlinCoroutinesGuava)
     implementation("androidx.concurrent:concurrent-futures:1.1.0")
-    implementation(project(":appactions:interaction:interaction-proto"))
 
     testAnnotationProcessor(libs.autoValue)
     testImplementation(libs.junit)
@@ -68,7 +66,6 @@
 androidx {
     name = "androidx.appactions.interaction:interaction-capabilities-core"
     type = LibraryType.PUBLISHED_LIBRARY
-    publish = Publish.NONE
     inceptionYear = "2023"
     description = "App Interaction library core capabilities API and implementation."
     failOnDeprecationWarnings = false
diff --git a/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/AbstractCapabilityBuilder.java b/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/AbstractCapabilityBuilder.java
deleted file mode 100644
index a37caf6..0000000
--- a/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/AbstractCapabilityBuilder.java
+++ /dev/null
@@ -1,147 +0,0 @@
-/*
- * Copyright 2023 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package androidx.appactions.interaction.capabilities.core;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.appactions.interaction.capabilities.core.impl.SingleTurnCapabilityImpl;
-import androidx.appactions.interaction.capabilities.core.impl.spec.ActionSpec;
-import androidx.appactions.interaction.capabilities.core.task.impl.AbstractTaskUpdater;
-import androidx.appactions.interaction.capabilities.core.task.impl.TaskCapabilityImpl;
-
-import com.google.common.util.concurrent.ListenableFuture;
-
-import java.util.Objects;
-
-/**
- * An abstract Builder class for ActionCapability.
- *
- * @param <BuilderT>
- * @param <PropertyT>
- * @param <ArgumentT>
- * @param <OutputT>
- * @param <ConfirmationT>
- * @param <TaskUpdaterT>
- */
-public abstract class AbstractCapabilityBuilder<
-        BuilderT extends
-                AbstractCapabilityBuilder<
-                                BuilderT,
-                                PropertyT,
-                                ArgumentT,
-                                OutputT,
-                                ConfirmationT,
-                                TaskUpdaterT>,
-        PropertyT,
-        ArgumentT,
-        OutputT,
-        ConfirmationT,
-        TaskUpdaterT extends AbstractTaskUpdater> {
-
-    private final ActionSpec<PropertyT, ArgumentT, OutputT> mActionSpec;
-    @Nullable private String mId;
-    @Nullable private PropertyT mProperty;
-    @Nullable private ActionExecutor<ArgumentT, OutputT> mActionExecutor;
-    @Nullable private TaskHandler<ArgumentT, OutputT, ConfirmationT, TaskUpdaterT> mTaskHandler;
-
-    /**
-     * @param actionSpec
-     */
-    protected AbstractCapabilityBuilder(
-            @NonNull ActionSpec<PropertyT, ArgumentT, OutputT> actionSpec) {
-        this.mActionSpec = actionSpec;
-    }
-
-    @SuppressWarnings("unchecked") // cast to child class
-    private BuilderT asBuilder() {
-        return (BuilderT) this;
-    }
-
-    /**
-     * Sets the Id of the capability being built. The Id should be a non-null string that is unique
-     * among all ActionCapability, and should not change during/across activity lifecycles.
-     */
-    @NonNull
-    public final BuilderT setId(@NonNull String id) {
-        this.mId = id;
-        return asBuilder();
-    }
-
-    /**
-     * Sets the Property instance for this capability. Must be called before {@link
-     * AbstractCapabilityBuilder.build}.
-     */
-    protected final BuilderT setProperty(@NonNull PropertyT property) {
-        this.mProperty = property;
-        return asBuilder();
-    }
-
-    /**
-     * Sets the TaskHandler for this capability. The individual capability factory classes can
-     * decide to expose their own public {@code setTaskHandler} method and invoke this parent
-     * method. Setting the TaskHandler should build a capability instance that supports multi-turn
-     * tasks.
-     */
-    protected final BuilderT setTaskHandler(
-            @NonNull TaskHandler<ArgumentT, OutputT, ConfirmationT, TaskUpdaterT> taskHandler) {
-        this.mTaskHandler = taskHandler;
-        return asBuilder();
-    }
-
-    /** Sets the ActionExecutor for this capability. */
-    @NonNull
-    public final BuilderT setActionExecutor(
-            @NonNull ActionExecutor<ArgumentT, OutputT> actionExecutor) {
-        this.mActionExecutor = actionExecutor;
-        return asBuilder();
-    }
-
-    /** Builds and returns this ActionCapability. */
-    @NonNull
-    public ActionCapability build() {
-        Objects.requireNonNull(mProperty, "property must not be null.");
-        if (mTaskHandler == null) {
-            Objects.requireNonNull(mActionExecutor, "actionExecutor must not be null.");
-            return new SingleTurnCapabilityImpl<PropertyT, ArgumentT, OutputT>(
-                    mId,
-                    mActionSpec,
-                    mProperty,
-                    (hostProperties)->new BaseSession<ArgumentT, OutputT>() {
-                        @Override
-                        public ListenableFuture<ExecutionResult<OutputT>> onFinishAsync(
-                                ArgumentT argument) {
-                            return mActionExecutor.executeAsync(argument);
-                        }
-                    });
-        }
-        TaskCapabilityImpl<PropertyT, ArgumentT, OutputT, ConfirmationT, TaskUpdaterT>
-                taskCapability =
-                        new TaskCapabilityImpl<>(
-                                Objects.requireNonNull(mId, "id field must not be null."),
-                                mActionSpec,
-                                mProperty,
-                                mTaskHandler.getParamsRegistry(),
-                                mTaskHandler.getOnInitListener(),
-                                mTaskHandler.getOnReadyToConfirmListener(),
-                                mTaskHandler.getOnFinishListener(),
-                                mTaskHandler.getConfirmationDataBindings(),
-                                mTaskHandler.getExecutionOutputBindings(),
-                                Runnable::run);
-        taskCapability.setTaskUpdaterSupplier(mTaskHandler.getTaskUpdaterSupplier());
-        return taskCapability;
-    }
-}
diff --git a/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/AbstractTaskHandlerBuilder.java b/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/AbstractTaskHandlerBuilder.java
deleted file mode 100644
index b73802c..0000000
--- a/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/AbstractTaskHandlerBuilder.java
+++ /dev/null
@@ -1,288 +0,0 @@
-/*
- * Copyright 2023 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package androidx.appactions.interaction.capabilities.core;
-
-import static androidx.appactions.interaction.capabilities.core.impl.utils.ImmutableCollectors.toImmutableList;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.appactions.interaction.capabilities.core.impl.converters.DisambigEntityConverter;
-import androidx.appactions.interaction.capabilities.core.impl.converters.ParamValueConverter;
-import androidx.appactions.interaction.capabilities.core.impl.converters.SearchActionConverter;
-import androidx.appactions.interaction.capabilities.core.task.AppEntityListResolver;
-import androidx.appactions.interaction.capabilities.core.task.AppEntityResolver;
-import androidx.appactions.interaction.capabilities.core.task.InvalidTaskException;
-import androidx.appactions.interaction.capabilities.core.task.InventoryListResolver;
-import androidx.appactions.interaction.capabilities.core.task.InventoryResolver;
-import androidx.appactions.interaction.capabilities.core.task.OnDialogFinishListener;
-import androidx.appactions.interaction.capabilities.core.task.OnInitListener;
-import androidx.appactions.interaction.capabilities.core.task.ValueListListener;
-import androidx.appactions.interaction.capabilities.core.task.ValueListener;
-import androidx.appactions.interaction.capabilities.core.task.impl.AbstractTaskUpdater;
-import androidx.appactions.interaction.capabilities.core.task.impl.GenericResolverInternal;
-import androidx.appactions.interaction.capabilities.core.task.impl.OnReadyToConfirmListenerInternal;
-import androidx.appactions.interaction.capabilities.core.task.impl.TaskParamRegistry;
-import androidx.appactions.interaction.proto.ParamValue;
-
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Objects;
-import java.util.Optional;
-import java.util.function.Function;
-import java.util.function.Supplier;
-
-/**
- * An abstract Builder class for an ActionCapability that supports task.
- *
- * @param <BuilderT>
- * @param <ArgumentT>
- * @param <OutputT>
- * @param <ConfirmationT>
- * @param <TaskUpdaterT>
- */
-public abstract class AbstractTaskHandlerBuilder<
-        BuilderT extends
-                AbstractTaskHandlerBuilder<BuilderT, ArgumentT, OutputT, ConfirmationT,
-                        TaskUpdaterT>,
-        ArgumentT,
-        OutputT,
-        ConfirmationT,
-        TaskUpdaterT extends AbstractTaskUpdater> {
-
-    private final ConfirmationType mConfirmationType;
-    private final TaskParamRegistry.Builder mParamsRegistry;
-    private final Map<String, Function<OutputT, List<ParamValue>>> mExecutionOutputBindings =
-            new HashMap<>();
-    private final Map<String, Function<ConfirmationT, List<ParamValue>>> mConfirmationDataBindings =
-            new HashMap<>();
-    @Nullable
-    private OnInitListener<TaskUpdaterT> mOnInitListener;
-    @Nullable
-    private OnReadyToConfirmListenerInternal<ConfirmationT> mOnReadyToConfirmListener;
-    @Nullable
-    private OnDialogFinishListener<ArgumentT, OutputT> mOnFinishListener;
-
-    protected AbstractTaskHandlerBuilder() {
-        this(ConfirmationType.NOT_SUPPORTED);
-    }
-
-    protected AbstractTaskHandlerBuilder(@NonNull ConfirmationType confirmationType) {
-        this.mConfirmationType = confirmationType;
-        this.mParamsRegistry = TaskParamRegistry.builder();
-    }
-
-    @SuppressWarnings("unchecked") // cast to child class
-    protected BuilderT asBuilder() {
-        return (BuilderT) this;
-    }
-
-    /** Sets the OnInitListener for this capability. */
-    public final BuilderT setOnInitListener(@NonNull OnInitListener<TaskUpdaterT> onInitListener) {
-        this.mOnInitListener = onInitListener;
-        return asBuilder();
-    }
-
-    /** Sets the onReadyToConfirmListener for this capability. */
-    protected final BuilderT setOnReadyToConfirmListenerInternal(
-            @NonNull OnReadyToConfirmListenerInternal<ConfirmationT> onReadyToConfirm) {
-        this.mOnReadyToConfirmListener = onReadyToConfirm;
-        return asBuilder();
-    }
-
-    /** Sets the onFinishListener for this capability. */
-    public final BuilderT setOnFinishListener(
-            @NonNull OnDialogFinishListener<ArgumentT, OutputT> onFinishListener) {
-        this.mOnFinishListener = onFinishListener;
-        return asBuilder();
-    }
-
-    protected <ValueTypeT> void registerInventoryTaskParam(
-            @NonNull String paramName,
-            @NonNull InventoryResolver<ValueTypeT> listener,
-            @NonNull ParamValueConverter<ValueTypeT> converter) {
-        mParamsRegistry.addTaskParameter(
-                paramName,
-                (paramValue) -> !paramValue.hasIdentifier(),
-                GenericResolverInternal.fromInventoryResolver(listener),
-                Optional.empty(),
-                Optional.empty(),
-                converter);
-    }
-
-    protected <ValueTypeT> void registerInventoryListTaskParam(
-            @NonNull String paramName,
-            @NonNull InventoryListResolver<ValueTypeT> listener,
-            @NonNull ParamValueConverter<ValueTypeT> converter) {
-        mParamsRegistry.addTaskParameter(
-                paramName,
-                (paramValue) -> !paramValue.hasIdentifier(),
-                GenericResolverInternal.fromInventoryListResolver(listener),
-                Optional.empty(),
-                Optional.empty(),
-                converter);
-    }
-
-    protected <ValueTypeT> void registerAppEntityTaskParam(
-            @NonNull String paramName,
-            @NonNull AppEntityResolver<ValueTypeT> listener,
-            @NonNull ParamValueConverter<ValueTypeT> converter,
-            @NonNull DisambigEntityConverter<ValueTypeT> entityConverter,
-            @NonNull SearchActionConverter<ValueTypeT> searchActionConverter) {
-        mParamsRegistry.addTaskParameter(
-                paramName,
-                (paramValue) -> !paramValue.hasIdentifier(),
-                GenericResolverInternal.fromAppEntityResolver(listener),
-                Optional.of(entityConverter),
-                Optional.of(searchActionConverter),
-                converter);
-    }
-
-    protected <ValueTypeT> void registerAppEntityListTaskParam(
-            @NonNull String paramName,
-            @NonNull AppEntityListResolver<ValueTypeT> listener,
-            @NonNull ParamValueConverter<ValueTypeT> converter,
-            @NonNull DisambigEntityConverter<ValueTypeT> entityConverter,
-            @NonNull SearchActionConverter<ValueTypeT> searchActionConverter) {
-        mParamsRegistry.addTaskParameter(
-                paramName,
-                (paramValue) -> !paramValue.hasIdentifier(),
-                GenericResolverInternal.fromAppEntityListResolver(listener),
-                Optional.of(entityConverter),
-                Optional.of(searchActionConverter),
-                converter);
-    }
-
-    protected <ValueTypeT> void registerValueTaskParam(
-            @NonNull String paramName,
-            @NonNull ValueListener<ValueTypeT> listener,
-            @NonNull ParamValueConverter<ValueTypeT> converter) {
-        mParamsRegistry.addTaskParameter(
-                paramName,
-                unused -> false,
-                GenericResolverInternal.fromValueListener(listener),
-                Optional.empty(),
-                Optional.empty(),
-                converter);
-    }
-
-    protected <ValueTypeT> void registerValueListTaskParam(
-            @NonNull String paramName,
-            @NonNull ValueListListener<ValueTypeT> listener,
-            @NonNull ParamValueConverter<ValueTypeT> converter) {
-        mParamsRegistry.addTaskParameter(
-                paramName,
-                unused -> false,
-                GenericResolverInternal.fromValueListListener(listener),
-                Optional.empty(),
-                Optional.empty(),
-                converter);
-    }
-
-    /**
-     * Registers an optional execution output.
-     *
-     * @param paramName    the BII output slot name of this parameter.
-     * @param outputGetter a getter of the output from the {@code OutputT} instance.
-     * @param converter    a converter from an output object to a ParamValue.
-     */
-    protected <T> void registerExecutionOutput(
-            @NonNull String paramName,
-            @NonNull Function<OutputT, Optional<T>> outputGetter,
-            @NonNull Function<T, ParamValue> converter) {
-        mExecutionOutputBindings.put(
-                paramName,
-                output -> outputGetter.apply(output).stream().map(converter).collect(
-                        toImmutableList()));
-    }
-
-    /**
-     * Registers a repeated execution output.
-     *
-     * @param paramName    the BII output slot name of this parameter.
-     * @param outputGetter a getter of the output from the {@code OutputT} instance.
-     * @param converter    a converter from an output object to a ParamValue.
-     */
-    protected <T> void registerRepeatedExecutionOutput(
-            @NonNull String paramName,
-            @NonNull Function<OutputT, List<T>> outputGetter,
-            @NonNull Function<T, ParamValue> converter) {
-        mExecutionOutputBindings.put(
-                paramName,
-                output -> outputGetter.apply(output).stream().map(converter).collect(
-                        toImmutableList()));
-    }
-
-    /**
-     * Registers an optional confirmation data.
-     *
-     * @param paramName          the BIC confirmation data slot name of this parameter.
-     * @param confirmationGetter a getter of the confirmation data from the {@code ConfirmationT}
-     *                           instance.
-     * @param converter          a converter from confirmation data to a ParamValue.
-     */
-    protected <T> void registerConfirmationOutput(
-            @NonNull String paramName,
-            @NonNull Function<ConfirmationT, Optional<T>> confirmationGetter,
-            @NonNull Function<T, ParamValue> converter) {
-        mConfirmationDataBindings.put(
-                paramName,
-                output ->
-                        confirmationGetter.apply(output).stream().map(converter).collect(
-                                toImmutableList()));
-    }
-
-    /** Specific capability builders override this to support BII-specific TaskUpdaters. */
-    @NonNull
-    protected abstract Supplier<TaskUpdaterT> getTaskUpdaterSupplier();
-
-    /**
-     * Build a TaskHandler.
-     */
-    @NonNull
-    public TaskHandler<ArgumentT, OutputT, ConfirmationT, TaskUpdaterT> build() {
-        if (this.mConfirmationType == ConfirmationType.REQUIRED
-                && mOnReadyToConfirmListener == null) {
-            throw new InvalidTaskException(
-                    "ConfirmationType is REQUIRED, but onReadyToConfirmListener is not set.");
-        }
-        if (this.mConfirmationType == ConfirmationType.NOT_SUPPORTED
-                && mOnReadyToConfirmListener != null) {
-            throw new InvalidTaskException(
-                    "ConfirmationType is NOT_SUPPORTED, but onReadyToConfirmListener is set.");
-        }
-        return new TaskHandler<>(
-                mConfirmationType,
-                mParamsRegistry.build(),
-                Optional.ofNullable(mOnInitListener),
-                Optional.ofNullable(mOnReadyToConfirmListener),
-                Objects.requireNonNull(mOnFinishListener, "onTaskFinishListener must not be null."),
-                mConfirmationDataBindings,
-                mExecutionOutputBindings,
-                getTaskUpdaterSupplier());
-    }
-
-    /** Confirmation types for a Capability. */
-    protected enum ConfirmationType {
-        // Confirmation is not supported for this Capability.
-        NOT_SUPPORTED,
-        // This Capability requires confirmation.
-        REQUIRED,
-        // Confirmation is optional for this Capability.
-        OPTIONAL
-    }
-}
diff --git a/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/ActionExecutor.kt b/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/ActionExecutor.kt
index 960b51f..60f0faf 100644
--- a/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/ActionExecutor.kt
+++ b/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/ActionExecutor.kt
@@ -21,9 +21,6 @@
 
 /**
  * An interface of executing the action.
- *
- * @param <ArgumentT>
- * @param <OutputT>
  */
 interface ActionExecutor<ArgumentT, OutputT> {
     /**
diff --git a/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/CapabilityBuilderBase.kt b/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/CapabilityBuilderBase.kt
new file mode 100644
index 0000000..8f30a4f
--- /dev/null
+++ b/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/CapabilityBuilderBase.kt
@@ -0,0 +1,138 @@
+/*
+ * 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.appactions.interaction.capabilities.core
+
+import androidx.appactions.interaction.capabilities.core.impl.SingleTurnCapabilityImpl
+import androidx.appactions.interaction.capabilities.core.impl.spec.ActionSpec
+import androidx.appactions.interaction.capabilities.core.task.impl.AbstractTaskUpdater
+import androidx.appactions.interaction.capabilities.core.task.impl.TaskCapabilityImpl
+import androidx.appactions.interaction.capabilities.core.task.impl.SessionBridge
+import com.google.common.util.concurrent.ListenableFuture
+import java.util.function.Supplier
+import androidx.annotation.RestrictTo
+
+/**
+ * An abstract Builder class for ActionCapability.
+ */
+abstract class CapabilityBuilderBase<
+    BuilderT :
+    CapabilityBuilderBase<
+        BuilderT,
+        PropertyT,
+        ArgumentT,
+        OutputT,
+        ConfirmationT,
+        SessionUpdaterT,
+        SessionT,>,
+    PropertyT,
+    ArgumentT,
+    OutputT,
+    ConfirmationT,
+    SessionUpdaterT : AbstractTaskUpdater,
+    SessionT : BaseSession<ArgumentT, OutputT>,
+    > protected constructor(
+    private val actionSpec: ActionSpec<PropertyT, ArgumentT, OutputT>,
+) {
+    private var id: String? = null
+    private var property: PropertyT? = null
+    private var actionExecutor: ActionExecutor<ArgumentT, OutputT>? = null
+    private var sessionBuilder: SessionBuilder<SessionT>? = null
+    /**
+     * The SessionBridge object, which is used to normalize Session instances to TaskHandler.
+     * see SessionBridge documentation for more information.
+     *
+     * @suppress
+     */
+    @get:RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+    protected open val sessionBridge: SessionBridge<SessionT, ConfirmationT>? = null
+    /** The supplier of SessionUpdaterT instances. */
+    protected open val sessionUpdaterSupplier: Supplier<SessionUpdaterT>? = null
+
+    @Suppress("UNCHECKED_CAST")
+    fun asBuilder(): BuilderT {
+        return this as BuilderT
+    }
+
+    /**
+     * Sets the Id of the capability being built. The Id should be a non-null string that is unique
+     * among all ActionCapability, and should not change during/across activity lifecycles.
+     */
+    fun setId(id: String): BuilderT = asBuilder().apply {
+        this.id = id
+    }
+
+    /**
+     * Sets the Property instance for this capability. Must be called before {@link
+     * CapabilityBuilderBase.build}.
+     */
+    protected fun setProperty(property: PropertyT) = asBuilder().apply {
+        this.property = property
+    }
+
+    /**
+     * Sets the ActionExecutor for this capability.
+     *
+     * setSessionBuilder and setActionExecutor are mutually exclusive, so calling one will nullify the other.
+     */
+    fun setActionExecutor(actionExecutor: ActionExecutor<ArgumentT, OutputT>) = asBuilder().apply {
+        this.actionExecutor = actionExecutor
+    }
+
+    /**
+     * Sets the SessionBuilder instance which is used to create Session instaces for this
+     * capability.
+     *
+     * setSessionBuilder and setActionExecutor are mutually exclusive, so calling one will nullify the other.
+     */
+    protected open fun setSessionBuilder(
+        sessionBuilder: SessionBuilder<SessionT>,
+    ): BuilderT = asBuilder().apply {
+        this.sessionBuilder = sessionBuilder
+    }
+
+    /** Builds and returns this ActionCapability. */
+    open fun build(): ActionCapability {
+        val checkedProperty = requireNotNull(property, { "property must not be null." })
+        if (actionExecutor != null) {
+            return SingleTurnCapabilityImpl(
+                id,
+                actionSpec,
+                checkedProperty,
+                {
+                    object : BaseSession<ArgumentT, OutputT> {
+                        override fun onFinishAsync(
+                            argument: ArgumentT,
+                        ): ListenableFuture<ExecutionResult<OutputT>> {
+                            return actionExecutor!!.executeAsync(argument!!)
+                        } }
+                },
+            )
+        } else {
+            return TaskCapabilityImpl(
+                id,
+                actionSpec,
+                checkedProperty,
+                requireNotNull(
+                    sessionBuilder,
+                    { "either setActionExecutor or setSessionBuilder must be called before build" },
+                ),
+                sessionBridge!!,
+                sessionUpdaterSupplier!!,
+            )
+        }
+    }
+}
diff --git a/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/ConfirmationOutput.kt b/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/ConfirmationOutput.kt
index acbdcc1..e6c907d 100644
--- a/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/ConfirmationOutput.kt
+++ b/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/ConfirmationOutput.kt
@@ -21,8 +21,6 @@
 /**
  * Class that represents the response after all slots are filled and accepted and the task is ready
  * to enter the confirmation turn.
- *
- * @param <ConfirmationT>
  */
 class ConfirmationOutput<ConfirmationT> internal constructor(val confirmation: ConfirmationT?) {
     override fun toString() =
@@ -36,8 +34,6 @@
 
     /**
      * Builder for ConfirmationOutput.
-     *
-     * @param <ConfirmationT>
      */
     class Builder<ConfirmationT> {
         private var confirmation: ConfirmationT? = null
diff --git a/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/ExecutionResult.kt b/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/ExecutionResult.kt
index 1234fe0..27675d5 100644
--- a/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/ExecutionResult.kt
+++ b/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/ExecutionResult.kt
@@ -19,8 +19,6 @@
 import java.util.Objects
 /**
  * Class that represents the response after an ActionCapability fulfills an action.
- *
- * @param <OutputT>
  */
 class ExecutionResult<OutputT> internal constructor(
     val startDictation: Boolean,
@@ -37,8 +35,6 @@
 
     /**
      * Builder for ExecutionResult.
-     *
-     * @param <OutputT>
      */
     class Builder<OutputT> {
         private var startDictation: Boolean = false
diff --git a/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/SessionBuilder.kt b/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/SessionBuilder.kt
index 7aa2a29..7e8ec2c 100644
--- a/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/SessionBuilder.kt
+++ b/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/SessionBuilder.kt
@@ -21,9 +21,14 @@
  */
 fun interface SessionBuilder<SessionT> {
     /**
+     * Implement this method to create session for handling assistant requests.\
+     *
+     * @param hostProperties only applicable while used with AppInteractionService. Contains the
+     *   dimensions of the UI area. Null when used without AppInteractionService.
+     *
      * @return A new SessionT instance for handling a task.
      */
     fun createSession(
-        hostProperties: HostProperties,
+        hostProperties: HostProperties?,
     ): SessionT
 }
diff --git a/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/TaskHandler.java b/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/TaskHandler.java
deleted file mode 100644
index 1341b36..0000000
--- a/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/TaskHandler.java
+++ /dev/null
@@ -1,105 +0,0 @@
-/*
- * Copyright 2023 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package androidx.appactions.interaction.capabilities.core;
-
-import androidx.annotation.NonNull;
-import androidx.appactions.interaction.capabilities.core.AbstractTaskHandlerBuilder.ConfirmationType;
-import androidx.appactions.interaction.capabilities.core.task.OnDialogFinishListener;
-import androidx.appactions.interaction.capabilities.core.task.OnInitListener;
-import androidx.appactions.interaction.capabilities.core.task.impl.AbstractTaskUpdater;
-import androidx.appactions.interaction.capabilities.core.task.impl.OnReadyToConfirmListenerInternal;
-import androidx.appactions.interaction.capabilities.core.task.impl.TaskParamRegistry;
-import androidx.appactions.interaction.proto.ParamValue;
-
-import java.util.List;
-import java.util.Map;
-import java.util.Optional;
-import java.util.function.Function;
-import java.util.function.Supplier;
-
-/**
- * Temporary holder for Task related data.
- *
- * @param <ArgumentT>
- * @param <OutputT>
- * @param <ConfirmationT>
- * @param <TaskUpdaterT>
- */
-public final class TaskHandler<
-        ArgumentT, OutputT, ConfirmationT, TaskUpdaterT extends AbstractTaskUpdater> {
-
-    private final ConfirmationType mConfirmationType;
-    private final TaskParamRegistry mParamsRegistry;
-    private final Map<String, Function<OutputT, List<ParamValue>>> mExecutionOutputBindings;
-    private final Map<String, Function<ConfirmationT, List<ParamValue>>> mConfirmationDataBindings;
-    private final Optional<OnInitListener<TaskUpdaterT>> mOnInitListener;
-    private final Optional<OnReadyToConfirmListenerInternal<ConfirmationT>>
-            mOnReadyToConfirmListener;
-    private final OnDialogFinishListener<ArgumentT, OutputT> mOnFinishListener;
-    private final Supplier<TaskUpdaterT> mTaskUpdaterSupplier;
-
-    TaskHandler(
-            @NonNull ConfirmationType confirmationType,
-            @NonNull TaskParamRegistry paramsRegistry,
-            @NonNull Optional<OnInitListener<TaskUpdaterT>> onInitListener,
-            @NonNull Optional<OnReadyToConfirmListenerInternal<ConfirmationT>> onReadyToConfirmListener,
-            @NonNull OnDialogFinishListener<ArgumentT, OutputT> onFinishListener,
-            @NonNull Map<String, Function<ConfirmationT, List<ParamValue>>> confirmationDataBindings,
-            @NonNull Map<String, Function<OutputT, List<ParamValue>>> executionOutputBindings,
-            @NonNull Supplier<TaskUpdaterT> taskUpdaterSupplier) {
-        this.mConfirmationType = confirmationType;
-        this.mParamsRegistry = paramsRegistry;
-        this.mOnInitListener = onInitListener;
-        this.mOnReadyToConfirmListener = onReadyToConfirmListener;
-        this.mOnFinishListener = onFinishListener;
-        this.mConfirmationDataBindings = confirmationDataBindings;
-        this.mExecutionOutputBindings = executionOutputBindings;
-        this.mTaskUpdaterSupplier = taskUpdaterSupplier;
-    }
-
-    ConfirmationType getConfirmationType() {
-        return mConfirmationType;
-    }
-
-    TaskParamRegistry getParamsRegistry() {
-        return mParamsRegistry;
-    }
-
-    Map<String, Function<OutputT, List<ParamValue>>> getExecutionOutputBindings() {
-        return mExecutionOutputBindings;
-    }
-
-    Map<String, Function<ConfirmationT, List<ParamValue>>> getConfirmationDataBindings() {
-        return mConfirmationDataBindings;
-    }
-
-    Optional<OnInitListener<TaskUpdaterT>> getOnInitListener() {
-        return mOnInitListener;
-    }
-
-    Optional<OnReadyToConfirmListenerInternal<ConfirmationT>> getOnReadyToConfirmListener() {
-        return mOnReadyToConfirmListener;
-    }
-
-    OnDialogFinishListener<ArgumentT, OutputT> getOnFinishListener() {
-        return mOnFinishListener;
-    }
-
-    Supplier<TaskUpdaterT> getTaskUpdaterSupplier() {
-        return mTaskUpdaterSupplier;
-    }
-}
diff --git a/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/impl/ActionCapabilitySession.kt b/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/impl/ActionCapabilitySession.kt
index 0a9fb0e..c479b33a 100644
--- a/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/impl/ActionCapabilitySession.kt
+++ b/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/impl/ActionCapabilitySession.kt
@@ -17,6 +17,7 @@
 package androidx.appactions.interaction.capabilities.core.impl
 
 import androidx.annotation.RestrictTo
+import androidx.appactions.interaction.proto.AppActionsContext.AppAction
 
 /**
  * Internal interface for a session, contains developer's Session instance
@@ -35,4 +36,13 @@
         argumentsWrapper: ArgumentsWrapper,
         callback: CallbackInternal,
     )
+
+    /**
+     * Support for manual input. This method should be invoked by AppInteraction SDKs
+     * (background/foreground), so the developers have a way to report state updates back to
+     * Assistant.
+     */
+    fun setTouchEventCallback(callback: TouchEventCallback)
+
+    val state: AppAction
 }
diff --git a/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/impl/SingleTurnCapabilityImpl.kt b/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/impl/SingleTurnCapabilityImpl.kt
index 76ed7b5..8029ed6 100644
--- a/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/impl/SingleTurnCapabilityImpl.kt
+++ b/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/impl/SingleTurnCapabilityImpl.kt
@@ -16,7 +16,6 @@
 
 package androidx.appactions.interaction.capabilities.core.impl
 
-import androidx.annotation.NonNull
 import androidx.appactions.interaction.capabilities.core.ActionCapability
 import androidx.appactions.interaction.capabilities.core.BaseSession
 import androidx.appactions.interaction.capabilities.core.HostProperties
@@ -41,7 +40,6 @@
 ) : ActionCapability {
     override val supportsMultiTurnTask = false
 
-    @NonNull
     override fun getAppAction(): AppAction {
         val appActionBuilder = actionSpec.convertPropertyToProto(property).toBuilder()
             .setTaskInfo(TaskInfo.newBuilder().setSupportsPartialFulfillment(false))
@@ -49,7 +47,6 @@
         return appActionBuilder.build()
     }
 
-    @NonNull
     override fun createSession(hostProperties: HostProperties): ActionCapabilitySession {
         return SingleTurnCapabilitySession(
             actionSpec,
diff --git a/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/impl/SingleTurnCapabilitySession.kt b/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/impl/SingleTurnCapabilitySession.kt
index 1b8bcf5..9140c97 100644
--- a/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/impl/SingleTurnCapabilitySession.kt
+++ b/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/impl/SingleTurnCapabilitySession.kt
@@ -18,17 +18,17 @@
 package androidx.appactions.interaction.capabilities.core.impl
 
 import androidx.annotation.NonNull
-import androidx.appactions.interaction.capabilities.core.ExecutionResult
+import androidx.annotation.RestrictTo
 import androidx.appactions.interaction.capabilities.core.BaseSession
+import androidx.appactions.interaction.capabilities.core.ExecutionResult
 import androidx.appactions.interaction.capabilities.core.impl.concurrent.FutureCallback
 import androidx.appactions.interaction.capabilities.core.impl.concurrent.Futures
 import androidx.appactions.interaction.capabilities.core.impl.spec.ActionSpec
+import androidx.appactions.interaction.proto.AppActionsContext.AppAction
 import androidx.appactions.interaction.proto.FulfillmentRequest.Fulfillment.FulfillmentValue
 import androidx.appactions.interaction.proto.FulfillmentResponse
 import androidx.appactions.interaction.proto.ParamValue
 
-import androidx.annotation.RestrictTo
-
 /**
  * ActionCapabilitySession implementation for executing single-turn fulfillment requests.
  *
@@ -38,10 +38,21 @@
 internal class SingleTurnCapabilitySession<
     ArgumentT,
     OutputT,
->(
+    >(
     val actionSpec: ActionSpec<*, ArgumentT, OutputT>,
     val externalSession: BaseSession<ArgumentT, OutputT>,
 ) : ActionCapabilitySession {
+    // single-turn capability does not have state
+    override val state: AppAction
+        get() {
+            throw UnsupportedOperationException()
+        }
+
+    // single-turn capability does not have touch events
+    override fun setTouchEventCallback(callback: TouchEventCallback) {
+        throw UnsupportedOperationException()
+    }
+
     override fun execute(
         @NonNull argumentsWrapper: ArgumentsWrapper,
         @NonNull callback: CallbackInternal,
@@ -87,4 +98,4 @@
         }
         return fulfillmentResponseBuilder.build()
     }
-}
\ No newline at end of file
+}
diff --git a/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/impl/converters/FieldBinding.java b/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/impl/converters/FieldBinding.java
index 048f5bb..04a8afd 100644
--- a/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/impl/converters/FieldBinding.java
+++ b/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/impl/converters/FieldBinding.java
@@ -17,9 +17,9 @@
 package androidx.appactions.interaction.capabilities.core.impl.converters;
 
 import androidx.appactions.interaction.capabilities.core.impl.BuilderOf;
+import androidx.appactions.interaction.protobuf.Value;
 
 import com.google.auto.value.AutoValue;
-import com.google.protobuf.Value;
 
 import java.util.Optional;
 import java.util.function.Function;
diff --git a/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/impl/converters/TypeConverters.java b/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/impl/converters/TypeConverters.java
index 14c48d5..3b610ad 100644
--- a/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/impl/converters/TypeConverters.java
+++ b/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/impl/converters/TypeConverters.java
@@ -40,10 +40,9 @@
 import androidx.appactions.interaction.proto.Entity;
 import androidx.appactions.interaction.proto.FulfillmentResponse;
 import androidx.appactions.interaction.proto.ParamValue;
-
-import com.google.protobuf.ListValue;
-import com.google.protobuf.Struct;
-import com.google.protobuf.Value;
+import androidx.appactions.interaction.protobuf.ListValue;
+import androidx.appactions.interaction.protobuf.Struct;
+import androidx.appactions.interaction.protobuf.Value;
 
 import java.time.Duration;
 import java.time.LocalDate;
diff --git a/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/impl/converters/TypeSpec.java b/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/impl/converters/TypeSpec.java
index 6781668..11181a6 100644
--- a/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/impl/converters/TypeSpec.java
+++ b/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/impl/converters/TypeSpec.java
@@ -18,8 +18,7 @@
 
 import androidx.annotation.NonNull;
 import androidx.appactions.interaction.capabilities.core.impl.exceptions.StructConversionException;
-
-import com.google.protobuf.Struct;
+import androidx.appactions.interaction.protobuf.Struct;
 
 /**
  * TypeSpec is used to convert between java objects in capabilities/values and Struct proto.
diff --git a/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/impl/converters/TypeSpecBuilder.java b/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/impl/converters/TypeSpecBuilder.java
index 38bab1f..46aa2d1 100644
--- a/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/impl/converters/TypeSpecBuilder.java
+++ b/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/impl/converters/TypeSpecBuilder.java
@@ -21,10 +21,9 @@
 import androidx.appactions.interaction.capabilities.core.impl.BuilderOf;
 import androidx.appactions.interaction.capabilities.core.impl.exceptions.StructConversionException;
 import androidx.appactions.interaction.capabilities.core.values.Thing;
-
-import com.google.protobuf.ListValue;
-import com.google.protobuf.Struct;
-import com.google.protobuf.Value;
+import androidx.appactions.interaction.protobuf.ListValue;
+import androidx.appactions.interaction.protobuf.Struct;
+import androidx.appactions.interaction.protobuf.Value;
 
 import java.time.Duration;
 import java.time.OffsetDateTime;
diff --git a/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/impl/converters/TypeSpecImpl.java b/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/impl/converters/TypeSpecImpl.java
index 13d36ed..25ac2fc 100644
--- a/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/impl/converters/TypeSpecImpl.java
+++ b/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/impl/converters/TypeSpecImpl.java
@@ -19,9 +19,8 @@
 import androidx.annotation.NonNull;
 import androidx.appactions.interaction.capabilities.core.impl.BuilderOf;
 import androidx.appactions.interaction.capabilities.core.impl.exceptions.StructConversionException;
-
-import com.google.protobuf.Struct;
-import com.google.protobuf.Value;
+import androidx.appactions.interaction.protobuf.Struct;
+import androidx.appactions.interaction.protobuf.Value;
 
 import java.util.Collections;
 import java.util.List;
diff --git a/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/task/impl/ManualInputUpdateRequest.java b/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/task/impl/ManualInputUpdateRequest.java
deleted file mode 100644
index a869f9b..0000000
--- a/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/task/impl/ManualInputUpdateRequest.java
+++ /dev/null
@@ -1,51 +0,0 @@
-/*
- * Copyright 2023 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package androidx.appactions.interaction.capabilities.core.task.impl;
-
-import androidx.appactions.interaction.proto.ParamValue;
-
-import com.google.auto.value.AutoValue;
-
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-
-/** Represents a fulfillment request coming from user tap. */
-@SuppressWarnings("AutoValueImmutableFields")
-@AutoValue
-abstract class TouchEventUpdateRequest {
-
-    static TouchEventUpdateRequest create(Map<String, List<ParamValue>> paramValuesMap) {
-        return new AutoValue_TouchEventUpdateRequest(paramValuesMap);
-    }
-
-    /**
-     * merge two TouchEventUpdateRequest instances. Map entries in newRequest will take priority in
-     * case of conflict.
-     */
-    static TouchEventUpdateRequest merge(
-            TouchEventUpdateRequest oldRequest, TouchEventUpdateRequest newRequest) {
-        Map<String, List<ParamValue>> mergedParamValuesMap = new HashMap<>(
-                oldRequest.paramValuesMap());
-        mergedParamValuesMap.putAll(newRequest.paramValuesMap());
-        return TouchEventUpdateRequest.create(Collections.unmodifiableMap(mergedParamValuesMap));
-    }
-
-    /* the param values from manual input. */
-    abstract Map<String, List<ParamValue>> paramValuesMap();
-}
diff --git a/appactions/interaction/interaction-service/src/main/java/androidx/appactions/interaction/service/proto/package-info.java b/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/task/impl/SessionBridge.kt
similarity index 61%
copy from appactions/interaction/interaction-service/src/main/java/androidx/appactions/interaction/service/proto/package-info.java
copy to appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/task/impl/SessionBridge.kt
index 93db9d1..8a9ce6a 100644
--- a/appactions/interaction/interaction-service/src/main/java/androidx/appactions/interaction/service/proto/package-info.java
+++ b/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/task/impl/SessionBridge.kt
@@ -14,8 +14,19 @@
  * limitations under the License.
  */
 
-/** @hide */
-@RestrictTo(RestrictTo.Scope.LIBRARY)
-package androidx.appactions.interaction.service.proto;
+package androidx.appactions.interaction.capabilities.core.task.impl
 
-import androidx.annotation.RestrictTo;
+import androidx.annotation.RestrictTo
+
+/**
+ * converts an external Session into TaskHandler instance.
+ *
+ * @hide
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+fun interface SessionBridge<
+    SessionT,
+    ConfirmationT
+> {
+    fun createTaskHandler(externalSession: SessionT): TaskHandler<ConfirmationT>
+}
diff --git a/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/task/impl/TaskCapabilityImpl.java b/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/task/impl/TaskCapabilityImpl.java
deleted file mode 100644
index c3e46f2..0000000
--- a/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/task/impl/TaskCapabilityImpl.java
+++ /dev/null
@@ -1,230 +0,0 @@
-/*
- * Copyright 2023 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package androidx.appactions.interaction.capabilities.core.task.impl;
-
-import androidx.annotation.GuardedBy;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.annotation.RestrictTo;
-import androidx.appactions.interaction.capabilities.core.ActionCapability;
-import androidx.appactions.interaction.capabilities.core.impl.ArgumentsWrapper;
-import androidx.appactions.interaction.capabilities.core.impl.CallbackInternal;
-import androidx.appactions.interaction.capabilities.core.impl.ErrorStatusInternal;
-import androidx.appactions.interaction.capabilities.core.impl.TouchEventCallback;
-import androidx.appactions.interaction.capabilities.core.impl.concurrent.FutureCallback;
-import androidx.appactions.interaction.capabilities.core.impl.concurrent.Futures;
-import androidx.appactions.interaction.capabilities.core.impl.spec.ActionSpec;
-import androidx.appactions.interaction.capabilities.core.task.OnDialogFinishListener;
-import androidx.appactions.interaction.capabilities.core.task.OnInitListener;
-import androidx.appactions.interaction.proto.AppActionsContext.AppAction;
-import androidx.appactions.interaction.proto.ParamValue;
-
-import java.util.List;
-import java.util.Map;
-import java.util.Optional;
-import java.util.concurrent.Executor;
-import java.util.function.Function;
-import java.util.function.Supplier;
-
-/**
- * Stateful horizontal task orchestrator to manage business logic for the task.
- *
- * @param <PropertyT>
- * @param <ArgumentT>
- * @param <OutputT>
- * @param <ConfirmationT>
- * @param <TaskUpdaterT>
- * @hide
- */
-@RestrictTo(RestrictTo.Scope.LIBRARY)
-public final class TaskCapabilityImpl<
-                PropertyT,
-                ArgumentT,
-                OutputT,
-                ConfirmationT,
-                TaskUpdaterT extends AbstractTaskUpdater>
-        implements ActionCapability, TaskUpdateHandler {
-
-    private final String mIdentifier;
-    private final Executor mExecutor;
-    private final TaskOrchestrator<PropertyT, ArgumentT, OutputT, ConfirmationT, TaskUpdaterT>
-            mTaskOrchestrator;
-
-    private final Object mAssistantUpdateLock = new Object();
-
-    @GuardedBy("mAssistantUpdateLock")
-    @Nullable
-    private AssistantUpdateRequest mPendingAssistantRequest = null;
-
-    @GuardedBy("mAssistantUpdateLock")
-    @Nullable
-    private TouchEventUpdateRequest mPendingTouchEventRequest = null;
-
-    public TaskCapabilityImpl(
-            @NonNull String identifier,
-            @NonNull ActionSpec<PropertyT, ArgumentT, OutputT> actionSpec,
-            PropertyT property,
-            @NonNull TaskParamRegistry paramRegistry,
-            @NonNull Optional<OnInitListener<TaskUpdaterT>> onInitListener,
-            @NonNull
-                    Optional<OnReadyToConfirmListenerInternal<ConfirmationT>>
-                            onReadyToConfirmListener,
-            @NonNull OnDialogFinishListener<ArgumentT, OutputT> onFinishListener,
-            @NonNull
-                    Map<String, Function<ConfirmationT, List<ParamValue>>>
-                            confirmationOutputBindings,
-            @NonNull Map<String, Function<OutputT, List<ParamValue>>> executionOutputBindings,
-            @NonNull Executor executor) {
-        this.mIdentifier = identifier;
-        this.mExecutor = executor;
-        this.mTaskOrchestrator =
-                new TaskOrchestrator<>(
-                        identifier,
-                        actionSpec,
-                        property,
-                        paramRegistry,
-                        onInitListener,
-                        onReadyToConfirmListener,
-                        onFinishListener,
-                        confirmationOutputBindings,
-                        executionOutputBindings,
-                        executor);
-    }
-
-    @NonNull
-    @Override
-    public String getId() {
-        return mIdentifier;
-    }
-
-    @Override
-    public boolean getSupportsMultiTurnTask() {
-        return true;
-    }
-
-    public void setTaskUpdaterSupplier(@NonNull Supplier<TaskUpdaterT> taskUpdaterSupplier) {
-        this.mTaskOrchestrator.setTaskUpdaterSupplier(
-                () -> {
-                    TaskUpdaterT taskUpdater = taskUpdaterSupplier.get();
-                    taskUpdater.init(this);
-                    return taskUpdater;
-                });
-    }
-
-    @Override
-    public void setTouchEventCallback(@NonNull TouchEventCallback touchEventCallback) {
-        mTaskOrchestrator.setTouchEventCallback(touchEventCallback);
-    }
-
-    @NonNull
-    @Override
-    public AppAction getAppAction() {
-        return this.mTaskOrchestrator.getAppAction();
-    }
-
-    /**
-     * If there is a pendingAssistantRequest, we will overwrite that request (and send CANCELLED
-     * response to that request).
-     *
-     * <p>This is done because assistant requests contain the full state, so we can safely ignore
-     * existing requests if a new one arrives.
-     */
-    private void enqueueAssistantRequest(@NonNull AssistantUpdateRequest request) {
-        synchronized (mAssistantUpdateLock) {
-            if (mPendingAssistantRequest != null) {
-                mPendingAssistantRequest.callbackInternal().onError(ErrorStatusInternal.CANCELLED);
-            }
-            mPendingAssistantRequest = request;
-            dispatchPendingRequestIfIdle();
-        }
-    }
-
-    private void enqueueTouchEventRequest(@NonNull TouchEventUpdateRequest request) {
-        synchronized (mAssistantUpdateLock) {
-            if (mPendingTouchEventRequest == null) {
-                mPendingTouchEventRequest = request;
-            } else {
-                mPendingTouchEventRequest =
-                        TouchEventUpdateRequest.merge(mPendingTouchEventRequest, request);
-            }
-            dispatchPendingRequestIfIdle();
-        }
-    }
-
-    /**
-     * If taskOrchestrator is idle, select the next request to dispatch to taskOrchestrator (if
-     * there are any pending requests).
-     *
-     * <p>If taskOrchestrator is not idle, do nothing, since this method will automatically be
-     * called when the current request finishes.
-     */
-    void dispatchPendingRequestIfIdle() {
-        synchronized (mAssistantUpdateLock) {
-            if (!mTaskOrchestrator.isIdle()) {
-                return;
-            }
-            UpdateRequest nextRequest = null;
-            if (mPendingAssistantRequest != null) {
-                nextRequest = UpdateRequest.of(mPendingAssistantRequest);
-                mPendingAssistantRequest = null;
-            } else if (mPendingTouchEventRequest != null) {
-                nextRequest = UpdateRequest.of(mPendingTouchEventRequest);
-                mPendingTouchEventRequest = null;
-            }
-            if (nextRequest != null) {
-                Futures.addCallback(
-                        mTaskOrchestrator.processUpdateRequest(nextRequest),
-                        new FutureCallback<Void>() {
-                            @Override
-                            public void onSuccess(Void unused) {
-                                dispatchPendingRequestIfIdle();
-                            }
-
-                            /**
-                             * A fatal exception has occurred, cause by one of the following:
-                             *
-                             * <ul>
-                             *   <li>1. The developer listener threw some runtime exception
-                             *   <li>2. The SDK encountered some uncaught internal exception
-                             * </ul>
-                             *
-                             * <p>In both cases, this exception will be rethrown which will crash
-                             * the app.
-                             */
-                            @Override
-                            public void onFailure(@NonNull Throwable t) {
-                                throw new IllegalStateException(
-                                        "unhandled exception in request processing", t);
-                            }
-                        },
-                        mExecutor);
-            }
-        }
-    }
-
-    @Override
-    public void execute(
-            @NonNull ArgumentsWrapper argumentsWrapper, @NonNull CallbackInternal callback) {
-        enqueueAssistantRequest(AssistantUpdateRequest.create(argumentsWrapper, callback));
-    }
-
-    /** Method for attempting to manually update the param values. */
-    @Override
-    public void updateParamValues(@NonNull Map<String, List<ParamValue>> paramValuesMap) {
-        enqueueTouchEventRequest(TouchEventUpdateRequest.create(paramValuesMap));
-    }
-}
diff --git a/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/task/impl/TaskCapabilityImpl.kt b/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/task/impl/TaskCapabilityImpl.kt
new file mode 100644
index 0000000..2dc76ad
--- /dev/null
+++ b/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/task/impl/TaskCapabilityImpl.kt
@@ -0,0 +1,72 @@
+/*
+ * 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.appactions.interaction.capabilities.core.task.impl
+import androidx.appactions.interaction.capabilities.core.ActionCapability
+import androidx.appactions.interaction.capabilities.core.BaseSession
+import androidx.appactions.interaction.capabilities.core.HostProperties
+import androidx.appactions.interaction.capabilities.core.SessionBuilder
+import androidx.appactions.interaction.capabilities.core.impl.ActionCapabilitySession
+import androidx.appactions.interaction.capabilities.core.impl.spec.ActionSpec
+import androidx.appactions.interaction.proto.AppActionsContext.AppAction
+import androidx.appactions.interaction.proto.TaskInfo
+import java.util.function.Supplier
+
+/**
+ * @param id a unique id for this capability, can be null
+ * @param supportsMultiTurnTask whether this is a single-turn capability or a multi-turn capability
+ * @param actionSpec the ActionSpec for this capability
+ * @param sessionBuilder the SessionBuilder provided by the library user
+ * @param sessionBridge a SessionBridge object that converts SessionT into TaskHandler instance
+ * @param sessionUpdaterSupplier a Supplier of SessionUpdaterT instances
+ */
+internal class TaskCapabilityImpl<
+    PropertyT,
+    ArgumentT,
+    OutputT,
+    SessionT : BaseSession<ArgumentT, OutputT>,
+    ConfirmationT,
+    SessionUpdaterT,
+    > constructor(
+    override val id: String?,
+    val actionSpec: ActionSpec<PropertyT, ArgumentT, OutputT>,
+    val property: PropertyT,
+    val sessionBuilder: SessionBuilder<SessionT>,
+    val sessionBridge: SessionBridge<SessionT, ConfirmationT>,
+    val sessionUpdaterSupplier: Supplier<SessionUpdaterT>,
+) : ActionCapability {
+
+    override val supportsMultiTurnTask = true
+
+    override fun getAppAction(): AppAction {
+        val appActionBuilder = actionSpec.convertPropertyToProto(property).toBuilder()
+            .setTaskInfo(TaskInfo.newBuilder().setSupportsPartialFulfillment(true))
+        id?.let(appActionBuilder::setIdentifier)
+        return appActionBuilder.build()
+    }
+
+    override fun createSession(hostProperties: HostProperties): ActionCapabilitySession {
+        val externalSession = sessionBuilder.createSession(
+            hostProperties,
+        )
+        return TaskCapabilitySession(
+            actionSpec,
+            getAppAction(),
+            sessionBridge.createTaskHandler(externalSession),
+            externalSession,
+        )
+    }
+}
diff --git a/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/task/impl/TaskCapabilitySession.kt b/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/task/impl/TaskCapabilitySession.kt
new file mode 100644
index 0000000..1a23dc1
--- /dev/null
+++ b/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/task/impl/TaskCapabilitySession.kt
@@ -0,0 +1,149 @@
+/*
+ * 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.appactions.interaction.capabilities.core.task.impl
+
+import androidx.appactions.interaction.capabilities.core.BaseSession
+import androidx.appactions.interaction.capabilities.core.impl.ActionCapabilitySession
+import androidx.appactions.interaction.capabilities.core.impl.ArgumentsWrapper
+import androidx.appactions.interaction.capabilities.core.impl.CallbackInternal
+import androidx.appactions.interaction.capabilities.core.impl.ErrorStatusInternal
+import androidx.appactions.interaction.capabilities.core.impl.TouchEventCallback
+import androidx.appactions.interaction.capabilities.core.impl.concurrent.FutureCallback
+import androidx.appactions.interaction.capabilities.core.impl.concurrent.Futures
+import androidx.appactions.interaction.capabilities.core.impl.spec.ActionSpec
+import androidx.appactions.interaction.proto.ParamValue
+import androidx.appactions.interaction.proto.AppActionsContext.AppAction
+
+internal class TaskCapabilitySession<
+    ArgumentT,
+    OutputT,
+    ConfirmationT,
+    > (
+    val actionSpec: ActionSpec<*, ArgumentT, OutputT>,
+    val appAction: AppAction,
+    val taskHandler: TaskHandler<ConfirmationT>,
+    val externalSession: BaseSession<ArgumentT, OutputT>,
+) : ActionCapabilitySession, TaskUpdateHandler {
+    override val state: AppAction
+        get() = sessionOrchestrator.getAppAction()
+    /** synchronize on this lock to enqueue assistant/manual input requests. */
+    private val requestLock = Any()
+
+    /** Contains session state and request processing logic. */
+    private val sessionOrchestrator: TaskOrchestrator<
+        ArgumentT, OutputT, ConfirmationT,> =
+        TaskOrchestrator(
+            actionSpec,
+            appAction,
+            taskHandler,
+            externalSession,
+            Runnable::run,
+        )
+    private var pendingAssistantRequest: AssistantUpdateRequest? = null
+    private var pendingTouchEventRequest: TouchEventUpdateRequest? = null
+
+    override fun execute(argumentsWrapper: ArgumentsWrapper, callback: CallbackInternal) {
+        enqueueAssistantRequest(AssistantUpdateRequest.create(argumentsWrapper, callback))
+    }
+
+    override fun updateParamValues(paramValuesMap: Map<String, List<ParamValue>>) {
+        enqueueTouchEventRequest(TouchEventUpdateRequest(paramValuesMap))
+    }
+
+    override fun setTouchEventCallback(callback: TouchEventCallback) {
+        sessionOrchestrator.setTouchEventCallback(callback)
+    }
+
+    /**
+     * If there is a pendingAssistantRequest, we will overwrite that request (and send CANCELLED
+     * response to that request).
+     *
+     * <p>This is done because assistant requests contain the full state, so we can safely ignore
+     * existing requests if a new one arrives.
+     */
+    private fun enqueueAssistantRequest(request: AssistantUpdateRequest) {
+        synchronized(requestLock) {
+            pendingAssistantRequest?.callbackInternal()?.onError(ErrorStatusInternal.CANCELLED)
+            pendingAssistantRequest = request
+            dispatchPendingRequestIfIdle()
+        }
+    }
+
+    private fun enqueueTouchEventRequest(request: TouchEventUpdateRequest) {
+        synchronized(requestLock) {
+            if (pendingTouchEventRequest == null) {
+                pendingTouchEventRequest = request
+            } else {
+                pendingTouchEventRequest =
+                    TouchEventUpdateRequest.merge(pendingTouchEventRequest!!, request)
+            }
+            dispatchPendingRequestIfIdle()
+        }
+    }
+
+    /**
+     * If sessionOrchestrator is idle, select the next request to dispatch to sessionOrchestrator (if
+     * there are any pending requests).
+     *
+     * <p>If sessionOrchestrator is not idle, do nothing, since this method will automatically be
+     * called when sessionOrchestrator becomes idle.
+     */
+    private fun dispatchPendingRequestIfIdle() {
+        synchronized(requestLock) {
+            if (!sessionOrchestrator.isIdle()) {
+                return
+            }
+            var nextRequest: UpdateRequest? = null
+            if (pendingAssistantRequest != null) {
+                nextRequest = UpdateRequest.of(pendingAssistantRequest)
+                pendingAssistantRequest = null
+            } else if (pendingTouchEventRequest != null) {
+                nextRequest = UpdateRequest.of(pendingTouchEventRequest)
+                pendingTouchEventRequest = null
+            }
+            if (nextRequest != null) {
+                Futures.addCallback(
+                    sessionOrchestrator.processUpdateRequest(nextRequest),
+                    object : FutureCallback<Void?> {
+                        override fun onSuccess(unused: Void?) {
+                            dispatchPendingRequestIfIdle()
+                        }
+
+                        /**
+                         * A fatal exception has occurred, cause by one of the following:
+                         *
+                         * <ul>
+                         *   <li>1. The developer listener threw some runtime exception
+                         *   <li>2. The SDK encountered some uncaught internal exception
+                         * </ul>
+                         *
+                         * <p>In both cases, this exception will be rethrown which will crash
+                         * the app.
+                         */
+                        override fun onFailure(t: Throwable) {
+                            throw IllegalStateException(
+                                "unhandled exception in request processing",
+                                t,
+                            )
+                        }
+                    },
+                    Runnable::run,
+                )
+            }
+        }
+    }
+}
diff --git a/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/task/impl/TaskCapabilityUtils.java b/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/task/impl/TaskCapabilityUtils.java
index 41fe7f6..1fca454 100644
--- a/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/task/impl/TaskCapabilityUtils.java
+++ b/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/task/impl/TaskCapabilityUtils.java
@@ -29,8 +29,7 @@
 import androidx.appactions.interaction.proto.Entity;
 import androidx.appactions.interaction.proto.FulfillmentRequest.Fulfillment.FulfillmentValue;
 import androidx.appactions.interaction.proto.ParamValue;
-
-import com.google.protobuf.Struct;
+import androidx.appactions.interaction.protobuf.Struct;
 
 import java.util.Arrays;
 import java.util.Collections;
diff --git a/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/task/impl/TaskHandler.kt b/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/task/impl/TaskHandler.kt
new file mode 100644
index 0000000..9e5a8e1
--- /dev/null
+++ b/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/task/impl/TaskHandler.kt
@@ -0,0 +1,211 @@
+/*
+ * 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.appactions.interaction.capabilities.core.task.impl
+
+import androidx.appactions.interaction.capabilities.core.impl.converters.DisambigEntityConverter
+import androidx.appactions.interaction.capabilities.core.impl.converters.ParamValueConverter
+import androidx.appactions.interaction.capabilities.core.impl.converters.SearchActionConverter
+import androidx.appactions.interaction.capabilities.core.task.AppEntityListResolver
+import androidx.appactions.interaction.capabilities.core.task.AppEntityResolver
+import androidx.appactions.interaction.capabilities.core.task.InventoryListResolver
+import androidx.appactions.interaction.capabilities.core.task.InventoryResolver
+import androidx.appactions.interaction.capabilities.core.task.ValueListListener
+import androidx.appactions.interaction.capabilities.core.task.ValueListener
+import androidx.appactions.interaction.proto.ParamValue
+import java.util.Optional
+import java.util.function.Function
+import java.util.function.Predicate
+
+/**
+ * Container of multi-turn Task related function references.
+ *
+ */
+data class TaskHandler<ConfirmationT> (
+    val taskParamRegistry: TaskParamRegistry,
+    val confirmationType: ConfirmationType,
+    val confirmationDataBindings: Map<String, Function<ConfirmationT, List<ParamValue>>>,
+    val onReadyToConfirmListener: OnReadyToConfirmListenerInternal<ConfirmationT>?,
+) {
+    class Builder<ConfirmationT>(
+        val confirmationType: ConfirmationType = ConfirmationType.NOT_SUPPORTED,
+    ) {
+        val taskParamRegistryBuilder = TaskParamRegistry.builder()
+        val confirmationDataBindings: MutableMap<
+            String, Function<ConfirmationT, List<ParamValue>>,> = mutableMapOf()
+        var onReadyToConfirmListener: OnReadyToConfirmListenerInternal<ConfirmationT>? = null
+
+        fun <ValueTypeT> registerInventoryTaskParam(
+            paramName: String,
+            listener: InventoryResolver<ValueTypeT>,
+            converter: ParamValueConverter<ValueTypeT>,
+        ): Builder<ConfirmationT> {
+            taskParamRegistryBuilder.addTaskParameter(
+                paramName,
+                GROUND_IF_NO_IDENTIFIER,
+                GenericResolverInternal.fromInventoryResolver(listener),
+                Optional.empty(),
+                Optional.empty(),
+                converter,
+            )
+            return this
+        }
+
+        fun <ValueTypeT> registerInventoryListTaskParam(
+            paramName: String,
+            listener: InventoryListResolver<ValueTypeT>,
+            converter: ParamValueConverter<ValueTypeT>,
+        ): Builder<ConfirmationT> {
+            taskParamRegistryBuilder.addTaskParameter(
+                paramName,
+                GROUND_IF_NO_IDENTIFIER,
+                GenericResolverInternal.fromInventoryListResolver(listener),
+                Optional.empty(),
+                Optional.empty(),
+                converter,
+            )
+            return this
+        }
+
+        fun <ValueTypeT> registerAppEntityTaskParam(
+            paramName: String,
+            listener: AppEntityResolver<ValueTypeT>,
+            converter: ParamValueConverter<ValueTypeT>,
+            entityConverter: DisambigEntityConverter<ValueTypeT>,
+            searchActionConverter: SearchActionConverter<ValueTypeT>,
+        ): Builder<ConfirmationT> {
+            taskParamRegistryBuilder.addTaskParameter(
+                paramName,
+                GROUND_IF_NO_IDENTIFIER,
+                GenericResolverInternal.fromAppEntityResolver(listener),
+                Optional.of(entityConverter),
+                Optional.of(searchActionConverter),
+                converter,
+            )
+            return this
+        }
+
+        fun <ValueTypeT> registerAppEntityListTaskParam(
+            paramName: String,
+            listener: AppEntityListResolver<ValueTypeT>,
+            converter: ParamValueConverter<ValueTypeT>,
+            entityConverter: DisambigEntityConverter<ValueTypeT>,
+            searchActionConverter: SearchActionConverter<ValueTypeT>,
+        ): Builder<ConfirmationT> {
+            taskParamRegistryBuilder.addTaskParameter(
+                paramName,
+                GROUND_IF_NO_IDENTIFIER,
+                GenericResolverInternal.fromAppEntityListResolver(listener),
+                Optional.of(entityConverter),
+                Optional.of(searchActionConverter),
+                converter,
+            )
+            return this
+        }
+
+        fun <ValueTypeT> registerValueTaskParam(
+            paramName: String,
+            listener: ValueListener<ValueTypeT>,
+            converter: ParamValueConverter<ValueTypeT>,
+        ): Builder<ConfirmationT> {
+            taskParamRegistryBuilder.addTaskParameter(
+                paramName,
+                GROUND_NEVER,
+                GenericResolverInternal.fromValueListener(listener),
+                Optional.empty(),
+                Optional.empty(),
+                converter,
+            )
+            return this
+        }
+
+        fun <ValueTypeT> registerValueListTaskParam(
+            paramName: String,
+            listener: ValueListListener<ValueTypeT>,
+            converter: ParamValueConverter<ValueTypeT>,
+        ): Builder<ConfirmationT> {
+            taskParamRegistryBuilder.addTaskParameter(
+                paramName,
+                GROUND_NEVER,
+                GenericResolverInternal.fromValueListListener(listener),
+                Optional.empty(),
+                Optional.empty(),
+                converter,
+            )
+            return this
+        }
+
+        /**
+         * Registers an optional, non-repeated confirmation data.
+         *
+         * @param paramName          the BIC confirmation data slot name of this parameter.
+         * @param confirmationGetter a getter of the confirmation data from the {@code ConfirmationT}
+         *                           instance.
+         * @param converter          a converter from confirmation data to a ParamValue.
+         */
+        fun <T> registerConfirmationOutput(
+            paramName: String,
+            confirmationGetter: (ConfirmationT) -> T?,
+            converter: (T) -> ParamValue,
+        ): Builder<ConfirmationT> {
+            confirmationDataBindings.put(
+                paramName,
+                { output: ConfirmationT ->
+                    listOfNotNull(confirmationGetter(output)).map(converter)
+                },
+            )
+            return this
+        }
+
+        /** Sets the onReadyToConfirmListener for this capability. */
+        fun setOnReadyToConfirmListenerInternal(
+            onReadyToConfirmListener: OnReadyToConfirmListenerInternal<ConfirmationT>,
+        ): Builder<ConfirmationT> {
+            this.onReadyToConfirmListener = onReadyToConfirmListener
+            return this
+        }
+
+        fun build(): TaskHandler<ConfirmationT> {
+            return TaskHandler(
+                taskParamRegistryBuilder.build(),
+                confirmationType,
+                confirmationDataBindings,
+                onReadyToConfirmListener,
+            )
+        }
+        companion object {
+            val GROUND_IF_NO_IDENTIFIER = Predicate<ParamValue> {
+                    paramValue ->
+                !paramValue.hasIdentifier()
+            }
+            val GROUND_NEVER = Predicate<ParamValue> {
+                    _ ->
+                false
+            }
+        }
+    }
+}
+
+enum class ConfirmationType {
+    // Confirmation is not supported for this Capability.
+    NOT_SUPPORTED,
+
+    // This Capability requires confirmation.
+    REQUIRED,
+
+    // Confirmation is optional for this Capability.
+    OPTIONAL,
+}
diff --git a/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/task/impl/TaskOrchestrator.java b/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/task/impl/TaskOrchestrator.java
index 4edee50..1813207 100644
--- a/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/task/impl/TaskOrchestrator.java
+++ b/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/task/impl/TaskOrchestrator.java
@@ -23,8 +23,10 @@
 import androidx.annotation.GuardedBy;
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
+import androidx.appactions.interaction.capabilities.core.BaseSession;
 import androidx.appactions.interaction.capabilities.core.ConfirmationOutput;
 import androidx.appactions.interaction.capabilities.core.ExecutionResult;
+import androidx.appactions.interaction.capabilities.core.InitArg;
 import androidx.appactions.interaction.capabilities.core.impl.ArgumentsWrapper;
 import androidx.appactions.interaction.capabilities.core.impl.CallbackInternal;
 import androidx.appactions.interaction.capabilities.core.impl.ErrorStatusInternal;
@@ -35,8 +37,6 @@
 import androidx.appactions.interaction.capabilities.core.impl.spec.ActionSpec;
 import androidx.appactions.interaction.capabilities.core.impl.utils.CapabilityLogger;
 import androidx.appactions.interaction.capabilities.core.impl.utils.LoggerInternal;
-import androidx.appactions.interaction.capabilities.core.task.OnDialogFinishListener;
-import androidx.appactions.interaction.capabilities.core.task.OnInitListener;
 import androidx.appactions.interaction.capabilities.core.task.impl.exceptions.MissingRequiredArgException;
 import androidx.appactions.interaction.proto.AppActionsContext.AppAction;
 import androidx.appactions.interaction.proto.AppActionsContext.IntentParameter;
@@ -58,50 +58,35 @@
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 import java.util.Optional;
 import java.util.Set;
 import java.util.concurrent.Executor;
 import java.util.function.Function;
-import java.util.function.Supplier;
 
 /**
- * TaskOrchestrator is responsible for holding task state, and processing assistant / manual input
- * updates to update task state.
+ * TaskOrchestrator is responsible for holding session state, and processing assistant / manual
+ * input updates to update session state.
  *
  * <p>TaskOrchestrator is also responsible to communicating state updates to developer provided
  * listeners.
  *
  * <p>Only one request can be processed at a time.
  */
-final class TaskOrchestrator<
-        PropertyT, ArgumentT, OutputT, ConfirmationT, TaskUpdaterT extends AbstractTaskUpdater> {
+final class TaskOrchestrator<ArgumentT, OutputT, ConfirmationT> {
 
     private static final String LOG_TAG = "TaskOrchestrator";
-    private final String mIdentifier;
-    private final ActionSpec<PropertyT, ArgumentT, OutputT> mActionSpec;
-    private final PropertyT mProperty;
-    private final TaskParamRegistry mParamRegistry;
-    private final Optional<OnInitListener<TaskUpdaterT>> mOnInitListener;
-    private final Optional<OnReadyToConfirmListenerInternal<ConfirmationT>>
-            mOnReadyToConfirmListener;
-
-    private final OnDialogFinishListener<ArgumentT, OutputT> mOnFinishListener;
+    private final ActionSpec<?, ArgumentT, OutputT> mActionSpec;
+    private final AppAction mAppAction;
+    private final TaskHandler<ConfirmationT> mTaskHandler;
     private final Executor mExecutor;
+    private final BaseSession<ArgumentT, OutputT> mExternalSession;
 
     /**
-     * Map of argument name to the {@link CurrentValue} which wraps the argument name and status
-     * .
+     * Map of argument name to the {@link CurrentValue} which wraps the argument name and status .
      */
     private final Map<String, List<CurrentValue>> mCurrentValuesMap;
     /**
-     * Map of confirmation data name to a function that converts confirmation data to ParamValue
-     * .
-     */
-    private final Map<String, Function<ConfirmationT, List<ParamValue>>>
-            mConfirmationOutputBindings;
-    /** Map of execution output name to a function that converts execution output to ParamValue. */
-    private final Map<String, Function<OutputT, List<ParamValue>>> mExecutionOutputBindings;
-    /**
      * Internal lock to enable synchronization while processing update requests. Also used for
      * synchronization of Task orchestrator state. ie indicate whether it is idle or not
      */
@@ -109,56 +94,32 @@
     /**
      * The callback that should be invoked when manual input processing finishes. This sends the
      * processing results to the AppInteraction SDKs. Note, this field is not provided on
-     * construction
-     * because the callback is not available at the time when the developer creates the capability.
+     * construction because the callback is not available at the time when the developer creates the
+     * capability.
      */
-    @Nullable
-    TouchEventCallback mTouchEventCallback;
+    @Nullable TouchEventCallback mTouchEventCallback;
     /** Current status of the overall task (i.e. status of the task). */
     private TaskStatus mTaskStatus;
-    /** Supplies new instances of TaskUpdaterT to give to onInitListener. */
-    private Supplier<TaskUpdaterT> mTaskUpdaterSupplier;
 
-    /**
-     * The current TaskUpdaterT instance. Should only be non-null when taskStatus is IN_PROGRESS.
-     */
-    @Nullable
-    private TaskUpdaterT mTaskUpdater;
     /** True if an UpdateRequest is currently being processed, false otherwise. */
     @GuardedBy("mTaskOrchestratorLock")
     private boolean mIsIdle = true;
 
     TaskOrchestrator(
-            String identifier,
-            ActionSpec<PropertyT, ArgumentT, OutputT> actionSpec,
-            PropertyT property,
-            TaskParamRegistry paramRegistry,
-            Optional<OnInitListener<TaskUpdaterT>> onInitListener,
-            Optional<OnReadyToConfirmListenerInternal<ConfirmationT>> onReadyToConfirmListener,
-            OnDialogFinishListener<ArgumentT, OutputT> onFinishListener,
-            Map<String, Function<ConfirmationT, List<ParamValue>>> confirmationOutputBindings,
-            Map<String, Function<OutputT, List<ParamValue>>> executionOutputBindings,
+            ActionSpec<?, ArgumentT, OutputT> actionSpec,
+            AppAction appAction,
+            TaskHandler<ConfirmationT> taskHandler,
+            BaseSession<ArgumentT, OutputT> externalSession,
             Executor executor) {
-        this.mIdentifier = identifier;
         this.mActionSpec = actionSpec;
-        this.mProperty = property;
-        this.mParamRegistry = paramRegistry;
-        this.mOnInitListener = onInitListener;
-        this.mOnReadyToConfirmListener = onReadyToConfirmListener;
-        this.mOnFinishListener = onFinishListener;
-        this.mConfirmationOutputBindings = confirmationOutputBindings;
-        this.mExecutionOutputBindings = executionOutputBindings;
+        this.mAppAction = appAction;
+        this.mTaskHandler = taskHandler;
+        this.mExternalSession = externalSession;
         this.mExecutor = executor;
 
         this.mCurrentValuesMap = Collections.synchronizedMap(new HashMap<>());
         this.mTaskStatus = TaskStatus.UNINITIATED;
-        this.mTaskUpdater = null;
     }
-
-    void setTaskUpdaterSupplier(Supplier<TaskUpdaterT> taskUpdaterSupplier) {
-        this.mTaskUpdaterSupplier = taskUpdaterSupplier;
-    }
-
     // Set a TouchEventCallback instance. This callback is invoked when state changes from manual
     // input.
     void setTouchEventCallback(@Nullable TouchEventCallback touchEventCallback) {
@@ -179,8 +140,7 @@
      * completed.
      *
      * <p>An unhandled exception when handling an UpdateRequest will cause all future update
-     * requests
-     * to fail.
+     * requests to fail.
      *
      * <p>This method should never be called when isIdle() returns false.
      */
@@ -194,12 +154,12 @@
             ListenableFuture<Void> requestProcessingFuture;
             switch (updateRequest.getKind()) {
                 case ASSISTANT:
-                    requestProcessingFuture = processAssistantUpdateRequest(
-                            updateRequest.assistant());
+                    requestProcessingFuture =
+                            processAssistantUpdateRequest(updateRequest.assistant());
                     break;
                 case TOUCH_EVENT:
-                    requestProcessingFuture = processTouchEventUpdateRequest(
-                            updateRequest.touchEvent());
+                    requestProcessingFuture =
+                            processTouchEventUpdateRequest(updateRequest.touchEvent());
                     break;
                 default:
                     throw new IllegalArgumentException("unknown UpdateRequest type");
@@ -239,7 +199,7 @@
                 return handleConfirm(callback);
             case CANCEL:
             case TERMINATE:
-                clearState();
+                terminate();
                 callback.onSuccess(FulfillmentResponse.getDefaultInstance());
                 break;
         }
@@ -248,7 +208,7 @@
 
     public ListenableFuture<Void> processTouchEventUpdateRequest(
             TouchEventUpdateRequest touchEventUpdateRequest) {
-        Map<String, List<ParamValue>> paramValuesMap = touchEventUpdateRequest.paramValuesMap();
+        Map<String, List<ParamValue>> paramValuesMap = touchEventUpdateRequest.getParamValuesMap();
         if (mTouchEventCallback == null
                 || paramValuesMap.isEmpty()
                 || mTaskStatus != TaskStatus.IN_PROGRESS) {
@@ -259,8 +219,10 @@
             mCurrentValuesMap.put(
                     argName,
                     entry.getValue().stream()
-                            .map(paramValue -> TaskCapabilityUtils.toCurrentValue(paramValue,
-                                    Status.ACCEPTED))
+                            .map(
+                                    paramValue ->
+                                            TaskCapabilityUtils.toCurrentValue(
+                                                    paramValue, Status.ACCEPTED))
                             .collect(toImmutableList()));
         }
         ListenableFuture<Void> argumentsProcessingFuture;
@@ -293,7 +255,8 @@
                                 @Override
                                 public void onSuccess(FulfillmentResponse fulfillmentResponse) {
                                     LoggerInternal.log(
-                                            CapabilityLogger.LogLevel.INFO, LOG_TAG,
+                                            CapabilityLogger.LogLevel.INFO,
+                                            LOG_TAG,
                                             "Manual input success");
                                     if (mTouchEventCallback != null) {
                                         mTouchEventCallback.onSuccess(
@@ -301,7 +264,8 @@
                                                 TouchEventMetadata.getDefaultInstance());
                                     } else {
                                         LoggerInternal.log(
-                                                CapabilityLogger.LogLevel.ERROR, LOG_TAG,
+                                                CapabilityLogger.LogLevel.ERROR,
+                                                LOG_TAG,
                                                 "Manual input null callback");
                                     }
                                     completer.set(null);
@@ -309,14 +273,17 @@
 
                                 @Override
                                 public void onFailure(@NonNull Throwable t) {
-                                    LoggerInternal.log(CapabilityLogger.LogLevel.ERROR, LOG_TAG,
+                                    LoggerInternal.log(
+                                            CapabilityLogger.LogLevel.ERROR,
+                                            LOG_TAG,
                                             "Manual input fail");
                                     if (mTouchEventCallback != null) {
                                         mTouchEventCallback.onError(
                                                 ErrorStatusInternal.TOUCH_EVENT_REQUEST_FAILURE);
                                     } else {
                                         LoggerInternal.log(
-                                                CapabilityLogger.LogLevel.ERROR, LOG_TAG,
+                                                CapabilityLogger.LogLevel.ERROR,
+                                                LOG_TAG,
                                                 "Manual input null callback");
                                     }
                                     completer.set(null);
@@ -327,15 +294,8 @@
                 });
     }
 
-    /** Remove any state that may affect the #getAppAction() call. */
-    private void clearState() {
-        if (this.mTaskUpdater != null) {
-            this.mTaskUpdater.destroy();
-            this.mTaskUpdater = null;
-        }
-        this.mCurrentValuesMap.clear();
-        this.mTaskStatus = TaskStatus.UNINITIATED;
-    }
+    // TODO: add cleanup logic if any
+    private void terminate() {}
 
     /**
      * If slot filling is incomplete, the future contains default FulfillmentResponse.
@@ -344,23 +304,20 @@
      */
     private ListenableFuture<FulfillmentResponse> maybeConfirmOrFinish() {
         Map<String, List<ParamValue>> finalArguments = getCurrentAcceptedArguments();
-        AppAction appAction = mActionSpec.convertPropertyToProto(mProperty);
         if (anyParamsOfStatus(Status.REJECTED)
-                || !TaskCapabilityUtils.isSlotFillingComplete(finalArguments,
-                appAction.getParamsList())) {
+                || !TaskCapabilityUtils.isSlotFillingComplete(
+                        finalArguments, mAppAction.getParamsList())) {
             return Futures.immediateFuture(FulfillmentResponse.getDefaultInstance());
         }
-        if (mOnReadyToConfirmListener.isPresent()) {
+        if (mTaskHandler.getOnReadyToConfirmListener() != null) {
             return getFulfillmentResponseForConfirmation(finalArguments);
         }
         return getFulfillmentResponseForExecution(finalArguments);
     }
 
     private ListenableFuture<Void> maybeInitializeTask() {
-        if (this.mTaskStatus == TaskStatus.UNINITIATED && mOnInitListener.isPresent()) {
-            this.mTaskUpdater = mTaskUpdaterSupplier.get();
-            this.mTaskStatus = TaskStatus.IN_PROGRESS;
-            return mOnInitListener.get().onInit(this.mTaskUpdater);
+        if (this.mTaskStatus == TaskStatus.UNINITIATED) {
+            mExternalSession.onInit(new InitArg());
         }
         this.mTaskStatus = TaskStatus.IN_PROGRESS;
         return Futures.immediateVoidFuture();
@@ -397,7 +354,9 @@
                             new FutureCallback<FulfillmentResponse>() {
                                 @Override
                                 public void onSuccess(FulfillmentResponse fulfillmentResponse) {
-                                    LoggerInternal.log(CapabilityLogger.LogLevel.INFO, LOG_TAG,
+                                    LoggerInternal.log(
+                                            CapabilityLogger.LogLevel.INFO,
+                                            LOG_TAG,
                                             "Task sync success");
                                     callback.onSuccess(fulfillmentResponse);
                                     completer.set(null);
@@ -405,8 +364,11 @@
 
                                 @Override
                                 public void onFailure(@NonNull Throwable t) {
-                                    LoggerInternal.log(CapabilityLogger.LogLevel.ERROR, LOG_TAG,
-                                            "Task sync fail", t);
+                                    LoggerInternal.log(
+                                            CapabilityLogger.LogLevel.ERROR,
+                                            LOG_TAG,
+                                            "Task sync fail",
+                                            t);
                                     callback.onError(ErrorStatusInternal.SYNC_REQUEST_FAILURE);
                                     completer.set(null);
                                 }
@@ -430,7 +392,8 @@
                                 @Override
                                 public void onSuccess(FulfillmentResponse fulfillmentResponse) {
                                     LoggerInternal.log(
-                                            CapabilityLogger.LogLevel.INFO, LOG_TAG,
+                                            CapabilityLogger.LogLevel.INFO,
+                                            LOG_TAG,
                                             "Task confirm success");
                                     callback.onSuccess(fulfillmentResponse);
                                     completer.set(null);
@@ -438,7 +401,9 @@
 
                                 @Override
                                 public void onFailure(@NonNull Throwable t) {
-                                    LoggerInternal.log(CapabilityLogger.LogLevel.ERROR, LOG_TAG,
+                                    LoggerInternal.log(
+                                            CapabilityLogger.LogLevel.ERROR,
+                                            LOG_TAG,
                                             "Task confirm fail");
                                     callback.onError(
                                             ErrorStatusInternal.CONFIRMATION_REQUEST_FAILURE);
@@ -478,8 +443,8 @@
                     Futures.transformAsync(
                             currentFuture,
                             (previousResult) ->
-                                    maybeProcessSlotAndUpdateCurrentValues(previousResult, name,
-                                            fulfillmentValues),
+                                    maybeProcessSlotAndUpdateCurrentValues(
+                                            previousResult, name, fulfillmentValues),
                             mExecutor,
                             "maybeProcessSlotAndUpdateCurrentValues");
         }
@@ -488,7 +453,8 @@
     }
 
     private ListenableFuture<SlotProcessingResult> maybeProcessSlotAndUpdateCurrentValues(
-            SlotProcessingResult previousResult, String slotKey,
+            SlotProcessingResult previousResult,
+            String slotKey,
             List<FulfillmentValue> newSlotValues) {
         List<CurrentValue> currentSlotValues =
                 mCurrentValuesMap.getOrDefault(slotKey, Collections.emptyList());
@@ -498,8 +464,8 @@
             return Futures.immediateFuture(previousResult);
         }
         List<CurrentValue> pendingArgs =
-                TaskCapabilityUtils.fulfillmentValuesToCurrentValues(modifiedSlotValues,
-                        Status.PENDING);
+                TaskCapabilityUtils.fulfillmentValuesToCurrentValues(
+                        modifiedSlotValues, Status.PENDING);
         return Futures.transform(
                 processSlot(slotKey, previousResult, pendingArgs),
                 currentResult -> {
@@ -521,7 +487,8 @@
         if (!previousResult.isSuccessful()) {
             return Futures.immediateFuture(SlotProcessingResult.create(false, pendingArgs));
         }
-        return TaskSlotProcessor.processSlot(name, pendingArgs, mParamRegistry, mExecutor);
+        return TaskSlotProcessor.processSlot(
+                name, pendingArgs, mTaskHandler.getTaskParamRegistry(), mExecutor);
     }
 
     /**
@@ -534,8 +501,10 @@
                 .filter(
                         entry ->
                                 entry.getValue().stream()
-                                        .allMatch(currentValue -> currentValue.getStatus()
-                                                == Status.ACCEPTED))
+                                        .allMatch(
+                                                currentValue ->
+                                                        currentValue.getStatus()
+                                                                == Status.ACCEPTED))
                 .collect(
                         toImmutableMap(
                                 Map.Entry::getKey,
@@ -555,8 +524,9 @@
                 .filter(
                         entry ->
                                 entry.getValue().stream()
-                                        .anyMatch(currentValue -> currentValue.getStatus()
-                                                == Status.PENDING))
+                                        .anyMatch(
+                                                currentValue ->
+                                                        currentValue.getStatus() == Status.PENDING))
                 .collect(
                         toImmutableMap(
                                 Map.Entry::getKey,
@@ -572,14 +542,16 @@
                 .anyMatch(
                         entry ->
                                 entry.getValue().stream()
-                                        .anyMatch(currentValue -> currentValue.getStatus()
-                                                == status));
+                                        .anyMatch(
+                                                currentValue ->
+                                                        currentValue.getStatus() == status));
     }
 
     private ListenableFuture<ConfirmationOutput<ConfirmationT>> executeOnTaskReadyToConfirm(
             Map<String, List<ParamValue>> finalArguments) {
         try {
-            return mOnReadyToConfirmListener.get().onReadyToConfirm(finalArguments);
+            return Objects.requireNonNull(mTaskHandler.getOnReadyToConfirmListener())
+                    .onReadyToConfirm(finalArguments);
         } catch (StructConversionException | MissingRequiredArgException e) {
             return Futures.immediateFailedFuture(e);
         }
@@ -587,14 +559,15 @@
 
     private ListenableFuture<ExecutionResult<OutputT>> executeOnTaskFinish(
             Map<String, List<ParamValue>> finalArguments) {
-        ListenableFuture<ExecutionResult<OutputT>> finishListener;
+        ListenableFuture<ExecutionResult<OutputT>> executionResultFuture;
         try {
-            finishListener = mOnFinishListener.onFinish(mActionSpec.buildArgument(finalArguments));
+            executionResultFuture =
+                    mExternalSession.onFinishAsync(mActionSpec.buildArgument(finalArguments));
         } catch (StructConversionException e) {
             return Futures.immediateFailedFuture(e);
         }
         return Futures.transform(
-                finishListener,
+                executionResultFuture,
                 executionResult -> {
                     this.mTaskStatus = TaskStatus.COMPLETED;
                     return executionResult;
@@ -610,8 +583,8 @@
                 result -> {
                     FulfillmentResponse.Builder fulfillmentResponse =
                             FulfillmentResponse.newBuilder();
-                    convertToConfirmationOutput(result).ifPresent(
-                            fulfillmentResponse::setConfirmationData);
+                    convertToConfirmationOutput(result)
+                            .ifPresent(fulfillmentResponse::setConfirmationData);
                     return fulfillmentResponse.build();
                 },
                 mExecutor,
@@ -626,8 +599,8 @@
                     FulfillmentResponse.Builder fulfillmentResponse =
                             FulfillmentResponse.newBuilder();
                     if (mTaskStatus == TaskStatus.COMPLETED) {
-                        convertToExecutionOutput(result).ifPresent(
-                                fulfillmentResponse::setExecutionOutput);
+                        convertToExecutionOutput(result)
+                                .ifPresent(fulfillmentResponse::setExecutionOutput);
                     }
                     return fulfillmentResponse.build();
                 },
@@ -643,8 +616,10 @@
                             List<CurrentValue> vals = mCurrentValuesMap.get(param.getName());
                             if (vals != null) {
                                 updatedList.add(
-                                        param.toBuilder().clearCurrentValue().addAllCurrentValue(
-                                                vals).build());
+                                        param.toBuilder()
+                                                .clearCurrentValue()
+                                                .addAllCurrentValue(vals)
+                                                .build());
                             } else {
                                 updatedList.add(param);
                             }
@@ -653,12 +628,10 @@
     }
 
     AppAction getAppAction() {
-        AppAction appActionWithoutState = mActionSpec.convertPropertyToProto(mProperty);
-        return appActionWithoutState.toBuilder()
+        return mAppAction.toBuilder()
                 .clearParams()
-                .addAllParams(addStateToParamsContext(appActionWithoutState.getParamsList()))
+                .addAllParams(addStateToParamsContext(mAppAction.getParamsList()))
                 .setTaskInfo(TaskInfo.newBuilder().setSupportsPartialFulfillment(true))
-                .setIdentifier(mIdentifier)
                 .build();
     }
 
@@ -666,20 +639,11 @@
     private Optional<StructuredOutput> convertToExecutionOutput(
             ExecutionResult<OutputT> executionResult) {
         OutputT output = executionResult.getOutput();
-        if (output == null || output instanceof Void) {
+        if (output == null) {
             return Optional.empty();
         }
 
-        StructuredOutput.Builder executionOutputBuilder = StructuredOutput.newBuilder();
-        for (Map.Entry<String, Function<OutputT, List<ParamValue>>> entry :
-                mExecutionOutputBindings.entrySet()) {
-            executionOutputBuilder.addOutputValues(
-                    StructuredOutput.OutputValue.newBuilder()
-                            .setName(entry.getKey())
-                            .addAllValues(entry.getValue().apply(output))
-                            .build());
-        }
-        return Optional.of(executionOutputBuilder.build());
+        return Optional.of(mActionSpec.convertOutputToProto(output));
     }
 
     /**
@@ -694,7 +658,7 @@
 
         StructuredOutput.Builder confirmationOutputBuilder = StructuredOutput.newBuilder();
         for (Map.Entry<String, Function<ConfirmationT, List<ParamValue>>> entry :
-                mConfirmationOutputBindings.entrySet()) {
+                mTaskHandler.getConfirmationDataBindings().entrySet()) {
             confirmationOutputBuilder.addOutputValues(
                     StructuredOutput.OutputValue.newBuilder()
                             .setName(entry.getKey())
diff --git a/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/task/impl/TouchEventUpdateRequest.kt b/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/task/impl/TouchEventUpdateRequest.kt
new file mode 100644
index 0000000..176fb6d
--- /dev/null
+++ b/appactions/interaction/interaction-capabilities-core/src/main/java/androidx/appactions/interaction/capabilities/core/task/impl/TouchEventUpdateRequest.kt
@@ -0,0 +1,39 @@
+/*
+ * 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.appactions.interaction.capabilities.core.task.impl
+
+import androidx.appactions.interaction.proto.ParamValue
+
+/** Represents a fulfillment request coming from user tap. */
+internal data class TouchEventUpdateRequest(val paramValuesMap: Map<String, List<ParamValue>>) {
+
+    companion object {
+        /**
+         * merge two TouchEventUpdateRequest instances. Map entries in newRequest will take priority in
+         * case of conflict.
+         */
+        @JvmStatic
+        fun merge(
+            oldRequest: TouchEventUpdateRequest,
+            newRequest: TouchEventUpdateRequest,
+        ): TouchEventUpdateRequest {
+            val mergedParamValuesMap = oldRequest.paramValuesMap.toMutableMap()
+            mergedParamValuesMap.putAll(newRequest.paramValuesMap)
+            return TouchEventUpdateRequest(mergedParamValuesMap.toMap())
+        }
+    }
+}
diff --git a/appactions/interaction/interaction-capabilities-core/src/test/java/androidx/appactions/interaction/capabilities/core/impl/converters/TypeConvertersTest.java b/appactions/interaction/interaction-capabilities-core/src/test/java/androidx/appactions/interaction/capabilities/core/impl/converters/TypeConvertersTest.java
index 2721638..0b838cb 100644
--- a/appactions/interaction/interaction-capabilities-core/src/test/java/androidx/appactions/interaction/capabilities/core/impl/converters/TypeConvertersTest.java
+++ b/appactions/interaction/interaction-capabilities-core/src/test/java/androidx/appactions/interaction/capabilities/core/impl/converters/TypeConvertersTest.java
@@ -40,10 +40,9 @@
 import androidx.appactions.interaction.capabilities.core.values.properties.Recipient;
 import androidx.appactions.interaction.proto.Entity;
 import androidx.appactions.interaction.proto.ParamValue;
-
-import com.google.protobuf.ListValue;
-import com.google.protobuf.Struct;
-import com.google.protobuf.Value;
+import androidx.appactions.interaction.protobuf.ListValue;
+import androidx.appactions.interaction.protobuf.Struct;
+import androidx.appactions.interaction.protobuf.Value;
 
 import org.junit.Test;
 import org.junit.runner.RunWith;
diff --git a/appactions/interaction/interaction-capabilities-core/src/test/java/androidx/appactions/interaction/capabilities/core/impl/converters/TypeSpecImplTest.java b/appactions/interaction/interaction-capabilities-core/src/test/java/androidx/appactions/interaction/capabilities/core/impl/converters/TypeSpecImplTest.java
index 52d6c2a..6a09135 100644
--- a/appactions/interaction/interaction-capabilities-core/src/test/java/androidx/appactions/interaction/capabilities/core/impl/converters/TypeSpecImplTest.java
+++ b/appactions/interaction/interaction-capabilities-core/src/test/java/androidx/appactions/interaction/capabilities/core/impl/converters/TypeSpecImplTest.java
@@ -22,9 +22,8 @@
 
 import androidx.appactions.interaction.capabilities.core.impl.exceptions.StructConversionException;
 import androidx.appactions.interaction.capabilities.core.testing.spec.TestEntity;
-
-import com.google.protobuf.Struct;
-import com.google.protobuf.Value;
+import androidx.appactions.interaction.protobuf.Struct;
+import androidx.appactions.interaction.protobuf.Value;
 
 import org.junit.Test;
 import org.junit.runner.RunWith;
diff --git a/appactions/interaction/interaction-capabilities-core/src/test/java/androidx/appactions/interaction/capabilities/core/task/impl/TaskCapabilityImplTest.java b/appactions/interaction/interaction-capabilities-core/src/test/java/androidx/appactions/interaction/capabilities/core/task/impl/TaskCapabilityImplTest.java
deleted file mode 100644
index 1c5e9e7..0000000
--- a/appactions/interaction/interaction-capabilities-core/src/test/java/androidx/appactions/interaction/capabilities/core/task/impl/TaskCapabilityImplTest.java
+++ /dev/null
@@ -1,1767 +0,0 @@
-/*
- * Copyright 2023 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package androidx.appactions.interaction.capabilities.core.task.impl;
-
-import static androidx.appactions.interaction.capabilities.core.testing.ArgumentUtils.buildRequestArgs;
-import static androidx.appactions.interaction.capabilities.core.testing.ArgumentUtils.buildSearchActionParamValue;
-import static androidx.appactions.interaction.capabilities.core.testing.TestingUtils.CB_TIMEOUT;
-import static androidx.appactions.interaction.capabilities.core.testing.TestingUtils.buildActionCallback;
-import static androidx.appactions.interaction.capabilities.core.testing.TestingUtils.buildActionCallbackWithFulfillmentResponse;
-import static androidx.appactions.interaction.capabilities.core.testing.TestingUtils.buildErrorActionCallback;
-import static androidx.appactions.interaction.capabilities.core.testing.TestingUtils.buildTouchEventCallback;
-import static androidx.appactions.interaction.proto.FulfillmentRequest.Fulfillment.Type.CANCEL;
-import static androidx.appactions.interaction.proto.FulfillmentRequest.Fulfillment.Type.CONFIRM;
-import static androidx.appactions.interaction.proto.FulfillmentRequest.Fulfillment.Type.SYNC;
-import static androidx.appactions.interaction.proto.FulfillmentRequest.Fulfillment.Type.TERMINATE;
-import static androidx.appactions.interaction.proto.FulfillmentRequest.Fulfillment.Type.UNKNOWN_TYPE;
-import static androidx.appactions.interaction.proto.FulfillmentRequest.Fulfillment.Type.UNRECOGNIZED;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import static org.junit.Assert.assertThrows;
-
-import static java.util.concurrent.TimeUnit.MILLISECONDS;
-
-import androidx.annotation.NonNull;
-import androidx.appactions.interaction.capabilities.core.AbstractCapabilityBuilder;
-import androidx.appactions.interaction.capabilities.core.AbstractTaskHandlerBuilder;
-import androidx.appactions.interaction.capabilities.core.ActionCapability;
-import androidx.appactions.interaction.capabilities.core.ConfirmationOutput;
-import androidx.appactions.interaction.capabilities.core.ExecutionResult;
-import androidx.appactions.interaction.capabilities.core.impl.ErrorStatusInternal;
-import androidx.appactions.interaction.capabilities.core.impl.TouchEventCallback;
-import androidx.appactions.interaction.capabilities.core.impl.concurrent.Futures;
-import androidx.appactions.interaction.capabilities.core.impl.converters.DisambigEntityConverter;
-import androidx.appactions.interaction.capabilities.core.impl.converters.TypeConverters;
-import androidx.appactions.interaction.capabilities.core.impl.spec.ActionSpec;
-import androidx.appactions.interaction.capabilities.core.impl.spec.ActionSpecBuilder;
-import androidx.appactions.interaction.capabilities.core.properties.EntityProperty;
-import androidx.appactions.interaction.capabilities.core.properties.EnumProperty;
-import androidx.appactions.interaction.capabilities.core.properties.SimpleProperty;
-import androidx.appactions.interaction.capabilities.core.properties.StringProperty;
-import androidx.appactions.interaction.capabilities.core.task.AppEntityResolver;
-import androidx.appactions.interaction.capabilities.core.task.EntitySearchResult;
-import androidx.appactions.interaction.capabilities.core.task.InvalidTaskException;
-import androidx.appactions.interaction.capabilities.core.task.OnDialogFinishListener;
-import androidx.appactions.interaction.capabilities.core.task.OnInitListener;
-import androidx.appactions.interaction.capabilities.core.task.OnReadyToConfirmListener;
-import androidx.appactions.interaction.capabilities.core.task.ValidationResult;
-import androidx.appactions.interaction.capabilities.core.task.ValueListener;
-import androidx.appactions.interaction.capabilities.core.testing.TestingUtils;
-import androidx.appactions.interaction.capabilities.core.testing.TestingUtils.ReusableTouchEventCallback;
-import androidx.appactions.interaction.capabilities.core.testing.TestingUtils.TouchEventResult;
-import androidx.appactions.interaction.capabilities.core.testing.spec.Argument;
-import androidx.appactions.interaction.capabilities.core.testing.spec.CapabilityStructFill;
-import androidx.appactions.interaction.capabilities.core.testing.spec.CapabilityTwoEntityValues;
-import androidx.appactions.interaction.capabilities.core.testing.spec.CapabilityTwoStrings;
-import androidx.appactions.interaction.capabilities.core.testing.spec.Confirmation;
-import androidx.appactions.interaction.capabilities.core.testing.spec.Output;
-import androidx.appactions.interaction.capabilities.core.testing.spec.Property;
-import androidx.appactions.interaction.capabilities.core.testing.spec.SettableFutureWrapper;
-import androidx.appactions.interaction.capabilities.core.testing.spec.TestEnum;
-import androidx.appactions.interaction.capabilities.core.values.EntityValue;
-import androidx.appactions.interaction.capabilities.core.values.ListItem;
-import androidx.appactions.interaction.capabilities.core.values.SearchAction;
-import androidx.appactions.interaction.proto.AppActionsContext.AppAction;
-import androidx.appactions.interaction.proto.AppActionsContext.IntentParameter;
-import androidx.appactions.interaction.proto.CurrentValue;
-import androidx.appactions.interaction.proto.DisambiguationData;
-import androidx.appactions.interaction.proto.Entity;
-import androidx.appactions.interaction.proto.FulfillmentResponse;
-import androidx.appactions.interaction.proto.FulfillmentResponse.StructuredOutput;
-import androidx.appactions.interaction.proto.FulfillmentResponse.StructuredOutput.OutputValue;
-import androidx.appactions.interaction.proto.ParamValue;
-import androidx.appactions.interaction.proto.TaskInfo;
-import androidx.concurrent.futures.CallbackToFutureAdapter;
-import androidx.concurrent.futures.CallbackToFutureAdapter.Completer;
-
-import com.google.common.util.concurrent.ListenableFuture;
-
-import org.junit.Test;
-import org.junit.runner.RunWith;
-import org.junit.runners.JUnit4;
-
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Optional;
-import java.util.concurrent.Executors;
-import java.util.concurrent.atomic.AtomicInteger;
-import java.util.concurrent.atomic.AtomicReference;
-import java.util.function.Supplier;
-
-@RunWith(JUnit4.class)
-public final class TaskCapabilityImplTest {
-
-    private static final Optional<DisambigEntityConverter<EntityValue>> DISAMBIG_ENTITY_CONVERTER =
-            Optional.of(TypeConverters::toEntity);
-    private static final GenericResolverInternal<EntityValue> AUTO_ACCEPT_ENTITY_VALUE =
-            GenericResolverInternal.fromAppEntityResolver(
-                    new AppEntityResolver<EntityValue>() {
-                        @Override
-                        public ListenableFuture<EntitySearchResult<EntityValue>> lookupAndRender(
-                                SearchAction<EntityValue> searchAction) {
-                            EntitySearchResult.Builder<EntityValue> result =
-                                    EntitySearchResult.newBuilder();
-                            return Futures.immediateFuture(
-                                    result.addPossibleValue(EntityValue.ofId("valid1")).build());
-                        }
-
-                        @NonNull
-                        @Override
-                        public ListenableFuture<ValidationResult> onReceived(EntityValue newValue) {
-                            return Futures.immediateFuture(ValidationResult.newAccepted());
-                        }
-                    });
-    private static final GenericResolverInternal<EntityValue> AUTO_REJECT_ENTITY_VALUE =
-            GenericResolverInternal.fromAppEntityResolver(
-                    new AppEntityResolver<EntityValue>() {
-                        @Override
-                        public ListenableFuture<EntitySearchResult<EntityValue>> lookupAndRender(
-                                SearchAction<EntityValue> searchAction) {
-                            EntitySearchResult.Builder<EntityValue> result =
-                                    EntitySearchResult.newBuilder();
-                            return Futures.immediateFuture(
-                                    result.addPossibleValue(EntityValue.ofId("valid1")).build());
-                        }
-
-                        @NonNull
-                        @Override
-                        public ListenableFuture<ValidationResult> onReceived(EntityValue newValue) {
-                            return Futures.immediateFuture(ValidationResult.newRejected());
-                        }
-                    });
-    private static final String CAPABILITY_NAME = "actions.intent.TEST";
-    private static final ActionSpec<Property, Argument, Output> ACTION_SPEC =
-            ActionSpecBuilder.ofCapabilityNamed(CAPABILITY_NAME)
-                    .setDescriptor(Property.class)
-                    .setArgument(Argument.class, Argument::newBuilder)
-                    .setOutput(Output.class)
-                    .bindRequiredEntityParameter(
-                            "required",
-                            Property::requiredEntityField,
-                            Argument.Builder::setRequiredEntityField)
-                    .bindOptionalStringParameter(
-                            "optional",
-                            Property::optionalStringField,
-                            Argument.Builder::setOptionalStringField)
-                    .bindOptionalEnumParameter(
-                            "optionalEnum",
-                            TestEnum.class,
-                            Property::enumField,
-                            Argument.Builder::setEnumField)
-                    .bindRepeatedStringParameter(
-                            "repeated",
-                            Property::repeatedStringField,
-                            Argument.Builder::setRepeatedStringField)
-                    .build();
-    private static final Property SINGLE_REQUIRED_FIELD_PROPERTY =
-            Property.newBuilder()
-                    .setRequiredEntityField(EntityProperty.newBuilder().setIsRequired(true).build())
-                    .build();
-    private static final Optional<OnReadyToConfirmListener<Argument, Confirmation>>
-            EMPTY_CONFIRM_LISTENER = Optional.empty();
-    private static final OnDialogFinishListener<Argument, Output> EMPTY_FINISH_LISTENER =
-            (finalArgs) ->
-                    Futures.immediateFuture(ExecutionResult.<Output>getDefaultInstance());
-
-    private static boolean groundingPredicate(ParamValue paramValue) {
-        return !paramValue.hasIdentifier();
-    }
-
-    private static List<CurrentValue> getCurrentValues(String argName, AppAction appAction) {
-        return appAction.getParamsList().stream()
-                .filter(intentParam -> intentParam.getName().equals(argName))
-                .findFirst()
-                .orElse(IntentParameter.getDefaultInstance())
-                .getCurrentValueList();
-    }
-
-    private static <TaskUpdaterT extends AbstractTaskUpdater>
-            TaskCapabilityImpl<Property, Argument, Output, Confirmation, TaskUpdaterT>
-                    createTaskCapability(
-                            Property property,
-                            TaskParamRegistry paramRegistry,
-                            Supplier<TaskUpdaterT> taskUpdaterSupplier,
-                            Optional<OnInitListener<TaskUpdaterT>> onInitListener,
-                            Optional<OnReadyToConfirmListener<Argument, Confirmation>>
-                                    optionalOnReadyToConfirmListener,
-                            OnDialogFinishListener<Argument, Output> onTaskFinishListener) {
-
-        Optional<OnReadyToConfirmListenerInternal<Confirmation>> onReadyToConfirmListenerInternal =
-                optionalOnReadyToConfirmListener.isPresent()
-                        ? Optional.of(
-                                (args) ->
-                                        optionalOnReadyToConfirmListener
-                                                .get()
-                                                .onReadyToConfirm(ACTION_SPEC.buildArgument(args)))
-                        : Optional.empty();
-
-        TaskCapabilityImpl<Property, Argument, Output, Confirmation, TaskUpdaterT> taskCapability =
-                new TaskCapabilityImpl<>(
-                        "id",
-                        ACTION_SPEC,
-                        property,
-                        paramRegistry,
-                        onInitListener,
-                        onReadyToConfirmListenerInternal,
-                        onTaskFinishListener,
-                        /* confirmationOutputBindings= */ new HashMap<>(),
-                        /* executionOutputBindings= */ new HashMap<>(),
-                        Executors.newFixedThreadPool(5));
-        taskCapability.setTaskUpdaterSupplier(taskUpdaterSupplier);
-        return taskCapability;
-    }
-
-    @Test
-    public void getAppAction_executeNeverCalled_taskIsUninitialized() {
-        ActionCapability capability =
-                createTaskCapability(
-                        SINGLE_REQUIRED_FIELD_PROPERTY,
-                        TaskParamRegistry.builder().build(),
-                        RequiredTaskUpdater::new,
-                        Optional.empty(),
-                        EMPTY_CONFIRM_LISTENER,
-                        EMPTY_FINISH_LISTENER);
-
-        assertThat(capability.getAppAction())
-                .isEqualTo(
-                        AppAction.newBuilder()
-                                .setName("actions.intent.TEST")
-                                .setIdentifier("id")
-                                .addParams(
-                                        IntentParameter.newBuilder()
-                                                .setName("required")
-                                                .setIsRequired(true))
-                                .setTaskInfo(
-                                        TaskInfo.newBuilder().setSupportsPartialFulfillment(true))
-                                .build());
-    }
-
-    @Test
-    public void onInitInvoked_invokedOnce() throws Exception {
-        AtomicInteger onSuccessInvocationCount = new AtomicInteger(0);
-        ActionCapability capability =
-                createTaskCapability(
-                        SINGLE_REQUIRED_FIELD_PROPERTY,
-                        TaskParamRegistry.builder().build(),
-                        RequiredTaskUpdater::new,
-                        Optional.of(
-                                (unused) -> {
-                                    onSuccessInvocationCount.incrementAndGet();
-                                    return Futures.immediateVoidFuture();
-                                }),
-                        EMPTY_CONFIRM_LISTENER,
-                        EMPTY_FINISH_LISTENER);
-
-        // TURN 1.
-        SettableFutureWrapper<Boolean> onSuccessInvoked = new SettableFutureWrapper<>();
-
-        capability.execute(
-                buildRequestArgs(SYNC, "unknownArgName", "foo"),
-                buildActionCallback(onSuccessInvoked));
-
-        assertThat(onSuccessInvoked.getFuture().get(CB_TIMEOUT, MILLISECONDS)).isTrue();
-        assertThat(onSuccessInvocationCount.get()).isEqualTo(1);
-
-        // TURN 2.
-        SettableFutureWrapper<Boolean> onSuccessInvoked2 = new SettableFutureWrapper<>();
-
-        capability.execute(
-                buildRequestArgs(
-                        SYNC,
-                        "required",
-                        ParamValue.newBuilder().setIdentifier("foo").setStringValue("foo").build()),
-                buildActionCallback(onSuccessInvoked2));
-
-        assertThat(onSuccessInvoked2.getFuture().get(CB_TIMEOUT, MILLISECONDS)).isTrue();
-        assertThat(onSuccessInvocationCount.get()).isEqualTo(1);
-    }
-
-    @Test
-    public void fulfillmentType_terminate_taskStateCleared() throws Exception {
-        ActionCapability capability =
-                createTaskCapability(
-                        SINGLE_REQUIRED_FIELD_PROPERTY,
-                        TaskParamRegistry.builder().build(),
-                        RequiredTaskUpdater::new,
-                        Optional.empty(),
-                        EMPTY_CONFIRM_LISTENER,
-                        EMPTY_FINISH_LISTENER);
-
-        // TURN 1.
-        SettableFutureWrapper<Boolean> onSuccessInvoked = new SettableFutureWrapper<>();
-
-        capability.execute(
-                buildRequestArgs(
-                        SYNC,
-                        "required",
-                        ParamValue.newBuilder().setIdentifier("foo").setStringValue("foo").build()),
-                buildActionCallback(onSuccessInvoked));
-
-        assertThat(onSuccessInvoked.getFuture().get(CB_TIMEOUT, MILLISECONDS)).isTrue();
-
-        // TURN 2.
-        SettableFutureWrapper<Boolean> onSuccessInvoked2 = new SettableFutureWrapper<>();
-
-        capability.execute(buildRequestArgs(TERMINATE), buildActionCallback(onSuccessInvoked2));
-
-        assertThat(onSuccessInvoked2.getFuture().get(CB_TIMEOUT, MILLISECONDS)).isTrue();
-        assertThat(capability.getAppAction())
-                .isEqualTo(
-                        AppAction.newBuilder()
-                                .setName("actions.intent.TEST")
-                                .setIdentifier("id")
-                                .addParams(
-                                        IntentParameter.newBuilder()
-                                                .setName("required")
-                                                .setIsRequired(true))
-                                .setTaskInfo(
-                                        TaskInfo.newBuilder().setSupportsPartialFulfillment(true))
-                                .build());
-    }
-
-    @Test
-    public void fulfillmentType_cancel_taskStateCleared() throws Exception {
-        SettableFutureWrapper<RequiredTaskUpdater> taskUpdaterCb = new SettableFutureWrapper<>();
-        ActionCapability capability =
-                createTaskCapability(
-                        SINGLE_REQUIRED_FIELD_PROPERTY,
-                        TaskParamRegistry.builder().build(),
-                        RequiredTaskUpdater::new,
-                        TestingUtils.buildOnInitListener(taskUpdaterCb),
-                        EMPTY_CONFIRM_LISTENER,
-                        EMPTY_FINISH_LISTENER);
-
-        // TURN 1.
-        SettableFutureWrapper<Boolean> onSuccessInvoked = new SettableFutureWrapper<>();
-
-        capability.execute(buildRequestArgs(SYNC), buildActionCallback(onSuccessInvoked));
-
-        assertThat(onSuccessInvoked.getFuture().get(CB_TIMEOUT, MILLISECONDS)).isTrue();
-        assertThat(taskUpdaterCb.getFuture().get(CB_TIMEOUT, MILLISECONDS).isDestroyed()).isFalse();
-
-        // TURN 2.
-        SettableFutureWrapper<Boolean> onSuccessInvoked2 = new SettableFutureWrapper<>();
-
-        capability.execute(buildRequestArgs(CANCEL), buildActionCallback(onSuccessInvoked2));
-
-        assertThat(onSuccessInvoked2.getFuture().get(CB_TIMEOUT, MILLISECONDS)).isTrue();
-        assertThat(capability.getAppAction())
-                .isEqualTo(
-                        AppAction.newBuilder()
-                                .setName("actions.intent.TEST")
-                                .setIdentifier("id")
-                                .addParams(
-                                        IntentParameter.newBuilder()
-                                                .setName("required")
-                                                .setIsRequired(true))
-                                .setTaskInfo(
-                                        TaskInfo.newBuilder().setSupportsPartialFulfillment(true))
-                                .build());
-        assertThat(taskUpdaterCb.getFuture().get(CB_TIMEOUT, MILLISECONDS).isDestroyed()).isTrue();
-    }
-
-    @Test
-    public void fulfillmentType_unknown_errorReported() throws Exception {
-        ActionCapability capability =
-                createTaskCapability(
-                        SINGLE_REQUIRED_FIELD_PROPERTY,
-                        TaskParamRegistry.builder().build(),
-                        RequiredTaskUpdater::new,
-                        Optional.empty(),
-                        EMPTY_CONFIRM_LISTENER,
-                        EMPTY_FINISH_LISTENER);
-
-        // TURN 1 (UNKNOWN).
-        SettableFutureWrapper<ErrorStatusInternal> errorCb = new SettableFutureWrapper<>();
-
-        capability.execute(buildRequestArgs(UNKNOWN_TYPE), buildErrorActionCallback(errorCb));
-
-        assertThat(errorCb.getFuture().get(CB_TIMEOUT, MILLISECONDS))
-                .isEqualTo(ErrorStatusInternal.INVALID_REQUEST_TYPE);
-        assertThat(capability.getAppAction())
-                .isEqualTo(
-                        AppAction.newBuilder()
-                                .setName("actions.intent.TEST")
-                                .setIdentifier("id")
-                                .addParams(
-                                        IntentParameter.newBuilder()
-                                                .setName("required")
-                                                .setIsRequired(true))
-                                .setTaskInfo(
-                                        TaskInfo.newBuilder().setSupportsPartialFulfillment(true))
-                                .build());
-
-        // TURN 2 (UNRECOGNIZED).
-        SettableFutureWrapper<ErrorStatusInternal> errorCb2 = new SettableFutureWrapper<>();
-
-        capability.execute(buildRequestArgs(UNRECOGNIZED), buildErrorActionCallback(errorCb2));
-
-        assertThat(errorCb2.getFuture().get(CB_TIMEOUT, MILLISECONDS))
-                .isEqualTo(ErrorStatusInternal.INVALID_REQUEST_TYPE);
-        assertThat(capability.getAppAction())
-                .isEqualTo(
-                        AppAction.newBuilder()
-                                .setName("actions.intent.TEST")
-                                .setIdentifier("id")
-                                .addParams(
-                                        IntentParameter.newBuilder()
-                                                .setName("required")
-                                                .setIsRequired(true))
-                                .setTaskInfo(
-                                        TaskInfo.newBuilder().setSupportsPartialFulfillment(true))
-                                .build());
-    }
-
-    @Test
-    public void slotFilling_optionalButRejectedParam_onFinishNotInvoked() throws Exception {
-        AtomicInteger onFinishInvocationCount = new AtomicInteger(0);
-        CapabilityTwoEntityValues.Property property =
-                CapabilityTwoEntityValues.Property.newBuilder()
-                        .setSlotA(EntityProperty.newBuilder().setIsRequired(true).build())
-                        .setSlotB(EntityProperty.newBuilder().setIsRequired(false).build())
-                        .build();
-        TaskParamRegistry paramRegistry =
-                TaskParamRegistry.builder()
-                        .addTaskParameter(
-                                "slotA",
-                                TaskCapabilityImplTest::groundingPredicate,
-                                AUTO_ACCEPT_ENTITY_VALUE,
-                                DISAMBIG_ENTITY_CONVERTER,
-                                Optional.of(
-                                        unused -> SearchAction.<EntityValue>newBuilder().build()),
-                                TypeConverters::toEntityValue)
-                        .addTaskParameter(
-                                "slotB",
-                                TaskCapabilityImplTest::groundingPredicate,
-                                AUTO_REJECT_ENTITY_VALUE,
-                                DISAMBIG_ENTITY_CONVERTER,
-                                Optional.of(
-                                        unused -> SearchAction.<EntityValue>newBuilder().build()),
-                                TypeConverters::toEntityValue)
-                        .build();
-        TaskCapabilityImpl<
-                        CapabilityTwoEntityValues.Property,
-                        CapabilityTwoEntityValues.Argument,
-                        Void,
-                        Void,
-                        EmptyTaskUpdater>
-                capability =
-                        new TaskCapabilityImpl<>(
-                                "fakeId",
-                                CapabilityTwoEntityValues.ACTION_SPEC,
-                                property,
-                                paramRegistry,
-                                /* onInitListener= */ Optional.empty(),
-                                /* onReadyToConfirmListener= */ Optional.empty(),
-                                (finalArgs) -> {
-                                    onFinishInvocationCount.incrementAndGet();
-                                    return Futures.immediateFuture(
-                                            ExecutionResult.getDefaultInstance());
-                                },
-                                /* confirmationOutputBindings= */ Collections.emptyMap(),
-                                /* executionOutputBindings= */ Collections.emptyMap(),
-                                Runnable::run);
-        capability.setTaskUpdaterSupplier(EmptyTaskUpdater::new);
-
-        // TURN 1.
-        SettableFutureWrapper<Boolean> onSuccessInvoked = new SettableFutureWrapper<>();
-        capability.execute(
-                buildRequestArgs(
-                        SYNC,
-                        "slotA",
-                        ParamValue.newBuilder().setIdentifier("foo").setStringValue("foo").build(),
-                        "slotB",
-                        ParamValue.newBuilder().setIdentifier("bar").setStringValue("bar").build()),
-                buildActionCallback(onSuccessInvoked));
-
-        assertThat(onSuccessInvoked.getFuture().get(CB_TIMEOUT, MILLISECONDS)).isTrue();
-        assertThat(onFinishInvocationCount.get()).isEqualTo(0);
-        assertThat(getCurrentValues("slotA", capability.getAppAction()))
-                .containsExactly(
-                        CurrentValue.newBuilder()
-                                .setValue(
-                                        ParamValue.newBuilder()
-                                                .setIdentifier("foo")
-                                                .setStringValue("foo"))
-                                .setStatus(CurrentValue.Status.ACCEPTED)
-                                .build());
-        assertThat(getCurrentValues("slotB", capability.getAppAction()))
-                .containsExactly(
-                        CurrentValue.newBuilder()
-                                .setValue(
-                                        ParamValue.newBuilder()
-                                                .setIdentifier("bar")
-                                                .setStringValue("bar"))
-                                .setStatus(CurrentValue.Status.REJECTED)
-                                .build());
-    }
-
-    @Test
-    public void slotFilling_assistantRemovedParam_clearInSdkState() throws Exception {
-        Property property =
-                Property.newBuilder()
-                        .setRequiredEntityField(
-                                EntityProperty.newBuilder().setIsRequired(true).build())
-                        .setEnumField(
-                                EnumProperty.newBuilder(TestEnum.class)
-                                        .addSupportedEnumValues(TestEnum.VALUE_1, TestEnum.VALUE_2)
-                                        .setIsRequired(true)
-                                        .build())
-                        .build();
-        ActionCapability capability =
-                createTaskCapability(
-                        property,
-                        TaskParamRegistry.builder().build(),
-                        RequiredTaskUpdater::new,
-                        Optional.empty(),
-                        EMPTY_CONFIRM_LISTENER,
-                        EMPTY_FINISH_LISTENER);
-
-        // TURN 1.
-        SettableFutureWrapper<Boolean> onSuccessInvoked = new SettableFutureWrapper<>();
-
-        capability.execute(
-                buildRequestArgs(
-                        SYNC,
-                        "required",
-                        ParamValue.newBuilder().setIdentifier("foo").setStringValue("foo").build()),
-                buildActionCallback(onSuccessInvoked));
-
-        assertThat(onSuccessInvoked.getFuture().get(CB_TIMEOUT, MILLISECONDS)).isTrue();
-        assertThat(getCurrentValues("required", capability.getAppAction()))
-                .containsExactly(
-                        CurrentValue.newBuilder()
-                                .setValue(
-                                        ParamValue.newBuilder()
-                                                .setIdentifier("foo")
-                                                .setStringValue("foo"))
-                                .setStatus(CurrentValue.Status.ACCEPTED)
-                                .build());
-        assertThat(getCurrentValues("optionalEnum", capability.getAppAction())).isEmpty();
-
-        // TURN 2.
-        SettableFutureWrapper<Boolean> onSuccessInvoked2 = new SettableFutureWrapper<>();
-
-        capability.execute(
-                buildRequestArgs(SYNC, "optionalEnum", TestEnum.VALUE_2),
-                buildActionCallback(onSuccessInvoked2));
-
-        assertThat(onSuccessInvoked2.getFuture().get(CB_TIMEOUT, MILLISECONDS)).isTrue();
-        assertThat(getCurrentValues("required", capability.getAppAction())).isEmpty();
-        assertThat(getCurrentValues("optionalEnum", capability.getAppAction()))
-                .containsExactly(
-                        CurrentValue.newBuilder()
-                                .setValue(ParamValue.newBuilder().setIdentifier("VALUE_2"))
-                                .setStatus(CurrentValue.Status.ACCEPTED)
-                                .build());
-    }
-
-    @Test
-    public void disambig_singleParam_disambigEntitiesInContext() throws Exception {
-        TaskParamRegistry.Builder paramRegistry = TaskParamRegistry.builder();
-        paramRegistry.addTaskParameter(
-                "required",
-                TaskCapabilityImplTest::groundingPredicate,
-                GenericResolverInternal.fromAppEntityResolver(
-                        new AppEntityResolver<EntityValue>() {
-                            @Override
-                            public ListenableFuture<EntitySearchResult<EntityValue>>
-                                    lookupAndRender(SearchAction<EntityValue> searchAction) {
-                                EntitySearchResult.Builder<EntityValue> result =
-                                        EntitySearchResult.newBuilder();
-                                return Futures.immediateFuture(
-                                        result.addPossibleValue(EntityValue.ofId("valid1"))
-                                                .addPossibleValue(EntityValue.ofId("valid2"))
-                                                .build());
-                            }
-
-                            @NonNull
-                            @Override
-                            public ListenableFuture<ValidationResult> onReceived(
-                                    EntityValue newValue) {
-                                return Futures.immediateFuture(ValidationResult.newAccepted());
-                            }
-                        }),
-                DISAMBIG_ENTITY_CONVERTER,
-                Optional.of(unused -> SearchAction.<EntityValue>newBuilder().build()),
-                TypeConverters::toEntityValue);
-        ActionCapability capability =
-                createTaskCapability(
-                        SINGLE_REQUIRED_FIELD_PROPERTY,
-                        paramRegistry.build(),
-                        RequiredTaskUpdater::new,
-                        Optional.empty(),
-                        EMPTY_CONFIRM_LISTENER,
-                        EMPTY_FINISH_LISTENER);
-
-        // TURN 1.
-        SettableFutureWrapper<Boolean> onSuccessInvoked = new SettableFutureWrapper<>();
-
-        capability.execute(
-                buildRequestArgs(SYNC, "required", buildSearchActionParamValue("invalid")),
-                buildActionCallback(onSuccessInvoked));
-
-        assertThat(onSuccessInvoked.getFuture().get(CB_TIMEOUT, MILLISECONDS)).isTrue();
-        assertThat(capability.getAppAction())
-                .isEqualTo(
-                        AppAction.newBuilder()
-                                .setName("actions.intent.TEST")
-                                .setIdentifier("id")
-                                .addParams(
-                                        IntentParameter.newBuilder()
-                                                .setName("required")
-                                                .setIsRequired(true)
-                                                .addCurrentValue(
-                                                        CurrentValue.newBuilder()
-                                                                .setValue(
-                                                                        buildSearchActionParamValue(
-                                                                                "invalid"))
-                                                                .setStatus(
-                                                                        CurrentValue.Status
-                                                                                .DISAMBIG)
-                                                                .setDisambiguationData(
-                                                                        DisambiguationData
-                                                                                .newBuilder()
-                                                                                .addEntities(
-                                                                                        Entity
-                                                                                                .newBuilder()
-                                                                                                .setIdentifier(
-                                                                                                        "valid1")
-                                                                                                .setName(
-                                                                                                        "valid1"))
-                                                                                .addEntities(
-                                                                                        Entity
-                                                                                                .newBuilder()
-                                                                                                .setIdentifier(
-                                                                                                        "valid2")
-                                                                                                .setName(
-                                                                                                        "valid2")))))
-                                .setTaskInfo(
-                                        TaskInfo.newBuilder().setSupportsPartialFulfillment(true))
-                                .build());
-
-        // TURN 2.
-        SettableFutureWrapper<Boolean> turn2SuccessInvoked = new SettableFutureWrapper<>();
-
-        capability.execute(
-                buildRequestArgs(
-                        SYNC,
-                        "required",
-                        ParamValue.newBuilder()
-                                .setIdentifier("valid2")
-                                .setStringValue("valid2")
-                                .build()),
-                buildActionCallback(turn2SuccessInvoked));
-
-        assertThat(turn2SuccessInvoked.getFuture().get(CB_TIMEOUT, MILLISECONDS)).isTrue();
-        assertThat(capability.getAppAction())
-                .isEqualTo(
-                        AppAction.newBuilder()
-                                .setName("actions.intent.TEST")
-                                .setIdentifier("id")
-                                .addParams(
-                                        IntentParameter.newBuilder()
-                                                .setName("required")
-                                                .setIsRequired(true)
-                                                .addCurrentValue(
-                                                        CurrentValue.newBuilder()
-                                                                .setValue(
-                                                                        ParamValue.newBuilder()
-                                                                                .setIdentifier(
-                                                                                        "valid2")
-                                                                                .setStringValue(
-                                                                                        "valid2"))
-                                                                .setStatus(
-                                                                        CurrentValue.Status
-                                                                                .ACCEPTED)))
-                                .setTaskInfo(
-                                        TaskInfo.newBuilder().setSupportsPartialFulfillment(true))
-                                .build());
-    }
-
-    /**
-     * Assistant sends grounded objects as identifier only, but we need to mark the entire value
-     * struct as accepted.
-     */
-    @Test
-    public void identifierOnly_refillsStruct() throws Exception {
-        ListItem item1 = ListItem.newBuilder().setName("red apple").setId("item1").build();
-        ListItem item2 = ListItem.newBuilder().setName("green apple").setId("item2").build();
-        SettableFutureWrapper<ListItem> onReceivedCb = new SettableFutureWrapper<>();
-        CapabilityStructFill.Property property =
-                CapabilityStructFill.Property.newBuilder()
-                        .setItemList(SimpleProperty.REQUIRED)
-                        .setAnyString(StringProperty.newBuilder().setIsRequired(true).build())
-                        .build();
-        TaskParamRegistry.Builder paramRegistryBuilder = TaskParamRegistry.builder();
-        paramRegistryBuilder.addTaskParameter(
-                "listItem",
-                TaskCapabilityImplTest::groundingPredicate,
-                GenericResolverInternal.fromAppEntityResolver(
-                        new AppEntityResolver<ListItem>() {
-                            @NonNull
-                            @Override
-                            public ListenableFuture<ValidationResult> onReceived(
-                                    ListItem listItem) {
-                                onReceivedCb.set(listItem);
-                                return Futures.immediateFuture(ValidationResult.newAccepted());
-                            }
-
-                            @Override
-                            public ListenableFuture<EntitySearchResult<ListItem>> lookupAndRender(
-                                    SearchAction<ListItem> searchAction) {
-                                return Futures.immediateFuture(
-                                        EntitySearchResult.<ListItem>newBuilder()
-                                                .addPossibleValue(item1)
-                                                .addPossibleValue(item2)
-                                                .build());
-                            }
-                        }),
-                Optional.of((DisambigEntityConverter<ListItem>) TypeConverters::toEntity),
-                Optional.of(unused -> SearchAction.<ListItem>newBuilder().build()),
-                TypeConverters::toListItem);
-        SettableFutureWrapper<ListItem> onFinishListItemCb = new SettableFutureWrapper<>();
-        SettableFutureWrapper<String> onFinishStringCb = new SettableFutureWrapper<>();
-        TaskCapabilityImpl<
-                        CapabilityStructFill.Property,
-                        CapabilityStructFill.Argument,
-                        Void,
-                        Void,
-                        EmptyTaskUpdater>
-                capability =
-                        new TaskCapabilityImpl<>(
-                                "selectListItem",
-                                CapabilityStructFill.ACTION_SPEC,
-                                property,
-                                paramRegistryBuilder.build(),
-                                /* onInitListener= */ Optional.empty(),
-                                /* onReadyToConfirmListener= */ Optional.empty(),
-                                (argument) -> {
-                                    ListItem listItem = argument.listItem().orElse(null);
-                                    String string = argument.anyString().orElse(null);
-                                    onFinishListItemCb.set(listItem);
-                                    onFinishStringCb.set(string);
-                                    return Futures.immediateFuture(
-                                            ExecutionResult.getDefaultInstance());
-                                },
-                                /* confirmationOutputBindings= */ Collections.emptyMap(),
-                                /* executionOutputBindings= */ Collections.emptyMap(),
-                                Runnable::run);
-        capability.setTaskUpdaterSupplier(EmptyTaskUpdater::new);
-
-        // first sync request
-        SettableFutureWrapper<Boolean> firstTurnSuccess = new SettableFutureWrapper<>();
-        capability.execute(
-                buildRequestArgs(SYNC, "listItem", buildSearchActionParamValue("apple")),
-                buildActionCallback(firstTurnSuccess));
-        assertThat(firstTurnSuccess.getFuture().get(CB_TIMEOUT, MILLISECONDS)).isTrue();
-        assertThat(onReceivedCb.getFuture().isDone()).isFalse();
-        assertThat(onFinishListItemCb.getFuture().isDone()).isFalse();
-        assertThat(capability.getAppAction())
-                .isEqualTo(
-                        AppAction.newBuilder()
-                                .setName("actions.intent.TEST")
-                                .setIdentifier("selectListItem")
-                                .addParams(
-                                        IntentParameter.newBuilder()
-                                                .setName("listItem")
-                                                .setIsRequired(true)
-                                                .addCurrentValue(
-                                                        CurrentValue.newBuilder()
-                                                                .setValue(
-                                                                        buildSearchActionParamValue(
-                                                                                "apple"))
-                                                                .setStatus(
-                                                                        CurrentValue.Status
-                                                                                .DISAMBIG)
-                                                                .setDisambiguationData(
-                                                                        DisambiguationData
-                                                                                .newBuilder()
-                                                                                .addEntities(
-                                                                                        TypeConverters
-                                                                                                .toEntity(
-                                                                                                        item1))
-                                                                                .addEntities(
-                                                                                        TypeConverters
-                                                                                                .toEntity(
-                                                                                                        item2))
-                                                                                .build())
-                                                                .build())
-                                                .build())
-                                .addParams(
-                                        IntentParameter.newBuilder()
-                                                .setName("string")
-                                                .setIsRequired(true)
-                                                .build())
-                                .setTaskInfo(
-                                        TaskInfo.newBuilder().setSupportsPartialFulfillment(true))
-                                .build());
-
-        // second sync request, sending grounded ParamValue with identifier only
-        SettableFutureWrapper<Boolean> secondTurnSuccess = new SettableFutureWrapper<>();
-        capability.execute(
-                buildRequestArgs(
-                        SYNC, "listItem", ParamValue.newBuilder().setIdentifier("item2").build()),
-                buildActionCallback(secondTurnSuccess));
-        assertThat(secondTurnSuccess.getFuture().get(CB_TIMEOUT, MILLISECONDS)).isTrue();
-        assertThat(onReceivedCb.getFuture().get(CB_TIMEOUT, MILLISECONDS)).isEqualTo(item2);
-        assertThat(onFinishListItemCb.getFuture().isDone()).isFalse();
-
-        // third sync request, sending grounded ParamValue with identifier only, completes task
-        SettableFutureWrapper<Boolean> thirdTurnSuccess = new SettableFutureWrapper<>();
-        capability.execute(
-                buildRequestArgs(
-                        SYNC,
-                        "listItem",
-                        ParamValue.newBuilder().setIdentifier("item2").build(),
-                        "string",
-                        "unused"),
-                buildActionCallback(thirdTurnSuccess));
-        assertThat(thirdTurnSuccess.getFuture().get(CB_TIMEOUT, MILLISECONDS)).isTrue();
-        assertThat(onFinishListItemCb.getFuture().get(CB_TIMEOUT, MILLISECONDS)).isEqualTo(item2);
-        assertThat(onFinishStringCb.getFuture().get(CB_TIMEOUT, MILLISECONDS)).isEqualTo("unused");
-    }
-
-    @Test
-    public void executionResult_resultReturned() throws Exception {
-        OnDialogFinishListener<Argument, Output> finishListener =
-                (argument) ->
-                        Futures.immediateFuture(
-                                new ExecutionResult.Builder<Output>()
-                                        .setOutput(
-                                                Output.builder()
-                                                        .setOptionalStringField("bar")
-                                                        .setRepeatedStringField(
-                                                                Arrays.asList("bar1", "bar2"))
-                                                        .build())
-                                        .build());
-        ActionCapability capability =
-                (ActionCapability)
-                        new CapabilityBuilder()
-                                .setTaskHandlerBuilder(
-                                        new TaskHandlerBuilder()
-                                                .setOnFinishListener(finishListener))
-                                .build();
-        SettableFutureWrapper<FulfillmentResponse> onSuccessInvoked = new SettableFutureWrapper<>();
-        StructuredOutput expectedOutput =
-                StructuredOutput.newBuilder()
-                        .addOutputValues(
-                                OutputValue.newBuilder()
-                                        .setName("optionalStringOutput")
-                                        .addValues(
-                                                ParamValue.newBuilder()
-                                                        .setStringValue("bar")
-                                                        .build())
-                                        .build())
-                        .addOutputValues(
-                                OutputValue.newBuilder()
-                                        .setName("repeatedStringOutput")
-                                        .addValues(
-                                                ParamValue.newBuilder()
-                                                        .setStringValue("bar1")
-                                                        .build())
-                                        .addValues(
-                                                ParamValue.newBuilder()
-                                                        .setStringValue("bar2")
-                                                        .build())
-                                        .build())
-                        .build();
-
-        capability.execute(
-                buildRequestArgs(
-                        SYNC,
-                        /* args...= */ "required",
-                        ParamValue.newBuilder().setIdentifier("foo").setStringValue("foo").build()),
-                buildActionCallbackWithFulfillmentResponse(onSuccessInvoked));
-
-        assertThat(
-                        onSuccessInvoked
-                                .getFuture()
-                                .get(CB_TIMEOUT, MILLISECONDS)
-                                .getExecutionOutput()
-                                .getOutputValuesList())
-                .containsExactlyElementsIn(expectedOutput.getOutputValuesList());
-    }
-
-    @Test
-    public void touchEvent_fillOnlySlot_onFinishInvoked() throws Exception {
-        EntityValue slotValue = EntityValue.newBuilder().setId("id1").setValue("value").build();
-        SettableFutureWrapper<RequiredTaskUpdater> updaterFuture = new SettableFutureWrapper<>();
-        SettableFutureWrapper<Argument> onFinishFuture = new SettableFutureWrapper<>();
-        SettableFutureWrapper<FulfillmentResponse> touchEventResponse =
-                new SettableFutureWrapper<>();
-
-        ActionCapability capability =
-                createTaskCapability(
-                        SINGLE_REQUIRED_FIELD_PROPERTY,
-                        TaskParamRegistry.builder().build(),
-                        RequiredTaskUpdater::new,
-                        TestingUtils.buildOnInitListener(updaterFuture),
-                        EMPTY_CONFIRM_LISTENER,
-                        TestingUtils.<Argument, Output>buildOnFinishListener(onFinishFuture));
-        capability.setTouchEventCallback(buildTouchEventCallback(touchEventResponse));
-
-        // Turn 1. No args but capability triggered (value updater should be set now).
-        SettableFutureWrapper<Boolean> onSuccessInvoked = new SettableFutureWrapper<>();
-        capability.execute(buildRequestArgs(SYNC), buildActionCallback(onSuccessInvoked));
-
-        assertThat(onSuccessInvoked.getFuture().get(CB_TIMEOUT, MILLISECONDS)).isTrue();
-        assertThat(onFinishFuture.getFuture().isDone()).isFalse();
-        assertThat(touchEventResponse.getFuture().isDone()).isFalse();
-
-        // Turn 2. Invoke the TaskCapability via the updater.
-        // TaskUpdater should be usable after onFinishListener from the first turn.
-        RequiredTaskUpdater taskUpdater = updaterFuture.getFuture().get(CB_TIMEOUT, MILLISECONDS);
-        taskUpdater.setRequiredEntityValue(slotValue);
-
-        assertThat(
-                        onFinishFuture
-                                .getFuture()
-                                .get(CB_TIMEOUT, MILLISECONDS)
-                                .requiredEntityField()
-                                .get())
-                .isNotNull();
-        assertThat(
-                        onFinishFuture
-                                .getFuture()
-                                .get(CB_TIMEOUT, MILLISECONDS)
-                                .requiredEntityField()
-                                .get())
-                .isEqualTo(slotValue);
-        assertThat(touchEventResponse.getFuture().get(CB_TIMEOUT, MILLISECONDS))
-                .isEqualTo(FulfillmentResponse.getDefaultInstance());
-    }
-
-    @Test
-    public void touchEvent_callbackNotSet_onFinishNotInvoked() throws Exception {
-        EntityValue slotValue = EntityValue.newBuilder().setId("id1").setValue("value").build();
-        SettableFutureWrapper<RequiredTaskUpdater> updaterFuture = new SettableFutureWrapper<>();
-        SettableFutureWrapper<Argument> onFinishFuture = new SettableFutureWrapper<>();
-        ActionCapability capability =
-                createTaskCapability(
-                        SINGLE_REQUIRED_FIELD_PROPERTY,
-                        TaskParamRegistry.builder().build(),
-                        RequiredTaskUpdater::new,
-                        TestingUtils.buildOnInitListener(updaterFuture),
-                        EMPTY_CONFIRM_LISTENER,
-                        TestingUtils.<Argument, Output>buildOnFinishListener(onFinishFuture));
-        // Explicitly set to null for testing.
-        capability.setTouchEventCallback(null);
-
-        // Turn 1. No args but capability triggered (value updater should be set now).
-        SettableFutureWrapper<Boolean> onSuccessInvoked = new SettableFutureWrapper<>();
-        capability.execute(buildRequestArgs(SYNC), buildActionCallback(onSuccessInvoked));
-
-        assertThat(onSuccessInvoked.getFuture().get(CB_TIMEOUT, MILLISECONDS)).isTrue();
-        assertThat(onFinishFuture.getFuture().isDone()).isFalse();
-
-        // Turn 2. Invoke the TaskCapability via the updater.
-        RequiredTaskUpdater taskUpdater = updaterFuture.getFuture().get(CB_TIMEOUT, MILLISECONDS);
-        taskUpdater.setRequiredEntityValue(slotValue);
-
-        assertThat(onFinishFuture.getFuture().isDone()).isFalse();
-    }
-
-    @Test
-    public void touchEvent_emptyValues_onFinishNotInvoked() throws Exception {
-        SettableFutureWrapper<RequiredTaskUpdater> updaterFuture = new SettableFutureWrapper<>();
-        SettableFutureWrapper<Argument> onFinishFuture = new SettableFutureWrapper<>();
-        SettableFutureWrapper<FulfillmentResponse> touchEventResponse =
-                new SettableFutureWrapper<>();
-        TaskCapabilityImpl<Property, Argument, Output, Confirmation, RequiredTaskUpdater>
-                capability =
-                        createTaskCapability(
-                                SINGLE_REQUIRED_FIELD_PROPERTY,
-                                TaskParamRegistry.builder().build(),
-                                RequiredTaskUpdater::new,
-                                TestingUtils.buildOnInitListener(updaterFuture),
-                                EMPTY_CONFIRM_LISTENER,
-                                TestingUtils.<Argument, Output>buildOnFinishListener(
-                                        onFinishFuture));
-        capability.setTouchEventCallback(buildTouchEventCallback(touchEventResponse));
-
-        // Turn 1. No args but capability triggered (value updater should be set now).
-        SettableFutureWrapper<Boolean> onSuccessInvoked = new SettableFutureWrapper<>();
-        capability.execute(buildRequestArgs(SYNC), buildActionCallback(onSuccessInvoked));
-
-        assertThat(onSuccessInvoked.getFuture().get(CB_TIMEOUT, MILLISECONDS)).isTrue();
-        assertThat(onFinishFuture.getFuture().isDone()).isFalse();
-        assertThat(touchEventResponse.getFuture().isDone()).isFalse();
-
-        // Turn 2. Invoke the TaskCapability via the updater.
-        // TaskUpdater should be usable after onFinishListener from the first turn.
-        capability.updateParamValues(Collections.emptyMap());
-
-        assertThat(onFinishFuture.getFuture().isDone()).isFalse();
-        assertThat(touchEventResponse.getFuture().isDone()).isFalse();
-    }
-
-    @Test
-    public void touchEvent_fillOnlySlot_confirmationRequired_onReadyToConfirmInvoked()
-            throws Exception {
-        EntityValue slotValue = EntityValue.newBuilder().setId("id1").setValue("value").build();
-        SettableFutureWrapper<RequiredTaskUpdater> updaterFuture = new SettableFutureWrapper<>();
-        SettableFutureWrapper<Argument> onReadyToConfirmFuture = new SettableFutureWrapper<>();
-        SettableFutureWrapper<Argument> onFinishFuture = new SettableFutureWrapper<>();
-        SettableFutureWrapper<FulfillmentResponse> touchEventResponse =
-                new SettableFutureWrapper<>();
-
-        TaskCapabilityImpl<Property, Argument, Output, Confirmation, RequiredTaskUpdater>
-                capability =
-                        createTaskCapability(
-                                SINGLE_REQUIRED_FIELD_PROPERTY,
-                                TaskParamRegistry.builder().build(),
-                                RequiredTaskUpdater::new,
-                                TestingUtils.buildOnInitListener(updaterFuture),
-                                TestingUtils.<Argument, Confirmation>buildOnReadyToConfirmListener(
-                                        onReadyToConfirmFuture),
-                                TestingUtils.<Argument, Output>buildOnFinishListener(
-                                        onFinishFuture));
-        capability.setTouchEventCallback(buildTouchEventCallback(touchEventResponse));
-
-        // Turn 1. No args but capability triggered (value updater should be set now).
-        SettableFutureWrapper<Boolean> onSuccessInvoked = new SettableFutureWrapper<>();
-        capability.execute(buildRequestArgs(SYNC), buildActionCallback(onSuccessInvoked));
-
-        assertThat(onSuccessInvoked.getFuture().get(CB_TIMEOUT, MILLISECONDS)).isTrue();
-        assertThat(onReadyToConfirmFuture.getFuture().isDone()).isFalse();
-        assertThat(onFinishFuture.getFuture().isDone()).isFalse();
-        assertThat(touchEventResponse.getFuture().isDone()).isFalse();
-
-        // Turn 2. Invoke the TaskCapability via the updater.
-        // TaskUpdater should be usable after onReadyToConfirmListener from the first turn.
-        RequiredTaskUpdater taskUpdater = updaterFuture.getFuture().get(CB_TIMEOUT, MILLISECONDS);
-        taskUpdater.setRequiredEntityValue(slotValue);
-
-        assertThat(onFinishFuture.getFuture().isDone()).isFalse();
-        assertThat(
-                        onReadyToConfirmFuture
-                                .getFuture()
-                                .get(CB_TIMEOUT, MILLISECONDS)
-                                .requiredEntityField()
-                                .get())
-                .isNotNull();
-        assertThat(
-                        onReadyToConfirmFuture
-                                .getFuture()
-                                .get(CB_TIMEOUT, MILLISECONDS)
-                                .requiredEntityField()
-                                .get())
-                .isEqualTo(slotValue);
-        assertThat(touchEventResponse.getFuture().get(CB_TIMEOUT, MILLISECONDS))
-                .isEqualTo(FulfillmentResponse.getDefaultInstance());
-    }
-
-    @Test
-    public void requiredConfirmation_throwsExecptionWhenConfirmationListenerIsNotSet()
-            throws Exception {
-        InvalidTaskException exception =
-                assertThrows(
-                        InvalidTaskException.class,
-                        () ->
-                                new CapabilityBuilderWithRequiredConfirmation()
-                                        .setTaskHandlerBuilder(
-                                                new TaskHandlerBuilderWithRequiredConfirmation())
-                                        .build());
-
-        assertThat(exception)
-                .hasMessageThat()
-                .contains("ConfirmationType is REQUIRED, but onReadyToConfirmListener is not set.");
-    }
-
-    @Test
-    public void confirmationNotSupported_throwsExecptionWhenConfirmationListenerIsSet()
-            throws Exception {
-
-        OnReadyToConfirmListenerInternal<Confirmation> onReadyToConfirmListener =
-                (args) ->
-                        Futures.immediateFuture(
-                                new ConfirmationOutput.Builder<Confirmation>()
-                                        .setConfirmation(
-                                                Confirmation.builder()
-                                                        .setOptionalStringField("bar")
-                                                        .build())
-                                        .build());
-
-        InvalidTaskException exception =
-                assertThrows(
-                        InvalidTaskException.class,
-                        () ->
-                                new CapabilityBuilder()
-                                        .setTaskHandlerBuilder(
-                                                new TaskHandlerBuilder()
-                                                        .setOnReadyToConfirmListener(
-                                                                onReadyToConfirmListener))
-                                        .build());
-
-        assertThat(exception)
-                .hasMessageThat()
-                .contains(
-                        "ConfirmationType is NOT_SUPPORTED, but onReadyToConfirmListener is set.");
-    }
-
-    @Test
-    public void confirmationOutput_resultReturned() throws Exception {
-        OnReadyToConfirmListenerInternal<Confirmation> onReadyToConfirmListener =
-                (args) ->
-                        Futures.immediateFuture(
-                                new ConfirmationOutput.Builder<Confirmation>()
-                                        .setConfirmation(
-                                                Confirmation.builder()
-                                                        .setOptionalStringField("bar")
-                                                        .build())
-                                        .build());
-        OnDialogFinishListener<Argument, Output> finishListener =
-                (argument) ->
-                        Futures.immediateFuture(
-                                new ExecutionResult.Builder<Output>()
-                                        .setOutput(
-                                                Output.builder()
-                                                        .setOptionalStringField("baz")
-                                                        .setRepeatedStringField(
-                                                                Arrays.asList("baz1", "baz2"))
-                                                        .build())
-                                        .build());
-        ActionCapability capability =
-                (ActionCapability)
-                        new CapabilityBuilderWithRequiredConfirmation()
-                                .setTaskHandlerBuilder(
-                                        new TaskHandlerBuilderWithRequiredConfirmation()
-                                                .setOnReadyToConfirmListener(
-                                                        onReadyToConfirmListener)
-                                                .setOnFinishListener(finishListener))
-                                .build();
-        SettableFutureWrapper<FulfillmentResponse> onSuccessInvoked = new SettableFutureWrapper<>();
-        StructuredOutput expectedOutput =
-                StructuredOutput.newBuilder()
-                        .addOutputValues(
-                                OutputValue.newBuilder()
-                                        .setName("optionalStringOutput")
-                                        .addValues(
-                                                ParamValue.newBuilder()
-                                                        .setStringValue("bar")
-                                                        .build())
-                                        .build())
-                        .build();
-
-        capability.execute(
-                buildRequestArgs(
-                        SYNC,
-                        /* args...= */ "required",
-                        ParamValue.newBuilder().setIdentifier("foo").setStringValue("foo").build()),
-                buildActionCallbackWithFulfillmentResponse(onSuccessInvoked));
-
-        assertThat(
-                        onSuccessInvoked
-                                .getFuture()
-                                .get(CB_TIMEOUT, MILLISECONDS)
-                                .getConfirmationData()
-                                .getOutputValuesList())
-                .containsExactlyElementsIn(expectedOutput.getOutputValuesList());
-    }
-
-    @Test
-    public void executionResult_resultReturnedAfterConfirm() throws Exception {
-        // Build the capability
-        OnReadyToConfirmListenerInternal<Confirmation> onReadyToConfirmListener =
-                (args) ->
-                        Futures.immediateFuture(
-                                new ConfirmationOutput.Builder<Confirmation>()
-                                        .setConfirmation(
-                                                Confirmation.builder()
-                                                        .setOptionalStringField("bar")
-                                                        .build())
-                                        .build());
-        OnDialogFinishListener<Argument, Output> finishListener =
-                (argument) ->
-                        Futures.immediateFuture(
-                                new ExecutionResult.Builder<Output>()
-                                        .setOutput(
-                                                Output.builder()
-                                                        .setOptionalStringField("baz")
-                                                        .setRepeatedStringField(
-                                                                Arrays.asList("baz1", "baz2"))
-                                                        .build())
-                                        .build());
-        ActionCapability capability =
-                (ActionCapability)
-                        new CapabilityBuilderWithRequiredConfirmation()
-                                .setTaskHandlerBuilder(
-                                        new TaskHandlerBuilderWithRequiredConfirmation()
-                                                .setOnReadyToConfirmListener(
-                                                        onReadyToConfirmListener)
-                                                .setOnFinishListener(finishListener))
-                                .build();
-        SettableFutureWrapper<FulfillmentResponse> onSuccessInvokedFirstTurn =
-                new SettableFutureWrapper<>();
-
-        // Send a sync request that triggers confirmation
-        capability.execute(
-                buildRequestArgs(
-                        SYNC,
-                        /* args...= */ "required",
-                        ParamValue.newBuilder().setIdentifier("foo").setStringValue("foo").build()),
-                buildActionCallbackWithFulfillmentResponse(onSuccessInvokedFirstTurn));
-
-        // Confirm the BIC
-        StructuredOutput expectedConfirmationOutput =
-                StructuredOutput.newBuilder()
-                        .addOutputValues(
-                                OutputValue.newBuilder()
-                                        .setName("optionalStringOutput")
-                                        .addValues(
-                                                ParamValue.newBuilder()
-                                                        .setStringValue("bar")
-                                                        .build())
-                                        .build())
-                        .build();
-        assertThat(
-                        onSuccessInvokedFirstTurn
-                                .getFuture()
-                                .get(CB_TIMEOUT, MILLISECONDS)
-                                .getConfirmationData()
-                                .getOutputValuesList())
-                .containsExactlyElementsIn(expectedConfirmationOutput.getOutputValuesList());
-
-        // Send a CONFIRM request which indicates the user confirmed the BIC. This triggers
-        // onFinish.
-        SettableFutureWrapper<FulfillmentResponse> onSuccessInvokedSecondTurn =
-                new SettableFutureWrapper<>();
-        capability.execute(
-                buildRequestArgs(CONFIRM),
-                buildActionCallbackWithFulfillmentResponse(onSuccessInvokedSecondTurn));
-
-        // Confirm the BIO
-        StructuredOutput expectedOutput =
-                StructuredOutput.newBuilder()
-                        .addOutputValues(
-                                OutputValue.newBuilder()
-                                        .setName("optionalStringOutput")
-                                        .addValues(
-                                                ParamValue.newBuilder()
-                                                        .setStringValue("baz")
-                                                        .build())
-                                        .build())
-                        .addOutputValues(
-                                OutputValue.newBuilder()
-                                        .setName("repeatedStringOutput")
-                                        .addValues(
-                                                ParamValue.newBuilder()
-                                                        .setStringValue("baz1")
-                                                        .build())
-                                        .addValues(
-                                                ParamValue.newBuilder()
-                                                        .setStringValue("baz2")
-                                                        .build())
-                                        .build())
-                        .build();
-        assertThat(
-                        onSuccessInvokedSecondTurn
-                                .getFuture()
-                                .get(CB_TIMEOUT, MILLISECONDS)
-                                .getExecutionOutput()
-                                .getOutputValuesList())
-                .containsExactlyElementsIn(expectedOutput.getOutputValuesList());
-
-        // send TERMINATE request after CONFIRM
-        SettableFutureWrapper<Boolean> terminateCb = new SettableFutureWrapper<>();
-        capability.execute(buildRequestArgs(TERMINATE), buildActionCallback(terminateCb));
-
-        assertThat(terminateCb.getFuture().get(CB_TIMEOUT, MILLISECONDS)).isTrue();
-    }
-
-    @Test
-    public void concurrentRequests_prioritizeAssistantRequest() throws Exception {
-        CapabilityTwoStrings.Property property =
-                CapabilityTwoStrings.Property.newBuilder()
-                        .setStringSlotA(StringProperty.newBuilder().setIsRequired(true).build())
-                        .setStringSlotB(StringProperty.newBuilder().setIsRequired(true).build())
-                        .build();
-        DeferredValueListener<String> slotAListener = new DeferredValueListener<>();
-        DeferredValueListener<String> slotBListener = new DeferredValueListener<>();
-        TaskParamRegistry.Builder paramRegistryBuilder = TaskParamRegistry.builder();
-        paramRegistryBuilder.addTaskParameter(
-                "stringSlotA",
-                unused -> false,
-                GenericResolverInternal.fromValueListener(slotAListener),
-                Optional.empty(),
-                Optional.empty(),
-                TypeConverters::toStringValue);
-        paramRegistryBuilder.addTaskParameter(
-                "stringSlotB",
-                unused -> false,
-                GenericResolverInternal.fromValueListener(slotBListener),
-                Optional.empty(),
-                Optional.empty(),
-                TypeConverters::toStringValue);
-        SettableFutureWrapper<String> onFinishCb = new SettableFutureWrapper<>();
-        TaskCapabilityImpl<
-                        CapabilityTwoStrings.Property,
-                        CapabilityTwoStrings.Argument,
-                        Void,
-                        Void,
-                        EmptyTaskUpdater>
-                taskCapability =
-                        new TaskCapabilityImpl<>(
-                                "myTestCapability",
-                                CapabilityTwoStrings.ACTION_SPEC,
-                                property,
-                                paramRegistryBuilder.build(),
-                                /* onInitListener= */ Optional.empty(),
-                                /* onReadyToConfirmListener= */ Optional.empty(),
-                                (argument) -> {
-                                    String slotA = argument.stringSlotA().orElse(null);
-                                    String slotB = argument.stringSlotB().orElse(null);
-                                    onFinishCb.set(String.format("%s %s", slotA, slotB));
-                                    return Futures.immediateFuture(
-                                            ExecutionResult.getDefaultInstance());
-                                },
-                                new HashMap<>(),
-                                new HashMap<>(),
-                                Runnable::run);
-        taskCapability.setTaskUpdaterSupplier(EmptyTaskUpdater::new);
-        ReusableTouchEventCallback touchEventCallback = new ReusableTouchEventCallback();
-        taskCapability.setTouchEventCallback(touchEventCallback);
-
-        // first assistant request
-        SettableFutureWrapper<FulfillmentResponse> firstTurnResult = new SettableFutureWrapper<>();
-        taskCapability.execute(
-                buildRequestArgs(SYNC, "stringSlotA", "apple"),
-                buildActionCallbackWithFulfillmentResponse(firstTurnResult));
-
-        // manual input request
-        Map<String, List<ParamValue>> touchEventParamValues = new HashMap<>();
-        touchEventParamValues.put(
-                "stringSlotA",
-                Collections.singletonList(ParamValue.newBuilder().setIdentifier("banana").build()));
-        taskCapability.updateParamValues(touchEventParamValues);
-
-        // second assistant request
-        SettableFutureWrapper<FulfillmentResponse> secondTurnResult = new SettableFutureWrapper<>();
-        taskCapability.execute(
-                buildRequestArgs(SYNC, "stringSlotA", "apple", "stringSlotB", "smoothie"),
-                buildActionCallbackWithFulfillmentResponse(secondTurnResult));
-
-        assertThat(firstTurnResult.getFuture().isDone()).isFalse();
-        assertThat(touchEventCallback.getLastResult()).isEqualTo(null);
-
-        // unblock first assistant request
-        slotAListener.setValidationResult(ValidationResult.newAccepted());
-        assertThat(firstTurnResult.getFuture().get(CB_TIMEOUT, MILLISECONDS))
-                .isEqualTo(FulfillmentResponse.getDefaultInstance());
-        assertThat(touchEventCallback.getLastResult()).isEqualTo(null);
-
-        // unblock second assistant request
-        slotBListener.setValidationResult(ValidationResult.newAccepted());
-        assertThat(secondTurnResult.getFuture().get(CB_TIMEOUT, MILLISECONDS))
-                .isEqualTo(FulfillmentResponse.getDefaultInstance());
-
-        assertThat(onFinishCb.getFuture().get(CB_TIMEOUT, MILLISECONDS))
-                .isEqualTo("apple smoothie");
-
-        // since task already finished, the manual input update was ignored.
-        assertThat(touchEventCallback.getLastResult()).isNull();
-    }
-
-    @Test
-    public void concurrentRequests_touchEventFinishesTask() throws Exception {
-        CapabilityTwoStrings.Property property =
-                CapabilityTwoStrings.Property.newBuilder()
-                        .setStringSlotA(StringProperty.newBuilder().setIsRequired(true).build())
-                        .setStringSlotB(StringProperty.newBuilder().setIsRequired(true).build())
-                        .build();
-        DeferredValueListener<String> slotAListener = new DeferredValueListener<>();
-        DeferredValueListener<String> slotBListener = new DeferredValueListener<>();
-        TaskParamRegistry.Builder paramRegistryBuilder = TaskParamRegistry.builder();
-        paramRegistryBuilder.addTaskParameter(
-                "stringSlotA",
-                unused -> false,
-                GenericResolverInternal.fromValueListener(slotAListener),
-                Optional.empty(),
-                Optional.empty(),
-                TypeConverters::toStringValue);
-        paramRegistryBuilder.addTaskParameter(
-                "stringSlotB",
-                unused -> false,
-                GenericResolverInternal.fromValueListener(slotBListener),
-                Optional.empty(),
-                Optional.empty(),
-                TypeConverters::toStringValue);
-        SettableFutureWrapper<String> onFinishCb = new SettableFutureWrapper<>();
-        TaskCapabilityImpl<
-                        CapabilityTwoStrings.Property,
-                        CapabilityTwoStrings.Argument,
-                        Void,
-                        Void,
-                        EmptyTaskUpdater>
-                taskCapability =
-                        new TaskCapabilityImpl<>(
-                                "myTestCapability",
-                                CapabilityTwoStrings.ACTION_SPEC,
-                                property,
-                                paramRegistryBuilder.build(),
-                                /* onInitListener= */ Optional.empty(),
-                                /* onReadyToConfirmListener= */ Optional.empty(),
-                                (argument) -> {
-                                    String slotA = argument.stringSlotA().orElse(null);
-                                    String slotB = argument.stringSlotB().orElse(null);
-                                    onFinishCb.set(String.format("%s %s", slotA, slotB));
-                                    return Futures.immediateFuture(
-                                            ExecutionResult.getDefaultInstance());
-                                },
-                                new HashMap<>(),
-                                new HashMap<>(),
-                                Runnable::run);
-        taskCapability.setTaskUpdaterSupplier(EmptyTaskUpdater::new);
-        ReusableTouchEventCallback touchEventCallback = new ReusableTouchEventCallback();
-        taskCapability.setTouchEventCallback(touchEventCallback);
-
-        // first assistant request
-        SettableFutureWrapper<FulfillmentResponse> firstTurnResult = new SettableFutureWrapper<>();
-        taskCapability.execute(
-                buildRequestArgs(SYNC, "stringSlotA", "apple"),
-                buildActionCallbackWithFulfillmentResponse(firstTurnResult));
-
-        // manual input request
-        Map<String, List<ParamValue>> touchEventParamValues = new HashMap<>();
-        touchEventParamValues.put(
-                "stringSlotB",
-                Collections.singletonList(
-                        ParamValue.newBuilder().setIdentifier("smoothie").build()));
-        taskCapability.updateParamValues(touchEventParamValues);
-
-        // second assistant request
-        SettableFutureWrapper<FulfillmentResponse> secondTurnResult = new SettableFutureWrapper<>();
-        taskCapability.execute(
-                buildRequestArgs(SYNC, "stringSlotA", "banana"),
-                buildActionCallbackWithFulfillmentResponse(secondTurnResult));
-
-        assertThat(firstTurnResult.getFuture().isDone()).isFalse();
-        assertThat(touchEventCallback.getLastResult()).isEqualTo(null);
-
-        // unblock first assistant request
-        slotAListener.setValidationResult(ValidationResult.newAccepted());
-        assertThat(firstTurnResult.getFuture().get(CB_TIMEOUT, MILLISECONDS))
-                .isEqualTo(FulfillmentResponse.getDefaultInstance());
-        assertThat(touchEventCallback.getLastResult()).isEqualTo(null);
-
-        // unblock second assistant request
-        slotAListener.setValidationResult(ValidationResult.newAccepted());
-        assertThat(secondTurnResult.getFuture().get(CB_TIMEOUT, MILLISECONDS))
-                .isEqualTo(FulfillmentResponse.getDefaultInstance());
-        assertThat(onFinishCb.getFuture().get(CB_TIMEOUT, MILLISECONDS))
-                .isEqualTo("banana smoothie");
-        assertThat(touchEventCallback.getLastResult().getKind())
-                .isEqualTo(TouchEventResult.Kind.SUCCESS);
-    }
-
-    @Test
-    public void touchEvent_noDisambig_continuesProcessing() throws Exception {
-        TaskParamRegistry.Builder paramRegistryBuilder = TaskParamRegistry.builder();
-        SettableFutureWrapper<RequiredTaskUpdater> taskUpdaterCb = new SettableFutureWrapper<>();
-        SettableFutureWrapper<Boolean> onFinishCb = new SettableFutureWrapper<>();
-        CapabilityTwoEntityValues.Property property =
-                CapabilityTwoEntityValues.Property.newBuilder()
-                        .setSlotA(EntityProperty.newBuilder().setIsRequired(true).build())
-                        .setSlotB(EntityProperty.newBuilder().setIsRequired(true).build())
-                        .build();
-        paramRegistryBuilder.addTaskParameter(
-                "slotA",
-                paramValue -> !paramValue.hasIdentifier(),
-                GenericResolverInternal.fromAppEntityResolver(
-                        new AppEntityResolver<EntityValue>() {
-                            @NonNull
-                            @Override
-                            public ListenableFuture<ValidationResult> onReceived(
-                                    EntityValue unused) {
-                                return Futures.immediateFuture(ValidationResult.newAccepted());
-                            }
-
-                            @Override
-                            public ListenableFuture<EntitySearchResult<EntityValue>>
-                                    lookupAndRender(SearchAction<EntityValue> unused) {
-                                return Futures.immediateFuture(
-                                        EntitySearchResult.<EntityValue>newBuilder()
-                                                .addPossibleValue(EntityValue.ofId("entityValue1"))
-                                                .addPossibleValue(EntityValue.ofId("entityValue2"))
-                                                .build());
-                            }
-                        }),
-                Optional.of(TypeConverters::toEntity),
-                Optional.of(paramValue -> SearchAction.<EntityValue>newBuilder().build()),
-                TypeConverters::toEntityValue);
-        TaskCapabilityImpl<
-                        CapabilityTwoEntityValues.Property,
-                        CapabilityTwoEntityValues.Argument,
-                        Void,
-                        Void,
-                        RequiredTaskUpdater>
-                taskCapability =
-                        new TaskCapabilityImpl<>(
-                                "fakeId",
-                                CapabilityTwoEntityValues.ACTION_SPEC,
-                                property,
-                                paramRegistryBuilder.build(),
-                                /* onInitListener= */ Optional.of(
-                                        taskUpdater -> {
-                                            taskUpdaterCb.set(taskUpdater);
-                                            return Futures.immediateVoidFuture();
-                                        }),
-                                /* onReadyToConfirmListener= */ Optional.empty(),
-                                /* onFinishListener= */ (paramValuesMap) -> {
-                            onFinishCb.set(true);
-                            return Futures.immediateFuture(
-                                            ExecutionResult.getDefaultInstance());
-                        },
-                                /* confirmationOutputBindings= */ new HashMap<>(),
-                                /* executionOutputBindings= */ new HashMap<>(),
-                                Runnable::run);
-        taskCapability.setTaskUpdaterSupplier(RequiredTaskUpdater::new);
-        SettableFutureWrapper<FulfillmentResponse> touchEventCb = new SettableFutureWrapper<>();
-        TouchEventCallback touchEventCallback = buildTouchEventCallback(touchEventCb);
-        taskCapability.setTouchEventCallback(touchEventCallback);
-
-        // turn 1
-        SettableFutureWrapper<Boolean> turn1Finished = new SettableFutureWrapper<>();
-        taskCapability.execute(
-                buildRequestArgs(
-                        SYNC, "slotA", buildSearchActionParamValue("query"), "slotB", "anything"),
-                buildActionCallback(turn1Finished));
-
-        assertThat(turn1Finished.getFuture().get(CB_TIMEOUT, MILLISECONDS)).isTrue();
-        assertThat(taskCapability.getAppAction())
-                .isEqualTo(
-                        AppAction.newBuilder()
-                                .setName("actions.intent.TEST")
-                                .setIdentifier("fakeId")
-                                .addParams(
-                                        IntentParameter.newBuilder()
-                                                .setName("slotA")
-                                                .setIsRequired(true)
-                                                .addCurrentValue(
-                                                        CurrentValue.newBuilder()
-                                                                .setValue(
-                                                                        buildSearchActionParamValue(
-                                                                                "query"))
-                                                                .setStatus(
-                                                                        CurrentValue.Status
-                                                                                .DISAMBIG)
-                                                                .setDisambiguationData(
-                                                                        DisambiguationData
-                                                                                .newBuilder()
-                                                                                .addEntities(
-                                                                                        Entity
-                                                                                                .newBuilder()
-                                                                                                .setIdentifier(
-                                                                                                        "entityValue1")
-                                                                                                .setName(
-                                                                                                        "entityValue1")
-                                                                                                .build())
-                                                                                .addEntities(
-                                                                                        Entity
-                                                                                                .newBuilder()
-                                                                                                .setIdentifier(
-                                                                                                        "entityValue2")
-                                                                                                .setName(
-                                                                                                        "entityValue2")
-                                                                                                .build())
-                                                                                .build())
-                                                                .build())
-                                                .build())
-                                .addParams(
-                                        IntentParameter.newBuilder()
-                                                .setName("slotB")
-                                                .setIsRequired(true)
-                                                .addCurrentValue(
-                                                        CurrentValue.newBuilder()
-                                                                .setValue(
-                                                                        ParamValue.newBuilder()
-                                                                                .setStringValue(
-                                                                                        "anything")
-                                                                                .build())
-                                                                .setStatus(
-                                                                        CurrentValue.Status.PENDING)
-                                                                .build())
-                                                .build())
-                                .setTaskInfo(
-                                        TaskInfo.newBuilder()
-                                                .setSupportsPartialFulfillment(true)
-                                                .build())
-                                .build());
-
-        // turn 2
-        taskCapability.updateParamValues(
-                Collections.singletonMap(
-                        "slotA",
-                        Collections.singletonList(
-                                ParamValue.newBuilder()
-                                        .setIdentifier("entityValue1")
-                                        .setStringValue("entityValue1")
-                                        .build())));
-
-        assertThat(touchEventCb.getFuture().get(CB_TIMEOUT, MILLISECONDS))
-                .isEqualTo(FulfillmentResponse.getDefaultInstance());
-        assertThat(onFinishCb.getFuture().get()).isTrue();
-    }
-
-    private static class RequiredTaskUpdater extends AbstractTaskUpdater {
-        void setRequiredEntityValue(EntityValue entityValue) {
-            super.updateParamValues(
-                    Collections.singletonMap(
-                            "required",
-                            Collections.singletonList(TypeConverters.toParamValue(entityValue))));
-        }
-    }
-
-    private static class EmptyTaskUpdater extends AbstractTaskUpdater {}
-
-    private static class DeferredValueListener<T> implements ValueListener<T> {
-
-        final AtomicReference<Completer<ValidationResult>> mCompleterRef = new AtomicReference<>();
-
-        void setValidationResult(ValidationResult t) {
-            Completer<ValidationResult> completer = mCompleterRef.getAndSet(null);
-            if (completer == null) {
-                throw new IllegalStateException("no onReceived is waiting");
-            }
-            completer.set(t);
-        }
-
-        @NonNull
-        @Override
-        public ListenableFuture<ValidationResult> onReceived(T value) {
-            return CallbackToFutureAdapter.getFuture(
-                    newCompleter -> {
-                        Completer<ValidationResult> oldCompleter =
-                                mCompleterRef.getAndSet(newCompleter);
-                        if (oldCompleter != null) {
-                            oldCompleter.setCancelled();
-                        }
-                        return "waiting for setValidationResult";
-                    });
-        }
-    }
-
-    private static class CapabilityBuilder
-            extends AbstractCapabilityBuilder<
-                    CapabilityBuilder,
-                    Property,
-                    Argument,
-                    Output,
-                    Confirmation,
-                    RequiredTaskUpdater> {
-        @SuppressWarnings("CheckReturnValue")
-        private CapabilityBuilder() {
-            super(ACTION_SPEC);
-            setId("id");
-            setProperty(SINGLE_REQUIRED_FIELD_PROPERTY);
-        }
-
-        @NonNull
-        public final CapabilityBuilder setTaskHandlerBuilder(
-                TaskHandlerBuilder taskHandlerBuilder) {
-            return setTaskHandler(taskHandlerBuilder.build());
-        }
-    }
-
-    private static class TaskHandlerBuilder
-            extends AbstractTaskHandlerBuilder<
-                    TaskHandlerBuilder, Argument, Output, Confirmation, RequiredTaskUpdater> {
-
-        private TaskHandlerBuilder() {
-            super.registerExecutionOutput(
-                    "optionalStringOutput",
-                    Output::optionalStringField,
-                    TypeConverters::toParamValue);
-            super.registerRepeatedExecutionOutput(
-                    "repeatedStringOutput",
-                    Output::repeatedStringField,
-                    TypeConverters::toParamValue);
-        }
-
-        @Override
-        protected Supplier<RequiredTaskUpdater> getTaskUpdaterSupplier() {
-            return RequiredTaskUpdater::new;
-        }
-
-        public TaskHandlerBuilder setOnReadyToConfirmListener(
-                OnReadyToConfirmListenerInternal<Confirmation> listener) {
-            return super.setOnReadyToConfirmListenerInternal(listener);
-        }
-    }
-
-    private static class CapabilityBuilderWithRequiredConfirmation
-            extends AbstractCapabilityBuilder<
-                    CapabilityBuilderWithRequiredConfirmation,
-                    Property,
-                    Argument,
-                    Output,
-                    Confirmation,
-                    RequiredTaskUpdater> {
-        @SuppressWarnings("CheckReturnValue")
-        private CapabilityBuilderWithRequiredConfirmation() {
-            super(ACTION_SPEC);
-            setProperty(SINGLE_REQUIRED_FIELD_PROPERTY);
-            setId("id");
-        }
-
-        @NonNull
-        public final CapabilityBuilderWithRequiredConfirmation setTaskHandlerBuilder(
-                TaskHandlerBuilderWithRequiredConfirmation taskHandlerBuilder) {
-            return setTaskHandler(taskHandlerBuilder.build());
-        }
-    }
-
-    private static class TaskHandlerBuilderWithRequiredConfirmation
-            extends AbstractTaskHandlerBuilder<
-                    TaskHandlerBuilderWithRequiredConfirmation,
-                    Argument,
-                    Output,
-                    Confirmation,
-                    RequiredTaskUpdater> {
-
-        private TaskHandlerBuilderWithRequiredConfirmation() {
-            super(ConfirmationType.REQUIRED);
-            super.registerExecutionOutput(
-                    "optionalStringOutput",
-                    Output::optionalStringField,
-                    TypeConverters::toParamValue);
-            super.registerRepeatedExecutionOutput(
-                    "repeatedStringOutput",
-                    Output::repeatedStringField,
-                    TypeConverters::toParamValue);
-            super.registerConfirmationOutput(
-                    "optionalStringOutput",
-                    Confirmation::optionalStringField,
-                    TypeConverters::toParamValue);
-        }
-
-        @Override
-        protected Supplier<RequiredTaskUpdater> getTaskUpdaterSupplier() {
-            return RequiredTaskUpdater::new;
-        }
-
-        public TaskHandlerBuilderWithRequiredConfirmation setOnReadyToConfirmListener(
-                OnReadyToConfirmListenerInternal<Confirmation> listener) {
-            return super.setOnReadyToConfirmListenerInternal(listener);
-        }
-    }
-}
diff --git a/appactions/interaction/interaction-capabilities-core/src/test/java/androidx/appactions/interaction/capabilities/core/task/impl/TaskCapabilityImplTest.kt b/appactions/interaction/interaction-capabilities-core/src/test/java/androidx/appactions/interaction/capabilities/core/task/impl/TaskCapabilityImplTest.kt
new file mode 100644
index 0000000..6cc5575
--- /dev/null
+++ b/appactions/interaction/interaction-capabilities-core/src/test/java/androidx/appactions/interaction/capabilities/core/task/impl/TaskCapabilityImplTest.kt
@@ -0,0 +1,919 @@
+/*
+ * 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.appactions.interaction.capabilities.core.task.impl
+
+import android.util.SizeF
+import androidx.appactions.interaction.capabilities.core.ActionCapability
+import androidx.appactions.interaction.capabilities.core.ExecutionResult
+import androidx.appactions.interaction.capabilities.core.HostProperties
+import androidx.appactions.interaction.capabilities.core.InitArg
+import androidx.appactions.interaction.capabilities.core.SessionBuilder
+import androidx.appactions.interaction.capabilities.core.CapabilityBuilderBase
+import androidx.appactions.interaction.capabilities.core.impl.ErrorStatusInternal
+import androidx.appactions.interaction.capabilities.core.impl.concurrent.Futures
+import androidx.appactions.interaction.capabilities.core.impl.converters.DisambigEntityConverter
+import androidx.appactions.interaction.capabilities.core.impl.converters.SearchActionConverter
+import androidx.appactions.interaction.capabilities.core.impl.converters.TypeConverters
+import androidx.appactions.interaction.capabilities.core.impl.spec.ActionSpec
+import androidx.appactions.interaction.capabilities.core.impl.spec.ActionSpecBuilder
+import androidx.appactions.interaction.capabilities.core.properties.EntityProperty
+import androidx.appactions.interaction.capabilities.core.properties.EnumProperty
+import androidx.appactions.interaction.capabilities.core.properties.SimpleProperty
+import androidx.appactions.interaction.capabilities.core.properties.StringProperty
+import androidx.appactions.interaction.capabilities.core.task.AppEntityResolver
+import androidx.appactions.interaction.capabilities.core.task.EntitySearchResult
+import androidx.appactions.interaction.capabilities.core.task.ValidationResult
+import androidx.appactions.interaction.capabilities.core.task.ValueListener
+import androidx.appactions.interaction.capabilities.core.testing.ArgumentUtils.buildRequestArgs
+import androidx.appactions.interaction.capabilities.core.testing.ArgumentUtils.buildSearchActionParamValue
+import androidx.appactions.interaction.capabilities.core.testing.TestingUtils.CB_TIMEOUT
+import androidx.appactions.interaction.capabilities.core.testing.TestingUtils.buildActionCallback
+import androidx.appactions.interaction.capabilities.core.testing.TestingUtils.buildActionCallbackWithFulfillmentResponse
+import androidx.appactions.interaction.capabilities.core.testing.TestingUtils.buildErrorActionCallback
+import androidx.appactions.interaction.capabilities.core.testing.spec.Argument
+import androidx.appactions.interaction.capabilities.core.testing.spec.CapabilityStructFill
+import androidx.appactions.interaction.capabilities.core.testing.spec.CapabilityTwoEntityValues
+import androidx.appactions.interaction.capabilities.core.testing.spec.Confirmation
+import androidx.appactions.interaction.capabilities.core.testing.spec.Output
+import androidx.appactions.interaction.capabilities.core.testing.spec.Property
+import androidx.appactions.interaction.capabilities.core.testing.spec.Session
+import androidx.appactions.interaction.capabilities.core.testing.spec.SettableFutureWrapper
+import androidx.appactions.interaction.capabilities.core.testing.spec.TestEnum
+import androidx.appactions.interaction.capabilities.core.values.EntityValue
+import androidx.appactions.interaction.capabilities.core.values.ListItem
+import androidx.appactions.interaction.capabilities.core.values.SearchAction
+import androidx.appactions.interaction.proto.AppActionsContext.AppAction
+import androidx.appactions.interaction.proto.AppActionsContext.IntentParameter
+import androidx.appactions.interaction.proto.CurrentValue
+import androidx.appactions.interaction.proto.DisambiguationData
+import androidx.appactions.interaction.proto.Entity
+import androidx.appactions.interaction.proto.FulfillmentRequest.Fulfillment.Type.SYNC
+import androidx.appactions.interaction.proto.FulfillmentRequest.Fulfillment.Type.UNKNOWN_TYPE
+import androidx.appactions.interaction.proto.FulfillmentResponse
+import androidx.appactions.interaction.proto.FulfillmentResponse.StructuredOutput
+import androidx.appactions.interaction.proto.FulfillmentResponse.StructuredOutput.OutputValue
+import androidx.appactions.interaction.proto.ParamValue
+import androidx.appactions.interaction.proto.TaskInfo
+import androidx.concurrent.futures.CallbackToFutureAdapter
+import androidx.concurrent.futures.CallbackToFutureAdapter.Completer
+import com.google.common.truth.Truth.assertThat
+import com.google.common.util.concurrent.ListenableFuture
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+import java.util.concurrent.TimeUnit.MILLISECONDS
+import java.util.concurrent.atomic.AtomicInteger
+import java.util.concurrent.atomic.AtomicReference
+import java.util.function.Supplier
+
+@RunWith(JUnit4::class)
+class TaskCapabilityImplTest {
+    val capability: ActionCapability = createCapability<EmptyTaskUpdater>(
+        SINGLE_REQUIRED_FIELD_PROPERTY,
+        sessionBuilder = SessionBuilder {
+            object : Session {
+                override fun onFinishAsync(argument: Argument) =
+                    Futures.immediateFuture(ExecutionResult.getDefaultInstance<Output>())
+            }
+        },
+        sessionBridge = SessionBridge { TaskHandler.Builder<Confirmation>().build() },
+        sessionUpdaterSupplier = ::EmptyTaskUpdater,
+    )
+    val hostProperties: HostProperties = HostProperties.Builder().setMaxHostSizeDp(
+        SizeF(300f, 500f),
+    ).build()
+
+    @Test
+    fun getAppAction_smokeTest() {
+        assertThat(capability.getAppAction())
+            .isEqualTo(
+                AppAction.newBuilder()
+                    .setName("actions.intent.TEST")
+                    .setIdentifier("id")
+                    .addParams(
+                        IntentParameter.newBuilder()
+                            .setName("required")
+                            .setIsRequired(true),
+                    )
+                    .setTaskInfo(
+                        TaskInfo.newBuilder().setSupportsPartialFulfillment(true),
+                    )
+                    .build(),
+            )
+    }
+
+    @Test
+    @kotlin.Throws(Exception::class)
+    fun onInitInvoked_invokedOnce() {
+        val onSuccessInvocationCount = AtomicInteger(0)
+        val capability: ActionCapability = createCapability(
+            SINGLE_REQUIRED_FIELD_PROPERTY,
+            sessionBuilder = SessionBuilder {
+                object : Session {
+                    override fun onInit(initArg: InitArg) {
+                        onSuccessInvocationCount.incrementAndGet()
+                    }
+                    override fun onFinishAsync(argument: Argument) =
+                        Futures.immediateFuture(
+                            ExecutionResult.getDefaultInstance<Output>(),
+                        )
+                }
+            },
+            sessionBridge = SessionBridge { TaskHandler.Builder<Confirmation>().build() },
+            sessionUpdaterSupplier = ::EmptyTaskUpdater,
+        )
+        val session = capability.createSession(hostProperties)
+
+        // TURN 1.
+        val onSuccessInvoked: SettableFutureWrapper<Boolean> = SettableFutureWrapper()
+        session.execute(
+            buildRequestArgs(SYNC, "unknownArgName", "foo"),
+            buildActionCallback(onSuccessInvoked),
+        )
+        assertThat(onSuccessInvoked.getFuture().get(CB_TIMEOUT, MILLISECONDS)).isTrue()
+        assertThat(onSuccessInvocationCount.get()).isEqualTo(1)
+
+        // TURN 2.
+        val onSuccessInvoked2: SettableFutureWrapper<Boolean> = SettableFutureWrapper()
+        session.execute(
+            buildRequestArgs(
+                SYNC,
+                "required",
+                ParamValue.newBuilder().setIdentifier("foo").setStringValue("foo").build(),
+            ),
+            buildActionCallback(onSuccessInvoked2),
+        )
+        assertThat(onSuccessInvoked2.getFuture().get(CB_TIMEOUT, MILLISECONDS)).isTrue()
+        assertThat(onSuccessInvocationCount.get()).isEqualTo(1)
+    }
+
+    class RequiredTaskUpdater : AbstractTaskUpdater() {
+        fun setRequiredEntityValue(entityValue: EntityValue) {
+            super.updateParamValues(
+                mapOf(
+                    "required" to
+                        listOf(TypeConverters.toParamValue(entityValue)),
+                ),
+            )
+        }
+    }
+
+    private class DeferredValueListener<T> : ValueListener<T> {
+        val mCompleterRef: AtomicReference<Completer<ValidationResult>> =
+            AtomicReference<Completer<ValidationResult>>()
+
+        fun setValidationResult(t: ValidationResult) {
+            val completer: Completer<ValidationResult> = mCompleterRef.getAndSet(null)
+                ?: throw IllegalStateException("no onReceived is waiting")
+            completer.set(t)
+        }
+
+        override fun onReceived(value: T): ListenableFuture<ValidationResult> {
+            return CallbackToFutureAdapter.getFuture { newCompleter ->
+                val oldCompleter: Completer<ValidationResult>? = mCompleterRef.getAndSet(
+                    newCompleter,
+                )
+                if (oldCompleter != null) {
+                    oldCompleter.setCancelled()
+                }
+                "waiting for setValidationResult"
+            }
+        }
+    }
+
+    @Test
+    @kotlin.Throws(Exception::class)
+    fun fulfillmentType_unknown_errorReported() {
+        val capability: ActionCapability = createCapability(
+            SINGLE_REQUIRED_FIELD_PROPERTY,
+            sessionBuilder = SessionBuilder {
+                object : Session {
+                    override fun onFinishAsync(argument: Argument) =
+                        Futures.immediateFuture(
+                            ExecutionResult.getDefaultInstance<Output>(),
+                        )
+                }
+            },
+            sessionBridge = SessionBridge { TaskHandler.Builder<Confirmation>().build() },
+            sessionUpdaterSupplier = ::RequiredTaskUpdater,
+        )
+        val session = capability.createSession(hostProperties)
+
+        assertThat(capability.getAppAction())
+            .isEqualTo(
+                AppAction.newBuilder()
+                    .setName("actions.intent.TEST")
+                    .setIdentifier("id")
+                    .addParams(
+                        IntentParameter.newBuilder()
+                            .setName("required")
+                            .setIsRequired(true),
+                    )
+                    .setTaskInfo(
+                        TaskInfo.newBuilder().setSupportsPartialFulfillment(true),
+                    )
+                    .build(),
+            )
+
+        // TURN 1 (UNKNOWN).
+        val errorCb: SettableFutureWrapper<ErrorStatusInternal> = SettableFutureWrapper()
+        session.execute(buildRequestArgs(UNKNOWN_TYPE), buildErrorActionCallback(errorCb))
+        assertThat(errorCb.getFuture().get(CB_TIMEOUT, MILLISECONDS))
+            .isEqualTo(ErrorStatusInternal.INVALID_REQUEST_TYPE)
+    }
+
+    @Test
+    @kotlin.Throws(Exception::class)
+    fun slotFilling_optionalButRejectedParam_onFinishNotInvoked() {
+        val onFinishInvocationCount = AtomicInteger(0)
+        val property: CapabilityTwoEntityValues.Property =
+            CapabilityTwoEntityValues.Property.newBuilder()
+                .setSlotA(EntityProperty.newBuilder().setIsRequired(true).build())
+                .setSlotB(EntityProperty.newBuilder().setIsRequired(false).build())
+                .build()
+        val sessionBuilder = SessionBuilder<CapabilityTwoEntityValues.Session> {
+            object : CapabilityTwoEntityValues.Session {}
+        }
+        val sessionBridge = SessionBridge<CapabilityTwoEntityValues.Session, Void> {
+            TaskHandler.Builder<Void>().registerValueTaskParam(
+                "slotA",
+                AUTO_ACCEPT_ENTITY_VALUE,
+                TypeConverters::toEntityValue,
+            ).registerValueTaskParam(
+                "slotB",
+                AUTO_REJECT_ENTITY_VALUE,
+                TypeConverters::toEntityValue,
+            ).build()
+        }
+        val capability: ActionCapability = TaskCapabilityImpl(
+            "fakeId",
+            CapabilityTwoEntityValues.ACTION_SPEC,
+            property,
+            sessionBuilder,
+            sessionBridge,
+            ::EmptyTaskUpdater,
+        )
+        val session = capability.createSession(hostProperties)
+
+        // TURN 1.
+        val onSuccessInvoked: SettableFutureWrapper<Boolean> = SettableFutureWrapper()
+        session.execute(
+            buildRequestArgs(
+                SYNC,
+                "slotA",
+                ParamValue.newBuilder().setIdentifier("foo").setStringValue("foo").build(),
+                "slotB",
+                ParamValue.newBuilder().setIdentifier("bar").setStringValue("bar").build(),
+            ),
+            buildActionCallback(onSuccessInvoked),
+        )
+        assertThat(onSuccessInvoked.getFuture().get(CB_TIMEOUT, MILLISECONDS)).isTrue()
+        assertThat(onFinishInvocationCount.get()).isEqualTo(0)
+        assertThat(getCurrentValues("slotA", session.state))
+            .containsExactly(
+                CurrentValue.newBuilder()
+                    .setValue(
+                        ParamValue.newBuilder()
+                            .setIdentifier("foo")
+                            .setStringValue("foo"),
+                    )
+                    .setStatus(CurrentValue.Status.ACCEPTED)
+                    .build(),
+            )
+        assertThat(getCurrentValues("slotB", session.state))
+            .containsExactly(
+                CurrentValue.newBuilder()
+                    .setValue(
+                        ParamValue.newBuilder()
+                            .setIdentifier("bar")
+                            .setStringValue("bar"),
+                    )
+                    .setStatus(CurrentValue.Status.REJECTED)
+                    .build(),
+            )
+    }
+
+    @Test
+    @kotlin.Throws(Exception::class)
+    fun slotFilling_assistantRemovedParam_clearInSdkState() {
+        val property: Property = Property.newBuilder()
+            .setRequiredEntityField(
+                EntityProperty.newBuilder().setIsRequired(true).build(),
+            )
+            .setEnumField(
+                EnumProperty.newBuilder(TestEnum::class.java)
+                    .addSupportedEnumValues(TestEnum.VALUE_1, TestEnum.VALUE_2)
+                    .setIsRequired(true)
+                    .build(),
+            )
+            .build()
+        val capability: ActionCapability = createCapability(
+            property,
+            sessionBuilder = SessionBuilder { Session.DEFAULT },
+            sessionBridge = SessionBridge { TaskHandler.Builder<Confirmation>().build() },
+            sessionUpdaterSupplier = ::EmptyTaskUpdater,
+        )
+        val session = capability.createSession(hostProperties)
+
+        // TURN 1.
+        val onSuccessInvoked: SettableFutureWrapper<Boolean> = SettableFutureWrapper()
+        session.execute(
+            buildRequestArgs(
+                SYNC,
+                "required",
+                ParamValue.newBuilder().setIdentifier("foo").setStringValue("foo").build(),
+            ),
+            buildActionCallback(onSuccessInvoked),
+        )
+        assertThat(onSuccessInvoked.getFuture().get(CB_TIMEOUT, MILLISECONDS)).isTrue()
+        assertThat(getCurrentValues("required", session.state))
+            .containsExactly(
+                CurrentValue.newBuilder()
+                    .setValue(
+                        ParamValue.newBuilder()
+                            .setIdentifier("foo")
+                            .setStringValue("foo"),
+                    )
+                    .setStatus(CurrentValue.Status.ACCEPTED)
+                    .build(),
+            )
+        assertThat(getCurrentValues("optionalEnum", session.state)).isEmpty()
+
+        // TURN 2.
+        val onSuccessInvoked2: SettableFutureWrapper<Boolean> = SettableFutureWrapper()
+        session.execute(
+            buildRequestArgs(SYNC, "optionalEnum", TestEnum.VALUE_2),
+            buildActionCallback(onSuccessInvoked2),
+        )
+        assertThat(onSuccessInvoked2.getFuture().get(CB_TIMEOUT, MILLISECONDS)).isTrue()
+        assertThat(getCurrentValues("required", session.state)).isEmpty()
+        assertThat(getCurrentValues("optionalEnum", session.state))
+            .containsExactly(
+                CurrentValue.newBuilder()
+                    .setValue(ParamValue.newBuilder().setIdentifier("VALUE_2"))
+                    .setStatus(CurrentValue.Status.ACCEPTED)
+                    .build(),
+            )
+    }
+
+    @Test
+    @kotlin.Throws(Exception::class)
+    @Suppress("DEPRECATION") // TODO(b/269638788) migrate session state to AppDialogState message
+    fun disambig_singleParam_disambigEntitiesInContext() {
+        val capability: ActionCapability = createCapability(
+            SINGLE_REQUIRED_FIELD_PROPERTY,
+            sessionBuilder = {
+                object : Session {
+                    override suspend fun onFinish(argument: Argument) =
+                        ExecutionResult.getDefaultInstance<Output>()
+                    override fun getRequiredEntityListener() =
+                        object : AppEntityResolver<EntityValue> {
+                            override fun lookupAndRender(
+                                searchAction: SearchAction<EntityValue>,
+                            ): ListenableFuture<EntitySearchResult<EntityValue>> {
+                                val result =
+                                    EntitySearchResult.newBuilder<EntityValue>()
+                                return Futures.immediateFuture(
+                                    result.addPossibleValue(EntityValue.ofId("valid1"))
+                                        .addPossibleValue(EntityValue.ofId("valid2"))
+                                        .build(),
+                                )
+                            }
+
+                            override fun onReceived(
+                                newValue: EntityValue,
+                            ): ListenableFuture<ValidationResult> {
+                                return Futures.immediateFuture(ValidationResult.newAccepted())
+                            }
+                        }
+                }
+            },
+            sessionBridge = SessionBridge<Session, Confirmation> {
+                    session ->
+                val builder = TaskHandler.Builder<Confirmation>()
+                session.getRequiredEntityListener()?.let {
+                        listener: AppEntityResolver<EntityValue> ->
+                    builder.registerAppEntityTaskParam(
+                        "required",
+                        listener,
+                        TypeConverters::toEntityValue,
+                        TypeConverters::toEntity,
+                        getTrivialSearchActionConverter(),
+
+                    )
+                }
+                builder.build()
+            },
+            sessionUpdaterSupplier = ::EmptyTaskUpdater,
+        )
+        val session = capability.createSession(hostProperties)
+
+        // TURN 1.
+        val onSuccessInvoked: SettableFutureWrapper<Boolean> = SettableFutureWrapper()
+        session.execute(
+            buildRequestArgs(SYNC, "required", buildSearchActionParamValue("invalid")),
+            buildActionCallback(onSuccessInvoked),
+        )
+        assertThat(onSuccessInvoked.getFuture().get(CB_TIMEOUT, MILLISECONDS)).isTrue()
+        assertThat(session.state)
+            .isEqualTo(
+                AppAction.newBuilder()
+                    .setName("actions.intent.TEST")
+                    .setIdentifier("id")
+                    .addParams(
+                        IntentParameter.newBuilder()
+                            .setName("required")
+                            .setIsRequired(true)
+                            .addCurrentValue(
+                                CurrentValue.newBuilder()
+                                    .setValue(
+                                        buildSearchActionParamValue(
+                                            "invalid",
+                                        ),
+                                    )
+                                    .setStatus(
+                                        CurrentValue.Status.DISAMBIG,
+                                    )
+                                    .setDisambiguationData(
+                                        DisambiguationData
+                                            .newBuilder()
+                                            .addEntities(
+                                                Entity
+                                                    .newBuilder()
+                                                    .setIdentifier(
+                                                        "valid1",
+                                                    )
+                                                    .setName(
+                                                        "valid1",
+                                                    ),
+                                            )
+                                            .addEntities(
+                                                Entity
+                                                    .newBuilder()
+                                                    .setIdentifier(
+                                                        "valid2",
+                                                    )
+                                                    .setName(
+                                                        "valid2",
+                                                    ),
+                                            ),
+                                    ),
+                            ),
+                    )
+                    .setTaskInfo(
+                        TaskInfo.newBuilder().setSupportsPartialFulfillment(true),
+                    )
+                    .build(),
+            )
+
+        // TURN 2.
+        val turn2SuccessInvoked: SettableFutureWrapper<Boolean> = SettableFutureWrapper()
+        session.execute(
+            buildRequestArgs(
+                SYNC,
+                "required",
+                ParamValue.newBuilder()
+                    .setIdentifier("valid2")
+                    .setStringValue("valid2")
+                    .build(),
+            ),
+            buildActionCallback(turn2SuccessInvoked),
+        )
+        assertThat(turn2SuccessInvoked.getFuture().get(CB_TIMEOUT, MILLISECONDS)).isTrue()
+        assertThat(session.state)
+            .isEqualTo(
+                AppAction.newBuilder()
+                    .setName("actions.intent.TEST")
+                    .setIdentifier("id")
+                    .addParams(
+                        IntentParameter.newBuilder()
+                            .setName("required")
+                            .setIsRequired(true)
+                            .addCurrentValue(
+                                CurrentValue.newBuilder()
+                                    .setValue(
+                                        ParamValue.newBuilder()
+                                            .setIdentifier(
+                                                "valid2",
+                                            )
+                                            .setStringValue(
+                                                "valid2",
+                                            ),
+                                    )
+                                    .setStatus(
+                                        CurrentValue.Status.ACCEPTED,
+                                    ),
+                            ),
+                    )
+                    .setTaskInfo(
+                        TaskInfo.newBuilder().setSupportsPartialFulfillment(true),
+                    )
+                    .build(),
+            )
+    }
+
+    /**
+     * Assistant sends grounded objects as identifier only, but we need to mark the entire value
+     * struct as accepted.
+     */
+    @Test
+    @kotlin.Throws(Exception::class)
+    @Suppress("DEPRECATION") // TODO(b/269638788) migrate session state to AppDialogState message
+    fun identifierOnly_refillsStruct() {
+        val property: CapabilityStructFill.Property = CapabilityStructFill.Property.newBuilder()
+            .setListItem(SimpleProperty.REQUIRED)
+            .setAnyString(StringProperty.newBuilder().setIsRequired(true).build())
+            .build()
+        val item1: ListItem = ListItem.newBuilder().setName("red apple").setId("item1").build()
+        val item2: ListItem = ListItem.newBuilder().setName("green apple").setId("item2").build()
+        val onReceivedCb: SettableFutureWrapper<ListItem> = SettableFutureWrapper()
+        val onFinishListItemCb: SettableFutureWrapper<ListItem> = SettableFutureWrapper()
+        val onFinishStringCb: SettableFutureWrapper<String> = SettableFutureWrapper()
+
+        val sessionBuilder = SessionBuilder<CapabilityStructFill.Session> {
+            object : CapabilityStructFill.Session {
+                override suspend fun onFinish(
+                    argument: CapabilityStructFill.Argument,
+                ): ExecutionResult<Void> {
+                    val listItem: ListItem = argument.listItem().orElse(null)
+                    val string: String = argument.anyString().orElse(null)
+                    onFinishListItemCb.set(listItem)
+                    onFinishStringCb.set(string)
+                    return ExecutionResult.getDefaultInstance<Void>()
+                }
+
+                override fun getListItemListener() = object : AppEntityResolver<ListItem> {
+                    override fun onReceived(
+                        listItem: ListItem,
+                    ): ListenableFuture<ValidationResult> {
+                        onReceivedCb.set(listItem)
+                        return Futures.immediateFuture(ValidationResult.newAccepted())
+                    }
+
+                    override fun lookupAndRender(
+                        searchAction: SearchAction<ListItem>,
+                    ): ListenableFuture<EntitySearchResult<ListItem>> = Futures.immediateFuture(
+                        EntitySearchResult.newBuilder<ListItem>()
+                            .addPossibleValue(item1)
+                            .addPossibleValue(item2)
+                            .build(),
+                    )
+                }
+            }
+        }
+        val sessionBridge = SessionBridge<CapabilityStructFill.Session, Void> {
+                session ->
+            TaskHandler.Builder<Void>()
+                .registerAppEntityTaskParam(
+                    "listItem",
+                    session.getListItemListener(),
+                    TypeConverters::toListItem,
+                    TypeConverters::toEntity,
+                    getTrivialSearchActionConverter(),
+                )
+                .build()
+        }
+
+        val capability: ActionCapability = TaskCapabilityImpl(
+            "selectListItem",
+            CapabilityStructFill.ACTION_SPEC,
+            property,
+            sessionBuilder,
+            sessionBridge,
+            ::EmptyTaskUpdater,
+        )
+        val session = capability.createSession(hostProperties)
+
+        // first sync request
+        val firstTurnSuccess: SettableFutureWrapper<Boolean> = SettableFutureWrapper()
+        session.execute(
+            buildRequestArgs(SYNC, "listItem", buildSearchActionParamValue("apple")),
+            buildActionCallback(firstTurnSuccess),
+        )
+        assertThat(firstTurnSuccess.getFuture().get(CB_TIMEOUT, MILLISECONDS)).isTrue()
+        assertThat(onReceivedCb.getFuture().isDone()).isFalse()
+        assertThat(onFinishListItemCb.getFuture().isDone()).isFalse()
+        assertThat(session.state)
+            .isEqualTo(
+                AppAction.newBuilder()
+                    .setName("actions.intent.TEST")
+                    .setIdentifier("selectListItem")
+                    .addParams(
+                        IntentParameter.newBuilder()
+                            .setName("listItem")
+                            .setIsRequired(true)
+                            .addCurrentValue(
+                                CurrentValue.newBuilder()
+                                    .setValue(
+                                        buildSearchActionParamValue(
+                                            "apple",
+                                        ),
+                                    )
+                                    .setStatus(
+                                        CurrentValue.Status.DISAMBIG,
+                                    )
+                                    .setDisambiguationData(
+                                        DisambiguationData
+                                            .newBuilder()
+                                            .addEntities(
+                                                TypeConverters
+                                                    .toEntity(
+                                                        item1,
+                                                    ),
+                                            )
+                                            .addEntities(
+                                                TypeConverters
+                                                    .toEntity(
+                                                        item2,
+                                                    ),
+                                            )
+                                            .build(),
+                                    )
+                                    .build(),
+                            )
+                            .build(),
+                    )
+                    .addParams(
+                        IntentParameter.newBuilder()
+                            .setName("string")
+                            .setIsRequired(true)
+                            .build(),
+                    )
+                    .setTaskInfo(
+                        TaskInfo.newBuilder().setSupportsPartialFulfillment(true),
+                    )
+                    .build(),
+            )
+
+        // second sync request, sending grounded ParamValue with identifier only
+        val secondTurnSuccess: SettableFutureWrapper<Boolean> = SettableFutureWrapper()
+        session.execute(
+            buildRequestArgs(
+                SYNC,
+                "listItem",
+                ParamValue.newBuilder().setIdentifier("item2").build(),
+            ),
+            buildActionCallback(secondTurnSuccess),
+        )
+        assertThat(secondTurnSuccess.getFuture().get(CB_TIMEOUT, MILLISECONDS)).isTrue()
+        assertThat(onReceivedCb.getFuture().get(CB_TIMEOUT, MILLISECONDS)).isEqualTo(item2)
+        assertThat(onFinishListItemCb.getFuture().isDone()).isFalse()
+
+        // third sync request, sending grounded ParamValue with identifier only, completes task
+        val thirdTurnSuccess: SettableFutureWrapper<Boolean> = SettableFutureWrapper()
+        session.execute(
+            buildRequestArgs(
+                SYNC,
+                "listItem",
+                ParamValue.newBuilder().setIdentifier("item2").build(),
+                "string",
+                "unused",
+            ),
+            buildActionCallback(thirdTurnSuccess),
+        )
+        assertThat(thirdTurnSuccess.getFuture().get(CB_TIMEOUT, MILLISECONDS)).isTrue()
+        assertThat(onFinishListItemCb.getFuture().get(CB_TIMEOUT, MILLISECONDS)).isEqualTo(
+            item2,
+        )
+        assertThat(
+            onFinishStringCb.getFuture()
+                .get(CB_TIMEOUT, MILLISECONDS),
+        ).isEqualTo("unused")
+    }
+
+    @Test
+    @kotlin.Throws(Exception::class)
+    fun executionResult_resultReturned() {
+        val sessionBuilder = SessionBuilder<Session> {
+            object : Session {
+                override suspend fun onFinish(argument: Argument) =
+                    ExecutionResult.Builder<Output>()
+                        .setOutput(
+                            Output.builder()
+                                .setOptionalStringField("bar")
+                                .setRepeatedStringField(
+                                    listOf("bar1", "bar2"),
+                                )
+                                .build(),
+                        )
+                        .build()
+            }
+        }
+        val capability = CapabilityBuilder()
+            .setSessionBuilder(sessionBuilder)
+            .build()
+        val session = capability.createSession(hostProperties)
+        val onSuccessInvoked: SettableFutureWrapper<FulfillmentResponse> = SettableFutureWrapper()
+        val expectedOutput: StructuredOutput = StructuredOutput.newBuilder()
+            .addOutputValues(
+                OutputValue.newBuilder()
+                    .setName("optionalStringOutput")
+                    .addValues(
+                        ParamValue.newBuilder()
+                            .setStringValue("bar")
+                            .build(),
+                    )
+                    .build(),
+            )
+            .addOutputValues(
+                OutputValue.newBuilder()
+                    .setName("repeatedStringOutput")
+                    .addValues(
+                        ParamValue.newBuilder()
+                            .setStringValue("bar1")
+                            .build(),
+                    )
+                    .addValues(
+                        ParamValue.newBuilder()
+                            .setStringValue("bar2")
+                            .build(),
+                    )
+                    .build(),
+            )
+            .build()
+        session.execute(
+            buildRequestArgs(
+                SYNC, /* args...= */
+                "required",
+                ParamValue.newBuilder().setIdentifier("foo").setStringValue("foo").build(),
+            ),
+            buildActionCallbackWithFulfillmentResponse(onSuccessInvoked),
+        )
+        assertThat(
+            onSuccessInvoked
+                .getFuture()
+                .get(CB_TIMEOUT, MILLISECONDS)
+                .getExecutionOutput()
+                .getOutputValuesList(),
+        )
+            .containsExactlyElementsIn(expectedOutput.getOutputValuesList())
+    }
+
+    /**
+     * an implementation of CapabilityBuilderBase using Argument. Output, etc. defined under testing/spec
+     */
+    class CapabilityBuilder : CapabilityBuilderBase<
+        CapabilityBuilder,
+        Property,
+        Argument,
+        Output,
+        Confirmation,
+        RequiredTaskUpdater,
+        Session,
+        >(ACTION_SPEC) {
+
+        init {
+            setProperty(SINGLE_REQUIRED_FIELD_PROPERTY)
+        }
+        override val sessionBridge: SessionBridge<Session, Confirmation> = SessionBridge {
+            TaskHandler.Builder<Confirmation>().build()
+        }
+        override val sessionUpdaterSupplier: Supplier<RequiredTaskUpdater> = Supplier {
+            RequiredTaskUpdater()
+        }
+
+        public override fun setSessionBuilder(
+            sessionBuilder: SessionBuilder<Session>,
+        ): CapabilityBuilder = super.setSessionBuilder(sessionBuilder)
+    }
+
+    companion object {
+        private val DISAMBIG_ENTITY_CONVERTER: DisambigEntityConverter<EntityValue> =
+            DisambigEntityConverter {
+                TypeConverters.toEntity(it)
+            }
+
+        private val AUTO_ACCEPT_ENTITY_VALUE: AppEntityResolver<EntityValue> =
+            object : AppEntityResolver<EntityValue> {
+                override fun lookupAndRender(
+                    searchAction: SearchAction<EntityValue>,
+                ): ListenableFuture<EntitySearchResult<EntityValue>> {
+                    val result: EntitySearchResult.Builder<EntityValue> =
+                        EntitySearchResult.newBuilder()
+                    return Futures.immediateFuture(
+                        result.addPossibleValue(EntityValue.ofId("valid1")).build(),
+                    )
+                }
+
+                override fun onReceived(newValue: EntityValue): ListenableFuture<ValidationResult> {
+                    return Futures.immediateFuture(ValidationResult.newAccepted())
+                }
+            }
+        private val AUTO_REJECT_ENTITY_VALUE: AppEntityResolver<EntityValue> =
+            object : AppEntityResolver<EntityValue> {
+                override fun lookupAndRender(
+                    searchAction: SearchAction<EntityValue>,
+                ): ListenableFuture<EntitySearchResult<EntityValue>> {
+                    val result: EntitySearchResult.Builder<EntityValue> =
+                        EntitySearchResult.newBuilder()
+                    return Futures.immediateFuture(
+                        result.addPossibleValue(EntityValue.ofId("valid1")).build(),
+                    )
+                }
+
+                override fun onReceived(newValue: EntityValue): ListenableFuture<ValidationResult> {
+                    return Futures.immediateFuture(ValidationResult.newRejected())
+                }
+            }
+
+        private fun <T> getTrivialSearchActionConverter() = SearchActionConverter {
+            SearchAction.newBuilder<T>().build()
+        }
+
+        private const val CAPABILITY_NAME = "actions.intent.TEST"
+        private val ACTION_SPEC: ActionSpec<Property, Argument, Output> =
+            ActionSpecBuilder.ofCapabilityNamed(
+                CAPABILITY_NAME,
+            )
+                .setDescriptor(Property::class.java)
+                .setArgument(Argument::class.java, Argument::newBuilder)
+                .setOutput(Output::class.java)
+                .bindRequiredEntityParameter(
+                    "required",
+                    Property::requiredEntityField,
+                    Argument.Builder::setRequiredEntityField,
+                )
+                .bindOptionalStringParameter(
+                    "optional",
+                    Property::optionalStringField,
+                    Argument.Builder::setOptionalStringField,
+                )
+                .bindOptionalEnumParameter(
+                    "optionalEnum",
+                    TestEnum::class.java,
+                    Property::enumField,
+                    Argument.Builder::setEnumField,
+                )
+                .bindRepeatedStringParameter(
+                    "repeated",
+                    Property::repeatedStringField,
+                    Argument.Builder::setRepeatedStringField,
+                ).bindOptionalOutput(
+                    "optionalStringOutput",
+                    Output::optionalStringField,
+                    TypeConverters::toParamValue,
+                )
+                .bindRepeatedOutput(
+                    "repeatedStringOutput",
+                    Output::repeatedStringField,
+                    TypeConverters::toParamValue,
+                )
+                .build()
+
+        private val SINGLE_REQUIRED_FIELD_PROPERTY: Property = Property.newBuilder()
+            .setRequiredEntityField(EntityProperty.newBuilder().setIsRequired(true).build())
+            .build()
+
+        private fun groundingPredicate(paramValue: ParamValue): Boolean {
+            return !paramValue.hasIdentifier()
+        }
+
+        // TODO(b/269638788) migrate session state to AppDialogState message
+        @Suppress("DEPRECATION")
+        private fun getCurrentValues(argName: String, appAction: AppAction): List<CurrentValue> {
+            return appAction.getParamsList().stream()
+                .filter { intentParam -> intentParam.getName().equals(argName) }
+                .findFirst()
+                .orElse(IntentParameter.getDefaultInstance())
+                .getCurrentValueList()
+        }
+
+        /**
+         * Create a capability instance templated with Property, Argument, Output,
+         *Confirmation etc., defined under ../../testing/spec
+         */
+        private fun <SessionUpdaterT : AbstractTaskUpdater> createCapability(
+            property: Property,
+            sessionBuilder: SessionBuilder<Session>,
+            sessionBridge: SessionBridge<Session, Confirmation>,
+            sessionUpdaterSupplier: Supplier<SessionUpdaterT>,
+        ): TaskCapabilityImpl<Property,
+            Argument,
+            Output,
+            Session,
+            Confirmation,
+            SessionUpdaterT,> {
+            return TaskCapabilityImpl(
+                "id",
+                ACTION_SPEC,
+                property,
+                sessionBuilder,
+                sessionBridge,
+                sessionUpdaterSupplier,
+            )
+        }
+    }
+}
diff --git a/appactions/interaction/interaction-capabilities-core/src/test/java/androidx/appactions/interaction/capabilities/core/task/impl/TaskSlotProcessorTest.java b/appactions/interaction/interaction-capabilities-core/src/test/java/androidx/appactions/interaction/capabilities/core/task/impl/TaskSlotProcessorTest.java
index 6ec0dfb..726b1d8 100644
--- a/appactions/interaction/interaction-capabilities-core/src/test/java/androidx/appactions/interaction/capabilities/core/task/impl/TaskSlotProcessorTest.java
+++ b/appactions/interaction/interaction-capabilities-core/src/test/java/androidx/appactions/interaction/capabilities/core/task/impl/TaskSlotProcessorTest.java
@@ -36,10 +36,10 @@
 import androidx.appactions.interaction.proto.DisambiguationData;
 import androidx.appactions.interaction.proto.Entity;
 import androidx.appactions.interaction.proto.ParamValue;
+import androidx.appactions.interaction.protobuf.Struct;
+import androidx.appactions.interaction.protobuf.Value;
 
 import com.google.common.util.concurrent.ListenableFuture;
-import com.google.protobuf.Struct;
-import com.google.protobuf.Value;
 
 import org.junit.Test;
 import org.junit.runner.RunWith;
diff --git a/appactions/interaction/interaction-capabilities-core/src/test/java/androidx/appactions/interaction/capabilities/core/testing/ArgumentUtils.java b/appactions/interaction/interaction-capabilities-core/src/test/java/androidx/appactions/interaction/capabilities/core/testing/ArgumentUtils.java
index 32a97b4..84d481d 100644
--- a/appactions/interaction/interaction-capabilities-core/src/test/java/androidx/appactions/interaction/capabilities/core/testing/ArgumentUtils.java
+++ b/appactions/interaction/interaction-capabilities-core/src/test/java/androidx/appactions/interaction/capabilities/core/testing/ArgumentUtils.java
@@ -22,9 +22,8 @@
 import androidx.appactions.interaction.proto.FulfillmentRequest.Fulfillment;
 import androidx.appactions.interaction.proto.FulfillmentRequest.Fulfillment.FulfillmentParam;
 import androidx.appactions.interaction.proto.ParamValue;
-
-import com.google.protobuf.Struct;
-import com.google.protobuf.Value;
+import androidx.appactions.interaction.protobuf.Struct;
+import androidx.appactions.interaction.protobuf.Value;
 
 import java.util.ArrayList;
 import java.util.Collections;
diff --git a/appactions/interaction/interaction-capabilities-core/src/test/java/androidx/appactions/interaction/capabilities/core/testing/spec/CapabilityStructFill.java b/appactions/interaction/interaction-capabilities-core/src/test/java/androidx/appactions/interaction/capabilities/core/testing/spec/CapabilityStructFill.java
index fc4b9b4..26dab57 100644
--- a/appactions/interaction/interaction-capabilities-core/src/test/java/androidx/appactions/interaction/capabilities/core/testing/spec/CapabilityStructFill.java
+++ b/appactions/interaction/interaction-capabilities-core/src/test/java/androidx/appactions/interaction/capabilities/core/testing/spec/CapabilityStructFill.java
@@ -17,12 +17,14 @@
 package androidx.appactions.interaction.capabilities.core.testing.spec;
 
 import androidx.annotation.NonNull;
+import androidx.appactions.interaction.capabilities.core.BaseSession;
 import androidx.appactions.interaction.capabilities.core.impl.BuilderOf;
 import androidx.appactions.interaction.capabilities.core.impl.converters.TypeConverters;
 import androidx.appactions.interaction.capabilities.core.impl.spec.ActionSpec;
 import androidx.appactions.interaction.capabilities.core.impl.spec.ActionSpecBuilder;
 import androidx.appactions.interaction.capabilities.core.properties.SimpleProperty;
 import androidx.appactions.interaction.capabilities.core.properties.StringProperty;
+import androidx.appactions.interaction.capabilities.core.task.AppEntityResolver;
 import androidx.appactions.interaction.capabilities.core.values.ListItem;
 
 import com.google.auto.value.AutoValue;
@@ -39,15 +41,14 @@
                     .setArgument(Argument.class, Argument::newBuilder)
                     .bindStructParameter(
                             "listItem",
-                            Property::itemList,
+                            Property::listItem,
                             Argument.Builder::setListItem,
                             TypeConverters::toListItem)
                     .bindOptionalStringParameter(
                             "string", Property::anyString, Argument.Builder::setAnyString)
                     .build();
 
-    private CapabilityStructFill() {
-    }
+    private CapabilityStructFill() {}
 
     /** Two required strings */
     @AutoValue
@@ -82,7 +83,7 @@
             return new AutoValue_CapabilityStructFill_Property.Builder();
         }
 
-        public abstract Optional<SimpleProperty> itemList();
+        public abstract Optional<SimpleProperty> listItem();
 
         public abstract Optional<StringProperty> anyString();
 
@@ -91,7 +92,7 @@
         public abstract static class Builder {
 
             @NonNull
-            public abstract Builder setItemList(@NonNull SimpleProperty value);
+            public abstract Builder setListItem(@NonNull SimpleProperty value);
 
             @NonNull
             public abstract Builder setAnyString(@NonNull StringProperty value);
@@ -100,4 +101,9 @@
             public abstract Property build();
         }
     }
+
+    public interface Session extends BaseSession<Argument, Void> {
+        @NonNull
+        AppEntityResolver<ListItem> getListItemListener();
+    }
 }
diff --git a/appactions/interaction/interaction-capabilities-core/src/test/java/androidx/appactions/interaction/capabilities/core/testing/spec/CapabilityTwoEntityValues.java b/appactions/interaction/interaction-capabilities-core/src/test/java/androidx/appactions/interaction/capabilities/core/testing/spec/CapabilityTwoEntityValues.java
index 298a57f..bab4659 100644
--- a/appactions/interaction/interaction-capabilities-core/src/test/java/androidx/appactions/interaction/capabilities/core/testing/spec/CapabilityTwoEntityValues.java
+++ b/appactions/interaction/interaction-capabilities-core/src/test/java/androidx/appactions/interaction/capabilities/core/testing/spec/CapabilityTwoEntityValues.java
@@ -17,6 +17,7 @@
 package androidx.appactions.interaction.capabilities.core.testing.spec;
 
 import androidx.annotation.NonNull;
+import androidx.appactions.interaction.capabilities.core.BaseSession;
 import androidx.appactions.interaction.capabilities.core.impl.BuilderOf;
 import androidx.appactions.interaction.capabilities.core.impl.spec.ActionSpec;
 import androidx.appactions.interaction.capabilities.core.impl.spec.ActionSpecBuilder;
@@ -34,14 +35,13 @@
             ActionSpecBuilder.ofCapabilityNamed(CAPABILITY_NAME)
                     .setDescriptor(Property.class)
                     .setArgument(Argument.class, Argument::newBuilder)
-                    .bindOptionalEntityParameter("slotA", Property::slotA,
-                            Argument.Builder::setSlotA)
-                    .bindOptionalEntityParameter("slotB", Property::slotB,
-                            Argument.Builder::setSlotB)
+                    .bindOptionalEntityParameter(
+                            "slotA", Property::slotA, Argument.Builder::setSlotA)
+                    .bindOptionalEntityParameter(
+                            "slotB", Property::slotB, Argument.Builder::setSlotB)
                     .build();
 
-    private CapabilityTwoEntityValues() {
-    }
+    private CapabilityTwoEntityValues() {}
 
     /** Two required strings */
     @AutoValue
@@ -94,4 +94,6 @@
             public abstract Property build();
         }
     }
+
+    public interface Session extends BaseSession<Argument, Void> {}
 }
diff --git a/appactions/interaction/interaction-capabilities-core/src/test/java/androidx/appactions/interaction/capabilities/core/testing/spec/Session.kt b/appactions/interaction/interaction-capabilities-core/src/test/java/androidx/appactions/interaction/capabilities/core/testing/spec/Session.kt
index 60de640..2a42788 100644
--- a/appactions/interaction/interaction-capabilities-core/src/test/java/androidx/appactions/interaction/capabilities/core/testing/spec/Session.kt
+++ b/appactions/interaction/interaction-capabilities-core/src/test/java/androidx/appactions/interaction/capabilities/core/testing/spec/Session.kt
@@ -17,5 +17,22 @@
 package androidx.appactions.interaction.capabilities.core.testing.spec
 
 import androidx.appactions.interaction.capabilities.core.BaseSession
+import androidx.appactions.interaction.capabilities.core.ExecutionResult
+import androidx.appactions.interaction.capabilities.core.impl.concurrent.Futures
+import androidx.appactions.interaction.capabilities.core.task.AppEntityResolver
+import androidx.appactions.interaction.capabilities.core.values.EntityValue
 
-interface Session : BaseSession<Argument, Output>
\ No newline at end of file
+interface Session : BaseSession<Argument, Output> {
+
+    fun getRequiredEntityListener(): AppEntityResolver<EntityValue>? = null
+
+    companion object {
+        @JvmStatic
+        val DEFAULT: Session = object : Session {
+            override fun onFinishAsync(argument: Argument) =
+                Futures.immediateFuture(
+                    ExecutionResult.getDefaultInstance<Output>(),
+                )
+        }
+    }
+}
diff --git a/appactions/interaction/interaction-capabilities-safety/build.gradle b/appactions/interaction/interaction-capabilities-safety/build.gradle
index e3315aa..19c9285 100644
--- a/appactions/interaction/interaction-capabilities-safety/build.gradle
+++ b/appactions/interaction/interaction-capabilities-safety/build.gradle
@@ -27,7 +27,6 @@
     api(libs.autoValueAnnotations)
     api(libs.kotlinStdlib)
     annotationProcessor(libs.autoValue)
-    implementation(libs.protobufLite)
     implementation("org.jetbrains.kotlin:kotlin-reflect:1.8.10")
     implementation("androidx.annotation:annotation:1.1.0")
     implementation(project(":appactions:interaction:interaction-capabilities-core"))
diff --git a/appactions/interaction/interaction-capabilities-safety/src/main/java/androidx/appactions/interaction/capabilities/safety/StopSafetyCheck.kt b/appactions/interaction/interaction-capabilities-safety/src/main/java/androidx/appactions/interaction/capabilities/safety/StopSafetyCheck.kt
index ccd353a..c4401ff 100644
--- a/appactions/interaction/interaction-capabilities-safety/src/main/java/androidx/appactions/interaction/capabilities/safety/StopSafetyCheck.kt
+++ b/appactions/interaction/interaction-capabilities-safety/src/main/java/androidx/appactions/interaction/capabilities/safety/StopSafetyCheck.kt
@@ -16,8 +16,9 @@
 
 package androidx.appactions.interaction.capabilities.safety
 
-import androidx.appactions.interaction.capabilities.core.AbstractCapabilityBuilder
+import androidx.appactions.interaction.capabilities.core.CapabilityBuilderBase
 import androidx.appactions.interaction.capabilities.core.ActionCapability
+import androidx.appactions.interaction.capabilities.core.BaseSession
 import androidx.appactions.interaction.capabilities.core.impl.BuilderOf
 import androidx.appactions.interaction.capabilities.core.impl.converters.TypeConverters
 import androidx.appactions.interaction.capabilities.core.impl.spec.ActionSpecBuilder
@@ -29,8 +30,8 @@
 import androidx.appactions.interaction.capabilities.safety.executionstatus.SafetyAccountNotLoggedIn
 import androidx.appactions.interaction.capabilities.safety.executionstatus.SafetyFeatureNotOnboarded
 import androidx.appactions.interaction.proto.ParamValue
-import com.google.protobuf.Struct
-import com.google.protobuf.Value
+import androidx.appactions.interaction.protobuf.Struct
+import androidx.appactions.interaction.protobuf.Value
 import java.util.Optional
 
 /** StopSafetyCheck.kt in interaction-capabilities-safety */
@@ -52,8 +53,8 @@
 class StopSafetyCheck private constructor() {
     // TODO(b/267805819): Update to include the SessionBuilder once Session API is ready.
     class CapabilityBuilder :
-        AbstractCapabilityBuilder<
-            CapabilityBuilder, Property, Argument, Output, Confirmation, TaskUpdater,
+        CapabilityBuilderBase<
+            CapabilityBuilder, Property, Argument, Output, Confirmation, TaskUpdater, Session,
             >(ACTION_SPEC) {
         override fun build(): ActionCapability {
             super.setProperty(Property())
@@ -179,4 +180,6 @@
     class Confirmation internal constructor()
 
     class TaskUpdater internal constructor() : AbstractTaskUpdater()
+
+    sealed interface Session : BaseSession<Argument, Output>
 }
diff --git a/appactions/interaction/interaction-proto/build.gradle b/appactions/interaction/interaction-proto/build.gradle
index 7fe89ea..d2213ec 100644
--- a/appactions/interaction/interaction-proto/build.gradle
+++ b/appactions/interaction/interaction-proto/build.gradle
@@ -17,15 +17,51 @@
 
 plugins {
     id("AndroidXPlugin")
-    id("com.android.library")
+    id("java-library")
+    id("com.github.johnrengelman.shadow")
     id("com.google.protobuf")
 }
 
+configurations {
+    shadowed
+    compileOnly.extendsFrom(shadowed)
+    testCompile.extendsFrom(shadowed)
+
+    // Make sure that only the shadowJar is included in the outgoing artifacts.
+    apiElements.outgoing.artifacts.clear()
+    apiElements.outgoing.artifact(shadowJar) {
+        builtBy shadowJar
+    }
+    runtimeElements.outgoing.artifacts.clear()
+    runtimeElements.outgoing.artifact(shadowJar) {
+        builtBy shadowJar
+    }
+}
+
 dependencies {
-    implementation(libs.protobufLite)
+    shadowed(libs.protobufLite)
+
     implementation("androidx.annotation:annotation:1.1.0")
 }
 
+// Move standard JAR to have another suffix. We will then build a shadowJar with
+// no classifier (where archiveClassifier = '') so it's picked up as the primary artifact.
+jar {
+    archiveClassifier = 'before-shadow'
+}
+
+// Classifier differentiates between different artifacts built from the same project.
+// The default is an empty string when using the Java plugin. Building shadowJar with an empty
+// classifier causes the shadow Jar file to be picked up as the primary artifact.
+shadowJar {
+    archiveClassifier = ''
+    configurations = [project.configurations.shadowed]
+    relocate "com.google.protobuf", "androidx.appactions.interaction.protobuf"
+    exclude("**/*.proto")
+}
+
+assemble.dependsOn(shadowJar)
+
 protobuf {
     protoc {
         artifact = libs.protobufCompiler.get()
@@ -34,6 +70,11 @@
     // https://github.com/google/protobuf-gradle-plugin#customizing-protobuf-compilation
     // for more information.
     generateProtoTasks {
+        // Add any additional directories specified in the "main" source set to the Java
+        // source directories of the main source set.
+        ofSourceSet("main").each { task ->
+            sourceSets.main.java.srcDir(task)
+        }
         all().each { task ->
             task.builtins {
                 java {
@@ -44,8 +85,24 @@
     }
 }
 
-android {
-    namespace "androidx.appactions.interaction.proto"
+afterEvaluate {
+    lint {
+        lintOptions {
+            // protobuf generates unannotated and synthetic accessor methods
+            disable("UnknownNullness", "SyntheticAccessor")
+        }
+    }
+    // Explicitly declares generateProto as a dependency for generateApi so
+    // API task can run normally.
+    project.tasks.getByName("generateApi") {
+        dependsOn(":appactions:interaction:interaction-proto:generateProto")
+    }
+    // Explicitly declares generateTestProto as a dependency for some of the lint tasks
+    // run on regular AndroidX libraries.
+    project.tasks.getByName("generateTestProto") {
+        mustRunAfter(":appactions:interaction:interaction-proto:lintReport")
+        mustRunAfter(":appactions:interaction:interaction-proto:lintAnalyze")
+    }
 }
 
 androidx {
@@ -53,4 +110,4 @@
     type = LibraryType.PUBLISHED_LIBRARY
     inceptionYear = "2022"
     description = "Protos for use with App Action interaction libraries."
-}
+}
\ No newline at end of file
diff --git a/appactions/interaction/interaction-proto/src/main/proto/app_actions_data.proto b/appactions/interaction/interaction-proto/src/main/proto/app_actions_data.proto
index 3548f99..e07f1aa 100644
--- a/appactions/interaction/interaction-proto/src/main/proto/app_actions_data.proto
+++ b/appactions/interaction/interaction-proto/src/main/proto/app_actions_data.proto
@@ -31,8 +31,9 @@
     string string_value = 2;
     bool bool_value = 3;
     double number_value = 4;
-    google.protobuf.Struct struct_value = 5;
+    google.protobuf.Struct struct_value = 6;
   }
+  reserved 5;  // Deleted DateTime type.
 }
 
 message Entity {
@@ -182,8 +183,7 @@
     bool is_focused = 3;
   }
 
-  // Current version of the protocol. Populated with values from the artifact version of the
-  // interaction-proto Jetpack library.
+  // Current version of the protocol. Populated with from the interaction-capabiliites-core library.
   optional Version version = 4;
 
   // Represents the dynamic capabilities declared by an App. Capabilities
@@ -372,7 +372,7 @@
   // The patch version.
   optional uint64 patch = 3;
 
-  // The prerelease version: a series of dot-separated identifiers.
+  // The prerelease version suffix.
   optional string prerelease_id = 4;
 }
 
diff --git a/appactions/interaction/interaction-service-proto/build.gradle b/appactions/interaction/interaction-service-proto/build.gradle
index c586cf5..66531ed 100644
--- a/appactions/interaction/interaction-service-proto/build.gradle
+++ b/appactions/interaction/interaction-service-proto/build.gradle
@@ -13,23 +13,97 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-
+import androidx.build.BundleInsideHelper
 import androidx.build.LibraryType
 import androidx.build.Publish
 import androidx.build.RunApiTasks
 
 plugins {
     id("AndroidXPlugin")
-    id("com.android.library")
+    id("java-library")
+    id("com.google.protobuf")
 }
 
+BundleInsideHelper.forInsideAar(
+        project,
+        [
+            new BundleInsideHelper.Relocation(/* from = */ "io.grpc.protobuf",
+        /* to =   */ "androidx.appactions.interaction.grpc.protobuf"),
+            new BundleInsideHelper.Relocation(/* from = */ "com.google.protobuf",
+        /* to =   */ "androidx.appactions.interaction.protobuf")
+    ]
+)
+
 dependencies {
-    annotationProcessor(libs.nullaway)
-    // Add dependencies here
+    // TODO(b/268709908): Bump this to version 1.52.0 and make available from libs.grpcProtobufLite
+    bundleInside(compileOnly("io.grpc:grpc-protobuf-lite:1.45.1") {
+        // Ensure we only bundle grpc-protobuf-lite. Any of its dependencies should be added
+        // as `implementation` dependencies below.
+        exclude group: 'com.google.protobuf'
+        exclude group: 'com.google.guava'
+        exclude group: 'io.grpc'
+        exclude group: 'com.google.code.findbugs'
+    })
+
+    // We need to use the non-shadow configurations at compile time to pick up the protos at the
+    // original package location (before renaming). The final AAR will have the renamed/shadowed
+    // configurations which are bundled here and in interaction-service.
+    compileOnly(project(path:":appactions:interaction:interaction-proto", configuration:"archives"))
+
+    // These are the compile-time dependencies needed to build the interaction-service-proto
+    // with the grpc-protobuf-lite dependencies bundled. They need to be added as dependencies in
+    // any library that bundles interaction-service-proto.
+    compileOnly(libs.protobufLite)
+    compileOnly(libs.grpcStub)
+    compileOnly("androidx.annotation:annotation:1.1.0")
+    compileOnly("javax.annotation:javax.annotation-api:1.3.2")
 }
 
-android {
-    namespace "androidx.appactions.interaction.service.proto"
+protobuf {
+    protoc {
+        artifact = libs.protobufCompiler.get()
+    }
+    // Configure the codegen plugins for GRPC.
+    plugins {
+        grpc {
+            artifact = 'io.grpc:protoc-gen-grpc-java:1.52.0'
+        }
+    }
+
+    // Generates the java proto-lite code for the protos in this project. See
+    // https://github.com/google/protobuf-gradle-plugin#customizing-protobuf-compilation
+    // for more information.
+    generateProtoTasks {
+        all().each { task ->
+            task.builtins {
+                java {
+                    option "lite"
+                }
+            }
+            task.plugins {
+                grpc {
+                    option 'lite'
+                }
+            }
+        }
+    }
+}
+
+afterEvaluate {
+    lint {
+        lintOptions {
+            // protobuf generates unannotated and synthetic accessor methods
+            disable("UnknownNullness", "SyntheticAccessor")
+            abortOnError(false)
+            checkReleaseBuilds(false)
+        }
+    }
+    // Explicitly declares generateTestProto as a dependency for some of the lint tasks
+    // run on regular AndroidX libraries.
+    project.tasks.getByName("generateTestProto") {
+        mustRunAfter(":appactions:interaction:interaction-service-proto:lintReport")
+        mustRunAfter(":appactions:interaction:interaction-service-proto:lintAnalyze")
+    }
 }
 
 androidx {
diff --git a/appactions/interaction/interaction-service-proto/src/main/java/androidx/appactions/interaction/package-info.java b/appactions/interaction/interaction-service-proto/src/main/java/androidx/appactions/interaction/package-info.java
index f6042aa..28ff8dc 100644
--- a/appactions/interaction/interaction-service-proto/src/main/java/androidx/appactions/interaction/package-info.java
+++ b/appactions/interaction/interaction-service-proto/src/main/java/androidx/appactions/interaction/package-info.java
@@ -15,6 +15,7 @@
  */
 
 /**
- * Insert package level documentation here
+ * Internal protos for interaction-service.
+ * @hide
  */
 package androidx.appactions.interaction.service.proto;
diff --git a/appactions/interaction/interaction-service/src/main/proto/app_interaction_service.proto b/appactions/interaction/interaction-service-proto/src/main/proto/app_interaction_service.proto
similarity index 100%
rename from appactions/interaction/interaction-service/src/main/proto/app_interaction_service.proto
rename to appactions/interaction/interaction-service-proto/src/main/proto/app_interaction_service.proto
diff --git a/appactions/interaction/interaction-service/build.gradle b/appactions/interaction/interaction-service/build.gradle
index 226a274..7c29f4b 100644
--- a/appactions/interaction/interaction-service/build.gradle
+++ b/appactions/interaction/interaction-service/build.gradle
@@ -13,31 +13,42 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-
+import androidx.build.BundleInsideHelper
 import androidx.build.LibraryType
 
 plugins {
     id("AndroidXPlugin")
     id("com.android.library")
-    id("com.google.protobuf")
     id("org.jetbrains.kotlin.android")
 }
 
+BundleInsideHelper.forInsideAar(
+        project,
+        [
+            new BundleInsideHelper.Relocation(
+                /* from = */ "io.grpc.protobuf",
+                /* to =   */ "androidx.appactions.interaction.grpc.protobuf"),
+            new BundleInsideHelper.Relocation(
+                /* from = */ "com.google.protobuf",
+                /* to =   */ "androidx.appactions.interaction.protobuf")
+        ]
+)
+
 dependencies {
+    bundleInside(project(":appactions:interaction:interaction-service-proto"))
+
+    implementation(project(":appactions:interaction:interaction-proto"))
+    implementation(project(":appactions:interaction:interaction-capabilities-core"))
     implementation(libs.grpcAndroid)
     implementation(libs.grpcBinder)
     implementation(libs.grpcStub)
-    implementation(libs.protobufLite)
+    implementation(libs.kotlinStdlib)
     implementation("androidx.annotation:annotation:1.1.0")
     implementation("androidx.concurrent:concurrent-futures:1.1.0")
     implementation("androidx.wear.tiles:tiles:1.1.0")
     implementation("javax.annotation:javax.annotation-api:1.3.2")
-    // TODO(b/268709908): Bump this to version 1.52.0 and make available from libs.grpcProtobufLite
-    implementation("io.grpc:grpc-protobuf-lite:1.45.1")
-    implementation(project(":appactions:interaction:interaction-capabilities-core"))
-    implementation(project(":appactions:interaction:interaction-proto"))
-    implementation(libs.kotlinStdlib)
 
+    testImplementation(project(":appactions:interaction:interaction-service-proto"))
     testImplementation(libs.kotlinStdlib)
     testImplementation(libs.junit)
     testImplementation(libs.robolectric)
@@ -46,36 +57,7 @@
     testImplementation(libs.testCore)
     testImplementation(libs.testRunner)
     testImplementation(libs.truth)
-}
-
-protobuf {
-    protoc {
-        artifact = libs.protobufCompiler.get()
-    }
-    // Configure the codegen plugins
-    plugins {
-        grpc {
-            artifact = 'io.grpc:protoc-gen-grpc-java:1.52.0'
-        }
-    }
-
-    // Generates the java proto-lite code for the protos in this project. See
-    // https://github.com/google/protobuf-gradle-plugin#customizing-protobuf-compilation
-    // for more information.
-    generateProtoTasks {
-        all().each { task ->
-            task.builtins {
-                java {
-                    option "lite"
-                }
-            }
-            task.plugins {
-                grpc {
-                    option 'lite'
-                }
-            }
-        }
-    }
+    testImplementation(libs.protobufLite)
 }
 
 android {
diff --git a/appactions/interaction/interaction-service/src/main/java/androidx/appactions/interaction/service/TileLayoutInternal.kt b/appactions/interaction/interaction-service/src/main/java/androidx/appactions/interaction/service/TileLayoutInternal.kt
index 26bf46d8..2d65fbc 100644
--- a/appactions/interaction/interaction-service/src/main/java/androidx/appactions/interaction/service/TileLayoutInternal.kt
+++ b/appactions/interaction/interaction-service/src/main/java/androidx/appactions/interaction/service/TileLayoutInternal.kt
@@ -20,7 +20,7 @@
 import androidx.appactions.interaction.service.proto.AppActionsServiceProto
 import androidx.wear.tiles.LayoutElementBuilders
 import androidx.wear.tiles.ResourceBuilders
-import com.google.protobuf.ByteString
+import androidx.appactions.interaction.protobuf.ByteString
 
 /** Holder for TileLayout response. */
 @RestrictTo(RestrictTo.Scope.LIBRARY)
diff --git a/benchmark/baseline-profiles-gradle-plugin/src/main/kotlin/androidx/baselineprofiles/gradle/buildprovider/BaselineProfilesBuildProviderPlugin.kt b/benchmark/baseline-profiles-gradle-plugin/src/main/kotlin/androidx/baselineprofiles/gradle/buildprovider/BaselineProfilesBuildProviderPlugin.kt
index 9f0df32..2abd6ef 100644
--- a/benchmark/baseline-profiles-gradle-plugin/src/main/kotlin/androidx/baselineprofiles/gradle/buildprovider/BaselineProfilesBuildProviderPlugin.kt
+++ b/benchmark/baseline-profiles-gradle-plugin/src/main/kotlin/androidx/baselineprofiles/gradle/buildprovider/BaselineProfilesBuildProviderPlugin.kt
@@ -16,7 +16,9 @@
 
 package androidx.baselineprofiles.gradle.buildprovider
 
+import androidx.baselineprofiles.gradle.utils.checkAgpVersion
 import androidx.baselineprofiles.gradle.utils.createNonObfuscatedBuildTypes
+import androidx.baselineprofiles.gradle.utils.isGradleSyncRunning
 import com.android.build.api.variant.ApplicationAndroidComponentsExtension
 import org.gradle.api.Plugin
 import org.gradle.api.Project
@@ -25,7 +27,7 @@
  * This is the build provider plugin for baseline profile generation. In order to generate baseline
  * profiles three plugins are needed: one is applied to the app or the library that should consume
  * the baseline profile when building (consumer), one is applied to the project that should supply
- * the test apk (build provider) and the last one is applied to a library module containing the ui
+ * the test apk (build provider) and the last one is applied to a test module containing the ui
  * test that generate the baseline profile on the device (producer).
  *
  * TODO (b/265438721): build provider should be changed to apk provider.
@@ -33,13 +35,61 @@
 class BaselineProfilesBuildProviderPlugin : Plugin<Project> {
 
     override fun apply(project: Project) {
+        var foundAppPlugin = false
         project.pluginManager.withPlugin("com.android.application") {
+            foundAppPlugin = true
             configureWithAndroidPlugin(project = project)
         }
+        var foundLibraryPlugin = false
+        project.pluginManager.withPlugin("com.android.library") {
+            foundLibraryPlugin = true
+        }
+
+        // Only used to verify that the android application plugin has been applied.
+        // Note that we don't want to throw any exception if gradle sync is in progress.
+        project.afterEvaluate {
+            if (!project.isGradleSyncRunning()) {
+                if (!foundAppPlugin) {
+
+                    // Check whether the library plugin was applied instead. If that's the case
+                    // it's possible the developer meant to generate a baseline profile for a
+                    // library and we can give further information.
+                    throw IllegalStateException(
+                        if (!foundLibraryPlugin) {
+                            """
+                    The module ${project.name} does not have the `com.android.application` plugin
+                    applied. The `androidx.baselineprofiles.buildprovider` plugin supports only
+                    android application modules. Please review your build.gradle to ensure this
+                    plugin is applied to the correct module.
+                    """.trimIndent()
+                        } else {
+                            """
+                    The module ${project.name} does not have the `com.android.application` plugin
+                    but has the `com.android.library` plugin. If you're trying to generate a
+                    baseline profile for a library, you'll need to apply the
+                    `androidx.baselineprofiles.buildprovider` to an android application that
+                    has the `com.android.application` plugin applied. This should be a sample app
+                    running the code of the library for which you want to generate the profile.
+                    Please review your build.gradle to ensure this plugin is applied to the
+                    correct module.
+                    """.trimIndent()
+                        }
+                    )
+                }
+                project.logger.debug(
+                    """
+                    [BaselineProfilesBuildProviderPlugin] afterEvaluate check: app plugin was applied
+                    """.trimIndent()
+                )
+            }
+        }
     }
 
     private fun configureWithAndroidPlugin(project: Project) {
 
+        // Checks that the required AGP version is applied to this project.
+        project.checkAgpVersion()
+
         // Create the non obfuscated release build types from the existing release ones.
         // We want to extend all the current release build types based on isDebuggable flag.
         project
diff --git a/benchmark/baseline-profiles-gradle-plugin/src/main/kotlin/androidx/baselineprofiles/gradle/consumer/BaselineProfilesConsumerExtension.kt b/benchmark/baseline-profiles-gradle-plugin/src/main/kotlin/androidx/baselineprofiles/gradle/consumer/BaselineProfilesConsumerExtension.kt
index 79a547b..1762884 100644
--- a/benchmark/baseline-profiles-gradle-plugin/src/main/kotlin/androidx/baselineprofiles/gradle/consumer/BaselineProfilesConsumerExtension.kt
+++ b/benchmark/baseline-profiles-gradle-plugin/src/main/kotlin/androidx/baselineprofiles/gradle/consumer/BaselineProfilesConsumerExtension.kt
@@ -16,6 +16,7 @@
 
 package androidx.baselineprofiles.gradle.consumer
 
+import org.gradle.api.Action
 import org.gradle.api.Project
 
 /**
@@ -38,10 +39,142 @@
     }
 
     /**
-     * Specifies what build type should be used to generate baseline profiles. By default this build
-     * type is `release`. In general, this should be a build type used for distribution. Note that
-     * this will be deprecated when b/265438201 is fixed, as all the build types will be used to
-     * generate baseline profiles.
+     * Enables on-demand baseline profile generation. Baseline profiles can be generated
+     * periodically or on-demand. Setting this flag to true will enable on-demand generation.
+     * When on-demand generation is enabled the baseline profile is regenerated before building the
+     * release build type. Note that in on-demand mode the baseline profile file is NOT saved in
+     * the `src/<variant>/baselineProfiles` folder, as opposite to the periodic generation where the
+     * latest baseline profile is always stored in the sources.
      */
-    var buildTypeName: String = "release"
+    var onDemandGeneration = false
+
+    /**
+     * Specifies if baseline profile files should be merged into a single one when generating for
+     * multiple variants:
+     *  - When `true` all the generated baseline profiles for each variant are merged into
+     *      `src/main/generatedBaselineProfiles`'.
+     *  - When `false` each variant will have its own baseline profile in
+     *      `src/<variant>/generated/baselineProfiles`'.
+     *  If this is not specified, by default it will be true for library modules and false for
+     *  application modules.
+     *  Note that when generation is onDemand the output folder is always in the build output
+     *  folder but this setting still determines whether the profile included in the built apk or
+     *  aar is merged into a single one.
+     */
+    var mergeIntoMain: Boolean? = null
+
+    /**
+     * Specifies a filtering rule to decide which profiles rules should be included in this
+     * consumer baseline profile. This is useful especially for libraries, in order to exclude
+     * profile rules for class and methods for dependencies of the sample app. The filter supports:
+     *  - Double wildcards, to match specified package and subpackages. Example: `com.example.**`
+     *  - Wildcards, to match specified package only. Example: `com.example.*`
+     *  - Class names, to match the specified class. Example: `com.example.MyClass`
+     *
+     * Note that when only excludes are specified, if there are no matches with any rule the profile
+     * rule is selected.
+     *
+     * Example to include a package and all the subpackages:
+     * ```
+     *     filter { include "com.somelibrary.**" }
+     * ```
+     *
+     * Example to exclude some packages and include all the rest:
+     * ```
+     *     filter { exclude "com.somelibrary.debug" }
+     * ```
+     *
+     * Example to include and exclude specific packages:
+     * ```
+     *     filter {
+     *          include "com.somelibrary.widget.grid.**"
+     *          exclude "com.somelibrary.widget.grid.debug.**"
+     *          include "com.somelibrary.widget.list.**"
+     *          exclude "com.somelibrary.widget.grid.debug.**"
+     *          include "com.somelibrary.widget.text.**"
+     *          exclude "com.somelibrary.widget.grid.debug.**"
+     *     }
+     * ```
+     *
+     * Filters also support variants and they can be expressed as follows:
+     * ```
+     *     filter { include "com.somelibrary.*" }
+     *     filter("free") { include "com.somelibrary.*" }
+     *     filter("paid") { include "com.somelibrary.*" }
+     *     filter("release") { include "com.somelibrary.*" }
+     *     filter("freeRelease") { include "com.somelibrary.*" }
+     * ```
+     * Filter block without specifying a variant applies to `main`, i.e. all the variants.
+     * Note that when a variant matches multiple filter blocks, all the filters will be merged.
+     * For example with `filter { ... }`, `filter("free") { ... }` and `filter("release") { ... }`
+     * all the blocks will be evaluated for variant `freeRelease` but only `main` and `release` for
+     * variant `paidRelease`.
+     */
+    @JvmOverloads
+    fun filter(variant: String = "main", action: FilterRules.() -> (Unit)) = action
+        .invoke(filterRules.computeIfAbsent(variant) { FilterRules() })
+
+    /**
+     * Specifies a filtering rule to decide which profiles rules should be included in this
+     * consumer baseline profile. This is useful especially for libraries, in order to exclude
+     * profile rules for class and methods for dependencies of the sample app. The filter supports:
+     *  - Double wildcards, to match specified package and subpackages. Example: `com.example.**`
+     *  - Wildcards, to match specified package only. Example: `com.example.*`
+     *  - Class names, to match the specified class. Example: `com.example.MyClass`
+     *
+     * Note that when only excludes are specified, if there are no matches with any rule the profile
+     * rule is selected.
+     *
+     * Example to include a package and all the subpackages:
+     * ```
+     *     filter { include "com.somelibrary.**" }
+     * ```
+     *
+     * Example to exclude some packages and include all the rest:
+     * ```
+     *     filter { exclude "com.somelibrary.debug" }
+     * ```
+     *
+     * Example to include and exclude specific packages:
+     * ```
+     *     filter {
+     *          include "com.somelibrary.widget.grid.**"
+     *          exclude "com.somelibrary.widget.grid.debug.**"
+     *          include "com.somelibrary.widget.list.**"
+     *          exclude "com.somelibrary.widget.list.debug.**"
+     *          include "com.somelibrary.widget.text.**"
+     *          exclude "com.somelibrary.widget.text.debug.**"
+     *     }
+     * ```
+     *
+     * Filters also support variants and they can be expressed as follows:
+     * ```
+     *     filter { include "com.somelibrary.*" }
+     *     filter("free") { include "com.somelibrary.*" }
+     *     filter("paid") { include "com.somelibrary.*" }
+     *     filter("release") { include "com.somelibrary.*" }
+     *     filter("freeRelease") { include "com.somelibrary.*" }
+     * ```
+     * Filter block without specifying a variant applies to `main`, i.e. all the variants.
+     * Note that when a variant matches multiple filter blocks, all the filters will be merged.
+     * For example with `filter { ... }`, `filter("free") { ... }` and `filter("release") { ... }`
+     * all the blocks will be evaluated for variant `freeRelease` but only `main` and `release` for
+     * variant `paidRelease`.
+     */
+    @JvmOverloads
+    fun filter(variant: String = "main", action: Action<FilterRules>) = action
+        .execute(filterRules.computeIfAbsent(variant) { FilterRules() })
+
+    internal val filterRules = mutableMapOf<String, FilterRules>()
+}
+
+class FilterRules {
+    internal val rules = mutableListOf<Pair<RuleType, String>>()
+    fun include(pkg: String) = rules.add(Pair(RuleType.INCLUDE, pkg))
+    fun exclude(pkg: String) = rules.add(Pair(RuleType.EXCLUDE, pkg))
+}
+
+enum class RuleType {
+    INCLUDE,
+    EXCLUDE
 }
diff --git a/benchmark/baseline-profiles-gradle-plugin/src/main/kotlin/androidx/baselineprofiles/gradle/consumer/BaselineProfilesConsumerPlugin.kt b/benchmark/baseline-profiles-gradle-plugin/src/main/kotlin/androidx/baselineprofiles/gradle/consumer/BaselineProfilesConsumerPlugin.kt
index 06b97d9..7e94208 100644
--- a/benchmark/baseline-profiles-gradle-plugin/src/main/kotlin/androidx/baselineprofiles/gradle/consumer/BaselineProfilesConsumerPlugin.kt
+++ b/benchmark/baseline-profiles-gradle-plugin/src/main/kotlin/androidx/baselineprofiles/gradle/consumer/BaselineProfilesConsumerPlugin.kt
@@ -20,56 +20,83 @@
 import androidx.baselineprofiles.gradle.utils.ATTRIBUTE_CATEGORY_BASELINE_PROFILE
 import androidx.baselineprofiles.gradle.utils.ATTRIBUTE_FLAVOR
 import androidx.baselineprofiles.gradle.utils.CONFIGURATION_NAME_BASELINE_PROFILES
+import androidx.baselineprofiles.gradle.utils.INTERMEDIATES_BASE_FOLDER
+import androidx.baselineprofiles.gradle.utils.BUILD_TYPE_BASELINE_PROFILE_PREFIX
 import androidx.baselineprofiles.gradle.utils.camelCase
+import androidx.baselineprofiles.gradle.utils.checkAgpVersion
+import androidx.baselineprofiles.gradle.utils.isGradleSyncRunning
+import androidx.baselineprofiles.gradle.utils.maybeRegister
 import com.android.build.api.variant.AndroidComponentsExtension
-import com.android.build.gradle.AppExtension
-import com.android.build.gradle.LibraryExtension
-import com.android.build.gradle.TestedExtension
+import com.android.build.api.variant.ApplicationAndroidComponentsExtension
+import com.android.build.api.variant.LibraryAndroidComponentsExtension
 import org.gradle.api.Plugin
 import org.gradle.api.Project
+import org.gradle.api.Task
 import org.gradle.api.artifacts.Configuration
 import org.gradle.api.attributes.Category
-import org.gradle.api.tasks.StopExecutionException
+import org.gradle.api.file.Directory
+import org.gradle.api.provider.Provider
+import org.gradle.api.tasks.TaskProvider
 
 /**
  * This is the consumer plugin for baseline profile generation. In order to generate baseline
  * profiles three plugins are needed: one is applied to the app or the library that should consume
  * the baseline profile when building (consumer), one is applied to the project that should supply
- * the apk under test (build provider) and the last one is applied to a library module containing
+ * the apk under test (build provider) and the last one is applied to a test module containing
  * the ui test that generate the baseline profile on the device (producer).
  */
 class BaselineProfilesConsumerPlugin : Plugin<Project> {
 
     companion object {
-
-        // The output file for the HRF baseline profile file in `src/main`
-        private const val BASELINE_PROFILE_SRC_MAIN_FILENAME = "baseline-prof.txt"
+        private const val GENERATE_TASK_NAME = "generate"
+        private const val BASELINE_PROFILE_DIR = "generated/baselineProfiles"
     }
 
     override fun apply(project: Project) {
+        var foundAppOrLibraryPlugin = false
         project.pluginManager.withPlugin("com.android.application") {
+            foundAppOrLibraryPlugin = true
             configureWithAndroidPlugin(project = project, isApplication = true)
         }
         project.pluginManager.withPlugin("com.android.library") {
+            foundAppOrLibraryPlugin = true
             configureWithAndroidPlugin(project = project, isApplication = false)
         }
+
+        // Only used to verify that the android application plugin has been applied.
+        // Note that we don't want to throw any exception if gradle sync is in progress.
+        project.afterEvaluate {
+            if (!project.isGradleSyncRunning()) {
+                if (!foundAppOrLibraryPlugin) {
+                    throw IllegalStateException(
+                        """
+                    The module ${project.name} does not have the `com.android.application` or
+                    `com.android.library` plugin applied. The `androidx.baselineprofiles.consumer`
+                    plugin supports only android application and library modules. Please review
+                    your build.gradle to ensure this plugin is applied to the correct module.
+                    """.trimIndent()
+                    )
+                }
+                project.logger.debug(
+                    """
+                    [BaselineProfilesConsumerPlugin] afterEvaluate check: app or library plugin
+                    was applied""".trimIndent()
+                )
+            }
+        }
     }
 
     private fun configureWithAndroidPlugin(project: Project, isApplication: Boolean) {
 
-        // TODO (b/259737859): This code will be updated to use source sets for baseline profiles,
-        //  as soon androidx repo is updated to use AGP 8.0-beta01.
+        // Checks that the required AGP version is applied to this project.
+        project.checkAgpVersion()
 
-        val androidComponent = project.extensions.getByType(
-            AndroidComponentsExtension::class.java
-        )
+        // Prepares extensions used by the plugin
 
-        val baselineProfilesExtension = BaselineProfilesConsumerExtension.registerExtension(project)
+        val baselineProfilesExtension =
+            BaselineProfilesConsumerExtension.registerExtension(project)
 
-        // Creates all the configurations, one per variant.
-        // Note that for this version of the plugin is not possible to rely entirely on the variant
-        // api so the actual creation of the tasks is postponed to be executed when all the
-        // agp tasks have been created, using the old api.
+        // Creates the main baseline profiles configuration
         val mainBaselineProfileConfiguration = createBaselineProfileConfigurationForVariant(
             project,
             variantName = "",
@@ -77,87 +104,179 @@
             buildTypeName = "",
             mainConfiguration = null
         )
-        val baselineProfileConfigurations = mutableListOf<Configuration>()
-        val baselineProfileVariantNames = mutableListOf<String>()
-        androidComponent.apply {
-            onVariants {
 
-                // Only create configurations for the build type expressed in the baseline profiles
-                // extension. Note that this can be removed after b/265438201.
-                if (it.buildType != baselineProfilesExtension.buildTypeName) {
-                    return@onVariants
+        // If that's the case, we check that we're generating for a non debuggable build type.
+        val buildTypes = mutableListOf<String>()
+
+        // Determines which build types are available if this module is an application.
+        // This extension exists only if the module is an application.
+        project
+            .extensions
+            .findByType(ApplicationAndroidComponentsExtension::class.java)
+            ?.finalizeDsl { ext ->
+                buildTypes.addAll(ext.buildTypes
+                    .filter {
+                        !it.isDebuggable &&
+                            !it.name.startsWith(BUILD_TYPE_BASELINE_PROFILE_PREFIX)
+                    }
+                    .map { it.name })
+            }
+
+        // Determines which build types are available if this module is an application.
+        // This extension exists only if the module is a library.
+        project
+            .extensions
+            .findByType(LibraryAndroidComponentsExtension::class.java)
+            ?.finalizeDsl { ext ->
+                buildTypes.addAll(ext.buildTypes
+                    .filter {
+
+                        // Note that library build types don't have a `debuggable` flag.
+                        it.name != "debug" &&
+                            !it.name.startsWith(BUILD_TYPE_BASELINE_PROFILE_PREFIX)
+                    }
+                    .map { it.name })
+            }
+
+        // Iterate variants to create per-variant tasks and configurations. Note that the src set
+        // api for variants are marked as unstable but there is no other way to do this.
+        @Suppress("UnstableApiUsage")
+        project
+            .extensions
+            .getByType(AndroidComponentsExtension::class.java)
+            .apply {
+                onVariants { variant ->
+
+                    if (variant.buildType !in buildTypes) {
+                        return@onVariants
+                    }
+
+                    // Creates the configuration to carry the specific variant artifact
+                    val baselineProfileConfiguration =
+                        createBaselineProfileConfigurationForVariant(
+                            project,
+                            variantName = variant.name,
+                            flavorName = variant.flavorName ?: "",
+                            buildTypeName = variant.buildType ?: "",
+                            mainConfiguration = mainBaselineProfileConfiguration
+                        )
+
+                    // There are 2 different ways in which the output task can merge the baseline
+                    // profile rules, according to [BaselineProfilesConsumerExtension#mergeIntoMain].
+                    // When mergeIntoMain is `true` the first variant will create a task shared across
+                    // all the variants to merge, while the next variants will simply add the additional
+                    // baseline profile artifacts, modifying the existing task.
+                    // When mergeIntoMain is `false` each variants has its own task with a single
+                    // artifact per task, specific for that variant.
+                    // When mergeIntoMain is not specified, it's by default true for libraries and false
+                    // for apps.
+                    val mergeIntoMain = baselineProfilesExtension.mergeIntoMain ?: !isApplication
+
+                    val (taskName, outputVariantFolder) = if (mergeIntoMain) {
+                        listOf("", "main")
+                    } else {
+                        listOf(variant.name, variant.name)
+                    }
+
+                    // Creates the task to merge the baseline profile artifacts coming from different
+                    // configurations. Note that this is the last task of the chain that triggers the
+                    // whole generation, hence it's called `generate`. The name is generated according
+                    // to the value of the `merge`.
+                    val genBaselineProfilesTaskProvider = project
+                        .tasks
+                        .maybeRegister<GenerateBaselineProfileTask>(
+                            GENERATE_TASK_NAME, taskName, "baselineProfiles",
+                        ) { task ->
+
+                            // These are all the configurations this task depends on,
+                            // in order to consume their artifacts. Note that if this task already
+                            // exist (for example if `merge` is `all`) the new artifact will be
+                            // added to the existing list.
+                            task.baselineProfileFileCollection
+                                .from
+                                .add(baselineProfileConfiguration)
+
+                            // This is the task output for the generated baseline profile
+                            task.baselineProfileDir.set(
+                                baselineProfilesExtension.baselineProfileOutputDir(
+                                    project = project,
+                                    variantName = outputVariantFolder
+                                )
+                            )
+
+                            // Sets the package filter rules. If this is the first task
+                            task.filterRules.addAll(
+                                baselineProfilesExtension.filterRules
+                                    .filter {
+                                        it.key in listOfNotNull(
+                                            "main",
+                                            variant.flavorName,
+                                            variant.buildType,
+                                            variant.name
+                                        )
+                                    }
+                                    .flatMap { it.value.rules }
+                            )
+                        }
+
+                    // The output folders for variant and main profiles are added as source dirs using
+                    // source sets api. This cannot be done in the `configure` block of the generation
+                    // task. The `onDemand` flag is checked here and the src set folder is chosen
+                    // accordingly: if `true`, baseline profiles are saved in the src folder so they
+                    // can be committed with srcs, if `false` they're stored in the generated build
+                    // files.
+                    if (baselineProfilesExtension.onDemandGeneration) {
+                        variant.sources.baselineProfiles?.apply {
+                            addGeneratedSourceDirectory(
+                                genBaselineProfilesTaskProvider,
+                                GenerateBaselineProfileTask::baselineProfileDir
+                            )
+                        }
+                    } else {
+                        val baselineProfileSourcesFile = baselineProfilesExtension
+                            .baselineProfileOutputDir(
+                                project = project,
+                                variantName = outputVariantFolder
+                            )
+                            .get()
+                            .asFile
+
+                        // If the folder does not exist it means that the profile has not been generated
+                        // so we don't need to add to sources.
+                        if (baselineProfileSourcesFile.exists()) {
+                            variant.sources.baselineProfiles?.addStaticSourceDirectory(
+                                baselineProfileSourcesFile.absolutePath
+                            )
+                        }
+                    }
+
+                    // This creates a task hierarchy to trigger generations for all the variants of a
+                    // specific build type, flavor or all of them. If `mergeIntoMain` is true, only one
+                    // generation task exists. Also if there are no flavors the variant name contains
+                    // the build type only, so no need to create a parent task for the build type.
+                    // Note that we cannot create the other parent tasks for flavor and global due to
+                    // b/265438201.
+                    if (!mergeIntoMain && variant.name != variant.buildType) {
+                        maybeCreateParentGenTask(
+                            project,
+                            variant.buildType,
+                            genBaselineProfilesTaskProvider
+                        )
+                    }
                 }
-
-                baselineProfileConfigurations.add(
-                    createBaselineProfileConfigurationForVariant(
-                        project,
-                        variantName = it.name,
-                        flavorName = it.flavorName ?: "",
-                        buildTypeName = it.buildType ?: "",
-                        mainConfiguration = mainBaselineProfileConfiguration
-                    )
-                )
-
-                // Save this variant name so later we can use it to set a dependency on the
-                // merge/prepare art profile task for it.
-                baselineProfileVariantNames.add(it.name)
             }
-        }
+    }
 
-        // Now that the configurations are created, the tasks can be created. The consumer plugin
-        // can only be applied to either applications or libraries.
-        // Note that for this plugin does not use the new variant api as it tries to access to some
-        // AGP tasks that don't yet exist in the new variant api callback (b/262007432).
-        val extensionVariants =
-            when (val tested = project.extensions.getByType(TestedExtension::class.java)) {
-                is AppExtension -> tested.applicationVariants
-                is LibraryExtension -> tested.libraryVariants
-                else -> throw StopExecutionException(
-                    """
-                Unrecognized extension: $tested not of type AppExtension or LibraryExtension.
-                """.trimIndent()
-                )
-            }
-
-        // After variants have been resolved and the AGP tasks have been created add the plugin tasks.
-        var applied = false
-        extensionVariants.all {
-            if (applied) return@all
-            applied = true
-
-            // Currently the plugin does not support generating a baseline profile for a specific
-            // flavor: all the flavors are merged into one and copied in src/main/baseline-prof.txt.
-            // This can be changed after b/239659205 when baseline profiles become a source set.
-            val mergeBaselineProfilesTaskProvider = project.tasks.register(
-                "generateBaselineProfiles", MergeBaselineProfileTask::class.java
-            ) { task ->
-
-                // These are all the configurations this task depends on, in order to consume their
-                // artifacts.
-                task.baselineProfileFileCollection.setFrom(baselineProfileConfigurations)
-
-                // This is the output file where all the configurations will be merged in.
-                // Note that this file is overwritten.
-                task.baselineProfileFile.set(
-                    project
-                        .layout
-                        .projectDirectory
-                        .file("src/main/$BASELINE_PROFILE_SRC_MAIN_FILENAME")
-                )
-            }
-
-            // If this is an application the mergeBaselineProfilesTask must run before the
-            // tasks that handle the baseline profile packaging. Merge for applications, prepare
-            // for libraries. Note that this will change with AGP 8.0 that should support
-            // source sets for baseline profiles.
-            for (variantName in baselineProfileVariantNames) {
-                val taskProvider = if (isApplication) {
-                    project.tasks.named(camelCase("merge", variantName, "artProfile"))
-                } else {
-                    project.tasks.named(camelCase("prepare", variantName, "artProfile"))
-                }
-                taskProvider.configure { it.mustRunAfter(mergeBaselineProfilesTaskProvider) }
-            }
+    private fun maybeCreateParentGenTask(
+        project: Project,
+        parentName: String?,
+        childGenerationTaskProvider: TaskProvider<GenerateBaselineProfileTask>
+    ) {
+        if (parentName == null) return
+        project.tasks.maybeRegister<Task>(GENERATE_TASK_NAME, parentName, "baselineProfiles") {
+            it.group = "Baseline Profiles"
+            it.description = "Generates baseline profiles."
+            it.dependsOn(childGenerationTaskProvider)
         }
     }
 
@@ -238,4 +357,31 @@
                 }
             }
     }
-}
\ No newline at end of file
+
+    fun BaselineProfilesConsumerExtension.baselineProfileOutputDir(
+        project: Project,
+        variantName: String
+    ): Provider<Directory> =
+        if (onDemandGeneration) {
+
+            // In on demand mode, the baseline profile is regenerated when building
+            // release and it's not saved in the module sources. To achieve this
+            // we can create an intermediate folder for the profile and add the
+            // generation task to src sets.
+            project
+                .layout
+                .buildDirectory
+                .dir("$INTERMEDIATES_BASE_FOLDER/$variantName/$BASELINE_PROFILE_DIR")
+        } else {
+
+            // In periodic mode the baseline profile generation is manually triggered.
+            // The baseline profile is stored in the baseline profile sources for
+            // the variant.
+            project.providers.provider {
+                project
+                    .layout
+                    .projectDirectory
+                    .dir("src/$variantName/$BASELINE_PROFILE_DIR/")
+            }
+        }
+}
diff --git a/benchmark/baseline-profiles-gradle-plugin/src/main/kotlin/androidx/baselineprofiles/gradle/consumer/GenerateBaselineProfileTask.kt b/benchmark/baseline-profiles-gradle-plugin/src/main/kotlin/androidx/baselineprofiles/gradle/consumer/GenerateBaselineProfileTask.kt
new file mode 100644
index 0000000..3142567
--- /dev/null
+++ b/benchmark/baseline-profiles-gradle-plugin/src/main/kotlin/androidx/baselineprofiles/gradle/consumer/GenerateBaselineProfileTask.kt
@@ -0,0 +1,198 @@
+/*
+ * Copyright 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.baselineprofiles.gradle.consumer
+
+import org.gradle.api.DefaultTask
+import org.gradle.api.GradleException
+import org.gradle.api.file.ConfigurableFileCollection
+import org.gradle.api.file.DirectoryProperty
+import org.gradle.api.provider.ListProperty
+import org.gradle.api.tasks.CacheableTask
+import org.gradle.api.tasks.Input
+import org.gradle.api.tasks.InputFiles
+import org.gradle.api.tasks.OutputDirectory
+import org.gradle.api.tasks.PathSensitive
+import org.gradle.api.tasks.PathSensitivity
+import org.gradle.api.tasks.TaskAction
+
+/**
+ * Collects all the baseline profile artifacts generated by all the producer configurations and
+ * merges them into one, sorting and ensuring that there are no duplicated lines.
+ *
+ * The format of the profile is a simple list of classes and methods loaded in memory when
+ * executing a test, expressed in JVM format. Duplicates can arise when multiple tests cover the
+ * same code: for example when having 2 tests both covering the startup path and then doing
+ * something else, both will have startup classes and methods. There is no harm in having this
+ * duplication but mostly the profile file will be unnecessarily larger.
+ */
+@CacheableTask
+abstract class GenerateBaselineProfileTask : DefaultTask() {
+
+    companion object {
+
+        // The output file for the HRF baseline profile file in `src/main`
+        private const val BASELINE_PROFILE_FILENAME = "baseline-prof.txt"
+    }
+
+    @get:InputFiles
+    @get:PathSensitive(PathSensitivity.NONE)
+    abstract val baselineProfileFileCollection: ConfigurableFileCollection
+
+    @get:Input
+    abstract val filterRules: ListProperty<Pair<RuleType, String>>
+
+    @get:OutputDirectory
+    abstract val baselineProfileDir: DirectoryProperty
+
+    init {
+        group = "Baseline Profiles"
+        description = "Generates baseline profiles."
+    }
+
+    @TaskAction
+    fun exec() {
+
+        // Check if there are no dependencies.
+        if (baselineProfileFileCollection.files.isEmpty()) {
+            throw GradleException(
+                """
+                The baseline profile consumer plugin is applied to this module but no dependency
+                has been set or the added dependencies are not generating any artifact. Please
+                review your build.gradle configuration making sure that a `baselineprofiles`
+                dependency exists and points to a valid `com.android.test` module that has the
+                `androidx.baselineprofiles.producer` plugin applied.
+            """.trimIndent()
+            )
+        }
+
+        // Rules are sorted for package depth and excludes are always evaluated first.
+        val rules = filterRules
+            .get()
+            .sortedWith(
+                compareBy<Pair<RuleType, String>> { r ->
+                    r.second.split(".").size
+                }.thenComparing { r ->
+                    if (r.first == RuleType.INCLUDE) 0 else 1
+                }.reversed()
+            )
+
+        // The profile rules here are:
+        // - read from the configuration artifacts and merged in a single list
+        // - the list is sorted (since we group by class later, we want the input to the group by
+        //      operation not to be influenced by reading order)
+        // - group by class and method (ignoring flag) and for each group keep only the first value
+        // - apply the filters
+        // - sort with comparator
+        val profileRules = baselineProfileFileCollection.files
+            .flatMap { it.readLines() }
+            .sorted()
+            .asSequence()
+            .mapNotNull { ProfileRule.parse(it) }
+            .groupBy { it.classDescriptor + it.methodDescriptor }
+            .map { it.value[0] }
+            .filter {
+
+                // If no rules are specified, always include this line.
+                if (rules.isEmpty()) return@filter true
+
+                // Otherwise rules are evaluated in the order they've been sorted previously.
+                for (r in rules) {
+                    if (r.matches(it.fullClassName)) {
+                        return@filter r.isInclude()
+                    }
+                }
+
+                // If the rules were all excludes and nothing matched, we can include this line
+                // otherwise exclude it.
+                return@filter !rules.any { r -> r.isInclude() }
+            }
+            .sortedWith(ProfileRule.comparator)
+            .map { it.underlying }
+
+        if (profileRules.isEmpty()) {
+            throw GradleException(
+                """
+                The baseline profile consumer plugin is configured with filters that exclude all
+                the profile rules. Please review your build.gradle configuration and make sure your
+                filters don't exclude all the baseline profile rules.
+            """.trimIndent()
+            )
+        }
+
+        baselineProfileDir
+            .file(BASELINE_PROFILE_FILENAME)
+            .get()
+            .asFile
+            .writeText(profileRules.joinToString(System.lineSeparator()))
+    }
+
+    private fun Pair<RuleType, String>.isInclude(): Boolean = first == RuleType.INCLUDE
+    private fun Pair<RuleType, String>.matches(fullClassName: String): Boolean {
+        val rule = second
+        return when {
+            rule.endsWith(".**") -> {
+                // This matches package and subpackages
+                val pkg = fullClassName.split(".").dropLast(1).joinToString(".")
+                val rulePkg = rule.dropLast(3)
+                pkg.startsWith(rulePkg)
+            }
+
+            rule.endsWith(".*") -> {
+                // This matches only the package
+                val pkgParts = fullClassName.split(".").dropLast(1)
+                val pkg = pkgParts.joinToString(".")
+                val rulePkg = rule.dropLast(2)
+                val ruleParts = rulePkg.split(".")
+                pkg.startsWith(rulePkg) && ruleParts.size == pkgParts.size
+            }
+
+            else -> {
+                // This matches only the specific class name
+                fullClassName == rule
+            }
+        }
+    }
+}
+
+// Implementation from:
+// benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/ProfileRule.kt
+private data class ProfileRule(
+    val underlying: String,
+    val flags: String,
+    val classDescriptor: String,
+    val methodDescriptor: String?,
+    val fullClassName: String,
+) {
+    companion object {
+
+        private val PROFILE_RULE_REGEX = "(H?S?P?)L([^;]*);(->)?(.*)".toRegex()
+
+        fun parse(rule: String): ProfileRule? = when (val result = PROFILE_RULE_REGEX.find(rule)) {
+            null -> null
+            else -> {
+                // Ignore `->`
+                val (flags, classDescriptor, _, methodDescriptor) = result.destructured
+                val fullClassName = classDescriptor.split("/").joinToString(".")
+                ProfileRule(rule, flags, classDescriptor, methodDescriptor, fullClassName)
+            }
+        }
+
+        internal val comparator: Comparator<ProfileRule> = compareBy(
+            { it.classDescriptor }, { it.methodDescriptor ?: "" }
+        )
+    }
+}
diff --git a/benchmark/baseline-profiles-gradle-plugin/src/main/kotlin/androidx/baselineprofiles/gradle/consumer/MergeBaselineProfileTask.kt b/benchmark/baseline-profiles-gradle-plugin/src/main/kotlin/androidx/baselineprofiles/gradle/consumer/MergeBaselineProfileTask.kt
deleted file mode 100644
index 2277e63..0000000
--- a/benchmark/baseline-profiles-gradle-plugin/src/main/kotlin/androidx/baselineprofiles/gradle/consumer/MergeBaselineProfileTask.kt
+++ /dev/null
@@ -1,63 +0,0 @@
-/*
- * Copyright 2022 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package androidx.baselineprofiles.gradle.consumer
-
-import org.gradle.api.DefaultTask
-import org.gradle.api.file.ConfigurableFileCollection
-import org.gradle.api.file.RegularFileProperty
-import org.gradle.api.tasks.CacheableTask
-import org.gradle.api.tasks.InputFiles
-import org.gradle.api.tasks.OutputFile
-import org.gradle.api.tasks.PathSensitive
-import org.gradle.api.tasks.PathSensitivity
-import org.gradle.api.tasks.TaskAction
-
-/**
- * Collects all the baseline profile artifacts generated by all the producer configurations and
- * merges them into one, sorting and ensuring that there are no duplicated lines.
- *
- * The format of the profile is a simple list of classes and methods loaded in memory when
- * executing a test, expressed in JVM format. Duplicates can arise when multiple tests cover the
- * same code: for example when having 2 tests both covering the startup path and then doing
- * something else, both will have startup classes and methods. There is no harm in having this
- * duplication but mostly the profile file will be unnecessarily larger.
- */
-@CacheableTask
-abstract class MergeBaselineProfileTask : DefaultTask() {
-
-    @get:InputFiles
-    @get:PathSensitive(PathSensitivity.NONE)
-    abstract val baselineProfileFileCollection: ConfigurableFileCollection
-
-    @get:OutputFile
-    abstract val baselineProfileFile: RegularFileProperty
-
-    init {
-        group = "Baseline Profiles"
-        description = "Merges all the baseline profiles into one, removing duplicate lines."
-    }
-
-    @TaskAction
-    fun exec() {
-        val lines = baselineProfileFileCollection.files
-            .flatMap { it.readLines() }
-            .sorted()
-            .distinct()
-
-        baselineProfileFile.get().asFile.writeText(lines.joinToString(System.lineSeparator()))
-    }
-}
\ No newline at end of file
diff --git a/benchmark/baseline-profiles-gradle-plugin/src/main/kotlin/androidx/baselineprofiles/gradle/producer/BaselineProfilesProducerExtension.kt b/benchmark/baseline-profiles-gradle-plugin/src/main/kotlin/androidx/baselineprofiles/gradle/producer/BaselineProfilesProducerExtension.kt
index e1222bf..652e01f 100644
--- a/benchmark/baseline-profiles-gradle-plugin/src/main/kotlin/androidx/baselineprofiles/gradle/producer/BaselineProfilesProducerExtension.kt
+++ b/benchmark/baseline-profiles-gradle-plugin/src/main/kotlin/androidx/baselineprofiles/gradle/producer/BaselineProfilesProducerExtension.kt
@@ -16,6 +16,7 @@
 
 package androidx.baselineprofiles.gradle.producer
 
+import org.gradle.api.Incubating
 import org.gradle.api.Project
 
 /**
@@ -56,7 +57,14 @@
     var managedDevices = mutableListOf<String>()
 
     /**
-     * Whether baseline profiles should be generated on connected devices.
+     * Whether baseline profiles should be generated on connected devices. Note that in order to
+     * generate a baseline profile, the device is required to be rooted or api level >= 33.
      */
-    var useConnectedDevices: Boolean = true
+    var useConnectedDevices = true
+
+    /**
+     * Enables the emulator display for GMD devices. This is not a stable api.
+     */
+    @Incubating
+    var enableEmulatorDisplay = false
 }
diff --git a/benchmark/baseline-profiles-gradle-plugin/src/main/kotlin/androidx/baselineprofiles/gradle/producer/BaselineProfilesProducerPlugin.kt b/benchmark/baseline-profiles-gradle-plugin/src/main/kotlin/androidx/baselineprofiles/gradle/producer/BaselineProfilesProducerPlugin.kt
index 0f60d71..de80cf5 100644
--- a/benchmark/baseline-profiles-gradle-plugin/src/main/kotlin/androidx/baselineprofiles/gradle/producer/BaselineProfilesProducerPlugin.kt
+++ b/benchmark/baseline-profiles-gradle-plugin/src/main/kotlin/androidx/baselineprofiles/gradle/producer/BaselineProfilesProducerPlugin.kt
@@ -21,9 +21,13 @@
 import androidx.baselineprofiles.gradle.utils.ATTRIBUTE_FLAVOR
 import androidx.baselineprofiles.gradle.utils.BUILD_TYPE_BASELINE_PROFILE_PREFIX
 import androidx.baselineprofiles.gradle.utils.CONFIGURATION_NAME_BASELINE_PROFILES
+import androidx.baselineprofiles.gradle.utils.INTERMEDIATES_BASE_FOLDER
 import androidx.baselineprofiles.gradle.utils.camelCase
+import androidx.baselineprofiles.gradle.utils.checkAgpVersion
 import androidx.baselineprofiles.gradle.utils.createBuildTypeIfNotExists
 import androidx.baselineprofiles.gradle.utils.createNonObfuscatedBuildTypes
+import androidx.baselineprofiles.gradle.utils.isGradleSyncRunning
+import com.android.build.api.dsl.TestBuildType
 import com.android.build.api.variant.TestAndroidComponentsExtension
 import com.android.build.gradle.TestExtension
 import org.gradle.api.GradleException
@@ -36,19 +40,47 @@
  * This is the producer plugin for baseline profile generation. In order to generate baseline
  * profiles three plugins are needed: one is applied to the app or the library that should consume
  * the baseline profile when building (consumer), one is applied to the project that should supply
- * the apk under test (build provider) and the last one is applied to a library module containing
+ * the apk under test (build provider) and the last one is applied to a test module containing
  * the ui test that generate the baseline profile on the device (producer).
  */
 class BaselineProfilesProducerPlugin : Plugin<Project> {
 
+    companion object {
+        private const val COLLECT_TASK_NAME = "collect"
+    }
+
     override fun apply(project: Project) {
+        var foundTestPlugin = false
         project.pluginManager.withPlugin("com.android.test") {
+            foundTestPlugin = true
             configureWithAndroidPlugin(project = project)
         }
+
+        // Only used to verify that the android test plugin has been applied.
+        project.afterEvaluate {
+            if (!project.isGradleSyncRunning()) {
+                if (!foundTestPlugin) {
+                    throw IllegalStateException(
+                        """
+                    The module ${project.name} does not have the `com.android.test` plugin
+                    applied. The `androidx.baselineprofiles.producer` plugin supports only android
+                    test modules. Please review your build.gradle to ensure this plugin is applied
+                    to the correct module.
+                    """.trimIndent()
+                    )
+                }
+                project.logger.debug(
+                    "[BaselineProfilesProducerPlugin] afterEvaluate check: app plugin was applied"
+                )
+            }
+        }
     }
 
     private fun configureWithAndroidPlugin(project: Project) {
 
+        // Checks that the required AGP version is applied to this project.
+        project.checkAgpVersion()
+
         // Prepares extensions used by the plugin
         val baselineProfilesExtension =
             BaselineProfilesProducerExtension.registerExtension(project)
@@ -62,42 +94,51 @@
         testExtension.experimentalProperties["android.experimental.self-instrumenting"] = true
 
         // Creates the new build types to match the build provider. Note that release does not
-        // exist by default so we need to create nonObfuscatedRelease and map it manually to
+        // exist by default so we need to create nonMinifiedRelease and map it manually to
         // `release`. All the existing build types beside `debug`, that is the default one, are
         // added manually in the configuration so we can assume they've been added for the purpose
-        // of generating baseline profiles. We don't need to create a nonObfuscated build type from
-        // `debug`.
+        // of generating baseline profiles. We don't need to create a nonMinified build type from
+        // `debug` since there will be no matching configuration with the apk provider module.
 
         val nonObfuscatedReleaseName = camelCase(BUILD_TYPE_BASELINE_PROFILE_PREFIX, "release")
         val extendedTypeToOriginalTypeMapping = mutableMapOf(nonObfuscatedReleaseName to "release")
 
         testAndroidComponent.finalizeDsl { ext ->
 
+            // The test build types need to be debuggable and have the same singing config key to
+            // be installed. We also disable the test coverage tracking since it's not important
+            // here.
+            val configureBlock: TestBuildType.() -> (Unit) = {
+                isDebuggable = true
+                enableAndroidTestCoverage = false
+                enableUnitTestCoverage = false
+                signingConfig = ext.buildTypes.getByName("debug").signingConfig
+                matchingFallbacks += listOf("release")
+            }
+
+            // The variant names are used by the test module to request a specific apk artifact to
+            // the under test app module (using configuration attributes). This is all handled by
+            // the com.android.test plugin, as long as both modules have the same variants.
+            // Unfortunately the test module cannot determine which variants are present in the
+            // under test app module. As a result we need to replicate the same build types and
+            // flavors, so that the same variant names are created.
             createNonObfuscatedBuildTypes(
                 project = project,
                 extension = ext,
                 extendedBuildTypeToOriginalBuildTypeMapping = extendedTypeToOriginalTypeMapping,
+                configureBlock = configureBlock,
                 filterBlock = {
-                    // TODO: Which build types to skip. In theory we want to skip only debug because
-                    //  it's the default one. All the ones that have been manually added should be
-                    //  considered for this.
+                    // All the build types that have been added to the test module be extended.
+                    // This is because we can't know here which ones are actually release in the
+                    // under test module.
                     it.name != "debug"
                 },
-                configureBlock = {
-                    enableAndroidTestCoverage = false
-                    enableUnitTestCoverage = false
-                },
             )
-
             createBuildTypeIfNotExists(
                 project = project,
                 extension = ext,
                 buildTypeName = nonObfuscatedReleaseName,
-                configureBlock = {
-                    enableAndroidTestCoverage = false
-                    enableUnitTestCoverage = false
-                    matchingFallbacks += listOf("release")
-                }
+                configureBlock = configureBlock
             )
         }
 
@@ -119,17 +160,21 @@
 
                 // Creating configurations only for the extended build types.
                 if (it.buildType == null ||
-                    it.buildType !in extendedTypeToOriginalTypeMapping.keys) {
+                    it.buildType !in extendedTypeToOriginalTypeMapping.keys
+                ) {
                     return@onVariants
                 }
 
+                val flavorName =
+                    if (it.flavorName == null || it.flavorName!!.isEmpty()) null else it.flavorName
+
                 // Creates the configuration to handle this variant. Note that in the attributes
                 // to match the configuration we use the original build type without `nonObfuscated`.
                 val originalBuildTypeName = extendedTypeToOriginalTypeMapping[it.buildType] ?: ""
                 val configurationName = createBaselineProfileConfigurationForVariant(
                     project = project,
                     variantName = it.name,
-                    flavorName = it.flavorName ?: "",
+                    flavorName = flavorName,
                     originalBuildTypeName = originalBuildTypeName
                 )
 
@@ -138,7 +183,7 @@
                     createTasksForVariant(
                         project = project,
                         variantName = it.name,
-                        flavorName = it.flavorName ?: "",
+                        flavorName = flavorName,
                         configurationName = configurationName,
                         baselineProfilesExtension = baselineProfilesExtension
                     )
@@ -159,7 +204,7 @@
     private fun createTasksForVariant(
         project: Project,
         variantName: String,
-        flavorName: String,
+        flavorName: String?,
         configurationName: String,
         baselineProfilesExtension: BaselineProfilesProducerExtension
     ) {
@@ -178,44 +223,67 @@
         // The test task runs the ui tests
         val testTasks = devices.map {
             try {
-                project.tasks.named(camelCase(it, variantName, "androidTest"))
+                project.tasks.named(camelCase(it, variantName, "androidTest")).apply {
+                    configure { t ->
+                        // TODO: this is a bit hack-ish but we can rewrite if we decide to keep the
+                        //  configuration [BaselineProfilesProducerExtension.enableEmulatorDisplay]
+                        if (t.hasProperty("enableEmulatorDisplay")) {
+                            t.setProperty(
+                                "enableEmulatorDisplay",
+                                baselineProfilesExtension.enableEmulatorDisplay
+                            )
+                        }
+                    }
+                }
             } catch (e: UnknownTaskException) {
+
+                // If gradle is syncing don't throw any exception and simply stop here. This plugin
+                // will fail at build time instead. This allows not breaking project sync in ide.
+                if (project.isGradleSyncRunning()) {
+                    return
+                }
+
                 throw GradleException(
                     """
                     It wasn't possible to determine the test task for managed device `$it`.
-                    Please check the managed devices specified in the baseline profiles configuration.
+                    Please check the managed devices specified in the baseline profiles
+                    configuration.
                 """.trimIndent(), e
                 )
             }
         }
 
-        // Merge result protos task
-        val mergeResultProtosTask = project.tasks.named(
-            camelCase("merge", variantName, "testResultProtos")
-        )
-
         // The collect task collects the baseline profile files from the ui test results
         val collectTaskProvider = project.tasks.register(
-            camelCase("collect", variantName, "BaselineProfiles"),
+            camelCase(COLLECT_TASK_NAME, variantName, "BaselineProfiles"),
             CollectBaselineProfilesTask::class.java
         ) {
 
             // Test tasks have to run before collect
-            it.dependsOn(testTasks, mergeResultProtosTask)
+            it.dependsOn(testTasks)
+
+            // Merge result protos task may not exist depending on gradle version
+            val mergeTaskName = camelCase("merge", variantName, "testResultProtos")
+            try {
+                it.dependsOn(project.tasks.named(mergeTaskName))
+            } catch (e: UnknownTaskException) {
+                // Nothing to do.
+                project.logger.info("Task $mergeTaskName does not exist.")
+            }
 
             // Sets flavor name
             it.outputFile.set(
                 project
                     .layout
                     .buildDirectory
-                    .file("intermediates/baselineprofiles/$flavorName/baseline-prof.txt")
+                    .file("$INTERMEDIATES_BASE_FOLDER/$flavorName/baseline-prof.txt")
             )
 
             // Sets the connected test results location, if tests are supposed to run also on
             // connected devices.
             if (shouldExpectConnectedOutput) {
                 it.connectedAndroidTestOutputDir.set(
-                    if (flavorName.isEmpty()) {
+                    if (flavorName == null) {
                         project.layout.buildDirectory
                             .dir("outputs/androidTest-results/connected")
                     } else {
@@ -229,7 +297,7 @@
             // also on managed devices.
             if (shouldExpectManagedOutput) {
                 it.managedAndroidTestOutputDir.set(
-                    if (flavorName.isEmpty()) {
+                    if (flavorName == null) {
                         project.layout.buildDirectory.dir(
                             "outputs/androidTest-results/managedDevice"
                         )
@@ -253,7 +321,7 @@
     private fun createBaselineProfileConfigurationForVariant(
         project: Project,
         variantName: String,
-        flavorName: String,
+        flavorName: String?,
         originalBuildTypeName: String,
     ): String {
         val configurationName =
@@ -264,6 +332,8 @@
                 isCanBeResolved = false
                 isCanBeConsumed = true
                 attributes {
+
+                    // Main specialized attribute
                     it.attribute(
                         Category.CATEGORY_ATTRIBUTE,
                         project.objects.named(
@@ -271,14 +341,14 @@
                             ATTRIBUTE_CATEGORY_BASELINE_PROFILE
                         )
                     )
-                    it.attribute(
-                        ATTRIBUTE_BUILD_TYPE,
-                        originalBuildTypeName
-                    )
-                    it.attribute(
-                        ATTRIBUTE_FLAVOR,
-                        flavorName
-                    )
+
+                    // Build type
+                    it.attribute(ATTRIBUTE_BUILD_TYPE, originalBuildTypeName)
+
+                    // Flavor if existing
+                    if (flavorName != null) {
+                        it.attribute(ATTRIBUTE_FLAVOR, flavorName)
+                    }
                 }
             }
         return configurationName
diff --git a/benchmark/baseline-profiles-gradle-plugin/src/main/kotlin/androidx/baselineprofiles/gradle/utils/Agp.kt b/benchmark/baseline-profiles-gradle-plugin/src/main/kotlin/androidx/baselineprofiles/gradle/utils/Agp.kt
new file mode 100644
index 0000000..ce2ce31
--- /dev/null
+++ b/benchmark/baseline-profiles-gradle-plugin/src/main/kotlin/androidx/baselineprofiles/gradle/utils/Agp.kt
@@ -0,0 +1,56 @@
+/*
+ * 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.baselineprofiles.gradle.utils
+
+import com.android.build.api.variant.AndroidComponentsExtension
+import org.gradle.api.GradleException
+import org.gradle.api.Project
+
+private val gradleSyncProps by lazy {
+    listOf(
+        "android.injected.build.model.v2",
+        "android.injected.build.model.only",
+        "android.injected.build.model.only.advanced",
+    )
+}
+
+internal fun Project.isGradleSyncRunning() =
+    gradleSyncProps.any { it in properties && properties[it].toString().toBoolean() }
+
+internal fun Project.checkAgpVersion() {
+    val extension = project.extensions.findByType(AndroidComponentsExtension::class.java)
+        ?: if (!isGradleSyncRunning()) {
+            throw GradleException(
+                """
+                    The module $name does not have a registered `AndroidComponentsExtension`. This can
+                    only happen if this is not an Android module. Please review your build.gradle to
+                    ensure this plugin is applied to the correct module.
+                """.trimIndent()
+            )
+        } else return
+
+    val agpVersion = extension.pluginVersion
+    if (agpVersion < MIN_AGP_VERSION_REQUIRED || agpVersion > MAX_AGP_VERSION_REQUIRED) {
+        throw GradleException(
+            """
+            This version of the Baseline Profile Gradle Plugin only works with Android Gradle plugin
+            between versions $MIN_AGP_VERSION_REQUIRED and $MAX_AGP_VERSION_REQUIRED. Current version
+            is $agpVersion."
+            """.trimIndent()
+        )
+    }
+}
\ No newline at end of file
diff --git a/benchmark/baseline-profiles-gradle-plugin/src/main/kotlin/androidx/baselineprofiles/gradle/utils/BuildTypes.kt b/benchmark/baseline-profiles-gradle-plugin/src/main/kotlin/androidx/baselineprofiles/gradle/utils/BuildTypes.kt
index 81dfc24..2146549 100644
--- a/benchmark/baseline-profiles-gradle-plugin/src/main/kotlin/androidx/baselineprofiles/gradle/utils/BuildTypes.kt
+++ b/benchmark/baseline-profiles-gradle-plugin/src/main/kotlin/androidx/baselineprofiles/gradle/utils/BuildTypes.kt
@@ -27,16 +27,14 @@
     crossinline configureBlock: T.() -> (Unit),
     extendedBuildTypeToOriginalBuildTypeMapping: MutableMap<String, String>
 ) {
-    extension.buildTypes
-        .filter { buildType ->
+    extension.buildTypes.filter { buildType ->
             if (buildType !is T) {
                 throw GradleException(
                     "Build type `${buildType.name}` is not of type ${T::class}"
                 )
             }
             filterBlock(buildType)
-        }
-        .forEach { buildType ->
+        }.forEach { buildType ->
 
             val newBuildTypeName = camelCase(BUILD_TYPE_BASELINE_PROFILE_PREFIX, buildType.name)
 
@@ -63,7 +61,7 @@
     project: Project,
     extension: CommonExtension<*, T, *, *>,
     buildTypeName: String,
-    configureBlock: BuildType.() -> Unit
+    crossinline configureBlock: T.() -> (Unit),
 ) {
     // Check in case the build type was created manually (to allow full customization)
     if (extension.buildTypes.findByName(buildTypeName) != null) {
diff --git a/benchmark/baseline-profiles-gradle-plugin/src/main/kotlin/androidx/baselineprofiles/gradle/utils/Constants.kt b/benchmark/baseline-profiles-gradle-plugin/src/main/kotlin/androidx/baselineprofiles/gradle/utils/Constants.kt
new file mode 100644
index 0000000..4aba6ee
--- /dev/null
+++ b/benchmark/baseline-profiles-gradle-plugin/src/main/kotlin/androidx/baselineprofiles/gradle/utils/Constants.kt
@@ -0,0 +1,41 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.baselineprofiles.gradle.utils
+
+import com.android.build.api.AndroidPluginVersion
+import org.gradle.api.attributes.Attribute
+
+// Minimum AGP version required
+internal val MIN_AGP_VERSION_REQUIRED = AndroidPluginVersion(8, 0, 0).beta(1)
+internal val MAX_AGP_VERSION_REQUIRED = AndroidPluginVersion(8, 2, 0)
+
+// Prefix for the build type baseline profiles
+internal const val BUILD_TYPE_BASELINE_PROFILE_PREFIX = "nonMinified"
+
+// Configuration consumed by this plugin that carries the baseline profile HRF file.
+internal const val CONFIGURATION_NAME_BASELINE_PROFILES = "baselineProfiles"
+
+// Custom category attribute to match the baseline profile configuration
+internal const val ATTRIBUTE_CATEGORY_BASELINE_PROFILE = "baselineProfiles"
+
+// Base folder for artifacts generated by the tasks
+internal const val INTERMEDIATES_BASE_FOLDER = "intermediates/baselineprofiles"
+
+internal val ATTRIBUTE_FLAVOR =
+    Attribute.of("androidx.baselineprofiles.gradle.attributes.Flavor", String::class.java)
+internal val ATTRIBUTE_BUILD_TYPE =
+    Attribute.of("androidx.baselineprofiles.gradle.attributes.BuildType", String::class.java)
diff --git a/benchmark/baseline-profiles-gradle-plugin/src/main/kotlin/androidx/baselineprofiles/gradle/utils/Tasks.kt b/benchmark/baseline-profiles-gradle-plugin/src/main/kotlin/androidx/baselineprofiles/gradle/utils/Tasks.kt
new file mode 100644
index 0000000..6be531b
--- /dev/null
+++ b/benchmark/baseline-profiles-gradle-plugin/src/main/kotlin/androidx/baselineprofiles/gradle/utils/Tasks.kt
@@ -0,0 +1,36 @@
+/*
+ * 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.baselineprofiles.gradle.utils
+
+import org.gradle.api.Task
+import org.gradle.api.UnknownTaskException
+import org.gradle.api.tasks.TaskContainer
+import org.gradle.api.tasks.TaskProvider
+
+internal inline fun <reified T : Task?> TaskContainer.maybeRegister(
+    vararg nameParts: String,
+    noinline configureBlock: ((T) -> (Unit))? = null
+): TaskProvider<T> {
+    val name = camelCase(*nameParts)
+    return try {
+        val task = named(name, T::class.java)
+        if (configureBlock != null) task.configure(configureBlock)
+        task
+    } catch (e: UnknownTaskException) {
+        register(name, T::class.java, configureBlock)
+    }
+}
\ No newline at end of file
diff --git a/benchmark/baseline-profiles-gradle-plugin/src/main/kotlin/androidx/baselineprofiles/gradle/utils/Utils.kt b/benchmark/baseline-profiles-gradle-plugin/src/main/kotlin/androidx/baselineprofiles/gradle/utils/Utils.kt
index 6af0e36..cb41d7c 100644
--- a/benchmark/baseline-profiles-gradle-plugin/src/main/kotlin/androidx/baselineprofiles/gradle/utils/Utils.kt
+++ b/benchmark/baseline-profiles-gradle-plugin/src/main/kotlin/androidx/baselineprofiles/gradle/utils/Utils.kt
@@ -16,31 +16,15 @@
 
 package androidx.baselineprofiles.gradle.utils
 
-import org.gradle.api.attributes.Attribute
 import org.gradle.configurationcache.extensions.capitalized
 
-internal fun camelCase(vararg strings: String): String {
+fun camelCase(vararg strings: String): String {
     if (strings.isEmpty()) return ""
-    return StringBuilder()
-        .apply {
-            var shouldCapitalize = false
-            for (str in strings.filter { it.isNotBlank() }) {
-                append(if (shouldCapitalize) str.capitalized() else str)
-                shouldCapitalize = true
-            }
-        }.toString()
+    return StringBuilder().apply {
+        var shouldCapitalize = false
+        for (str in strings.filter { it.isNotBlank() }) {
+            append(if (shouldCapitalize) str.capitalized() else str)
+            shouldCapitalize = true
+        }
+    }.toString()
 }
-
-// Prefix for the build type baseline profiles
-internal const val BUILD_TYPE_BASELINE_PROFILE_PREFIX = "nonObfuscated"
-
-// Configuration consumed by this plugin that carries the baseline profile HRF file.
-internal const val CONFIGURATION_NAME_BASELINE_PROFILES = "baselineprofiles"
-
-// Custom category attribute to match the baseline profile configuration
-internal const val ATTRIBUTE_CATEGORY_BASELINE_PROFILE = "baselineprofile"
-
-internal val ATTRIBUTE_FLAVOR =
-    Attribute.of("androidx.baselineprofiles.gradle.attributes.Flavor", String::class.java)
-internal val ATTRIBUTE_BUILD_TYPE =
-    Attribute.of("androidx.baselineprofiles.gradle.attributes.BuildType", String::class.java)
\ No newline at end of file
diff --git a/benchmark/baseline-profiles-gradle-plugin/src/test/kotlin/androidx/baselineprofiles/gradle/buildprovider/BaselineProfilesBuildProviderPluginTest.kt b/benchmark/baseline-profiles-gradle-plugin/src/test/kotlin/androidx/baselineprofiles/gradle/buildprovider/BaselineProfilesBuildProviderPluginTest.kt
index 4c75385..df3ffc6 100644
--- a/benchmark/baseline-profiles-gradle-plugin/src/test/kotlin/androidx/baselineprofiles/gradle/buildprovider/BaselineProfilesBuildProviderPluginTest.kt
+++ b/benchmark/baseline-profiles-gradle-plugin/src/test/kotlin/androidx/baselineprofiles/gradle/buildprovider/BaselineProfilesBuildProviderPluginTest.kt
@@ -51,20 +51,21 @@
                 android {
                     namespace 'com.example.namespace'
                 }
-                tasks.register("printNonObfuscatedReleaseBuildType") {
-                    println(android.buildTypes.nonObfuscatedRelease)
+                tasks.register("printBuildType") {
+                    println(android.buildTypes.nonMinifiedRelease)
                 }
             """.trimIndent(),
             suffix = ""
         )
 
-        val buildTypeProperties = gradleRunner
-            .withArguments("printNonObfuscatedReleaseBuildType", "--stacktrace")
+        gradleRunner
+            .withArguments("printBuildType", "--stacktrace")
             .build()
             .output
-
-        assertThat(buildTypeProperties).contains("minifyEnabled=false")
-        assertThat(buildTypeProperties).contains("testCoverageEnabled=false")
-        assertThat(buildTypeProperties).contains("debuggable=false")
+            .also {
+                assertThat(it).contains("minifyEnabled=false")
+                assertThat(it).contains("testCoverageEnabled=false")
+                assertThat(it).contains("debuggable=false")
+            }
     }
 }
diff --git a/benchmark/baseline-profiles-gradle-plugin/src/test/kotlin/androidx/baselineprofiles/gradle/consumer/BaselineProfilesConsumerPluginTest.kt b/benchmark/baseline-profiles-gradle-plugin/src/test/kotlin/androidx/baselineprofiles/gradle/consumer/BaselineProfilesConsumerPluginTest.kt
index 6490dbb..2191b71 100644
--- a/benchmark/baseline-profiles-gradle-plugin/src/test/kotlin/androidx/baselineprofiles/gradle/consumer/BaselineProfilesConsumerPluginTest.kt
+++ b/benchmark/baseline-profiles-gradle-plugin/src/test/kotlin/androidx/baselineprofiles/gradle/consumer/BaselineProfilesConsumerPluginTest.kt
@@ -64,6 +64,31 @@
             .withPluginClasspath()
     }
 
+    private fun writeDefaultProducerProject() {
+        producerProjectSetup.writeDefaultBuildGradle(
+            prefix = MockProducerBuildGrade()
+                .withConfiguration(flavor = "", buildType = "release")
+                .withProducedBaselineProfiles(
+                    lines = listOf(
+                        Fixtures.CLASS_1_METHOD_1,
+                        Fixtures.CLASS_2_METHOD_2,
+                        Fixtures.CLASS_2,
+                        Fixtures.CLASS_1,
+                    ),
+                    flavor = "",
+                    buildType = "release"
+                )
+                .build(),
+            suffix = ""
+        )
+    }
+
+    private fun readBaselineProfileFileContent(variantName: String) =
+        File(
+            consumerProjectSetup.rootDir,
+            "src/$variantName/generated/baselineProfiles/baseline-prof.txt"
+        ).readLines()
+
     @Test
     fun testGenerateBaselineProfilesTaskWithNoFlavors() {
         consumerProjectSetup.writeDefaultBuildGradle(
@@ -76,7 +101,7 @@
                     namespace 'com.example.namespace'
                 }
                 dependencies {
-                    baselineprofiles(project(":$producerModuleName"))
+                    baselineProfiles(project(":$producerModuleName"))
                 }
             """.trimIndent(),
             suffix = ""
@@ -84,8 +109,22 @@
         producerProjectSetup.writeDefaultBuildGradle(
             prefix = MockProducerBuildGrade()
                 .withConfiguration(flavor = "", buildType = "release")
-                .withProducedBaselineProfiles(listOf("3", "2"), flavor = "", buildType = "release")
-                .withProducedBaselineProfiles(listOf("4", "1"), flavor = "", buildType = "release")
+                .withProducedBaselineProfiles(
+                    lines = listOf(
+                        Fixtures.CLASS_1_METHOD_1,
+                        Fixtures.CLASS_1,
+                    ),
+                    flavor = "",
+                    buildType = "release"
+                )
+                .withProducedBaselineProfiles(
+                    lines = listOf(
+                        Fixtures.CLASS_2_METHOD_1,
+                        Fixtures.CLASS_2,
+                    ),
+                    flavor = "",
+                    buildType = "release"
+                )
                 .build(),
             suffix = ""
         )
@@ -94,15 +133,17 @@
             .withArguments("generateBaselineProfiles", "--stacktrace")
             .build()
 
-        // The expected output should have each line sorted descending
-        assertThat(
-            File(consumerProjectSetup.rootDir, "src/main/baseline-prof.txt").readLines()
-        )
-            .containsExactly("4", "3", "2", "1")
+        assertThat(readBaselineProfileFileContent("main"))
+            .containsExactly(
+                Fixtures.CLASS_1,
+                Fixtures.CLASS_1_METHOD_1,
+                Fixtures.CLASS_2,
+                Fixtures.CLASS_2_METHOD_1,
+            )
     }
 
     @Test
-    fun testGenerateBaselineProfilesTaskWithFlavors() {
+    fun testGenerateBaselineProfilesTaskWithFlavorsAndDefaultMerge() {
         consumerProjectSetup.writeDefaultBuildGradle(
             prefix = """
                 plugins {
@@ -122,7 +163,7 @@
                     }
                 }
                 dependencies {
-                    baselineprofiles(project(":$producerModuleName"))
+                    baselineProfiles(project(":$producerModuleName"))
                 }
             """.trimIndent(),
             suffix = ""
@@ -132,12 +173,307 @@
                 .withConfiguration(flavor = "free", buildType = "release")
                 .withConfiguration(flavor = "paid", buildType = "release")
                 .withProducedBaselineProfiles(
-                    listOf("3", "2"),
+                    lines = listOf(
+                        Fixtures.CLASS_1_METHOD_1,
+                        Fixtures.CLASS_1,
+                    ),
                     flavor = "free",
                     buildType = "release"
                 )
                 .withProducedBaselineProfiles(
-                    listOf("4", "1"),
+                    lines = listOf(
+                        Fixtures.CLASS_2_METHOD_1,
+                        Fixtures.CLASS_2,
+                    ),
+                    flavor = "paid",
+                    buildType = "release"
+                )
+                .build(),
+            suffix = ""
+        )
+
+        // Asserts that all per-variant, per-flavor and per-build type tasks are being generated.
+        gradleRunner
+            .withArguments("tasks", "--stacktrace")
+            .build()
+            .output
+            .also {
+                assertThat(it).contains("generateReleaseBaselineProfiles - ")
+                assertThat(it).contains("generateFreeReleaseBaselineProfiles - ")
+                assertThat(it).contains("generatePaidReleaseBaselineProfiles - ")
+            }
+
+        gradleRunner
+            .withArguments("generateReleaseBaselineProfiles", "--stacktrace")
+            .build()
+
+        assertThat(readBaselineProfileFileContent("freeRelease"))
+            .containsExactly(
+                Fixtures.CLASS_1,
+                Fixtures.CLASS_1_METHOD_1,
+            )
+
+        assertThat(readBaselineProfileFileContent("paidRelease"))
+            .containsExactly(
+                Fixtures.CLASS_2,
+                Fixtures.CLASS_2_METHOD_1,
+            )
+    }
+
+    @Test
+    fun testGenerateBaselineProfilesTaskWithFlavorsAndMergeAll() {
+        consumerProjectSetup.writeDefaultBuildGradle(
+            prefix = """
+                plugins {
+                    id("com.android.application")
+                    id("androidx.baselineprofiles.consumer")
+                }
+                android {
+                    namespace 'com.example.namespace'
+                    productFlavors {
+                        flavorDimensions = ["version"]
+                        free {
+                            dimension "version"
+                        }
+                        paid {
+                            dimension "version"
+                        }
+                    }
+                }
+                dependencies {
+                    baselineProfiles(project(":$producerModuleName"))
+                }
+                baselineProfilesProfileConsumer {
+                    mergeIntoMain = true
+                }
+            """.trimIndent(),
+            suffix = ""
+        )
+        producerProjectSetup.writeDefaultBuildGradle(
+            prefix = MockProducerBuildGrade()
+                .withConfiguration(flavor = "free", buildType = "release")
+                .withConfiguration(flavor = "paid", buildType = "release")
+                .withProducedBaselineProfiles(
+                    lines = listOf(
+                        Fixtures.CLASS_1_METHOD_1,
+                        Fixtures.CLASS_1,
+                    ),
+                    flavor = "free",
+                    buildType = "release"
+                )
+                .withProducedBaselineProfiles(
+                    lines = listOf(
+                        Fixtures.CLASS_2_METHOD_1,
+                        Fixtures.CLASS_2,
+                    ),
+                    flavor = "paid",
+                    buildType = "release"
+                )
+                .build(),
+            suffix = ""
+        )
+
+        // Asserts that all per-variant, per-flavor and per-build type tasks are being generated.
+        gradleRunner
+            .withArguments("tasks", "--stacktrace")
+            .build()
+            .output
+            .also {
+                assertThat(it).contains("generateBaselineProfiles - ")
+                assertThat(it).doesNotContain("generateReleaseBaselineProfiles - ")
+                assertThat(it).doesNotContain("generateFreeReleaseBaselineProfiles - ")
+                assertThat(it).doesNotContain("generatePaidReleaseBaselineProfiles - ")
+            }
+
+        gradleRunner
+            .withArguments("generateBaselineProfiles", "--stacktrace")
+            .build()
+
+        assertThat(readBaselineProfileFileContent("main"))
+            .containsExactly(
+                Fixtures.CLASS_1,
+                Fixtures.CLASS_1_METHOD_1,
+                Fixtures.CLASS_2,
+                Fixtures.CLASS_2_METHOD_1,
+            )
+    }
+
+    @Test
+    fun testPluginAppliedToApplicationModule() {
+
+        // For this test the producer is not important
+        writeDefaultProducerProject()
+
+        consumerProjectSetup.writeDefaultBuildGradle(
+            prefix = """
+                plugins {
+                    id("com.android.application")
+                    id("androidx.baselineprofiles.consumer")
+                }
+                android {
+                    namespace 'com.example.namespace'
+                }
+                dependencies {
+                    baselineProfiles(project(":$producerModuleName"))
+                }
+            """.trimIndent(),
+            suffix = ""
+        )
+
+        gradleRunner
+            .withArguments("generateReleaseBaselineProfiles", "--stacktrace")
+            .build()
+
+        // This should not fail.
+    }
+
+    @Test
+    fun testPluginAppliedToLibraryModule() {
+        // For this test the producer is not important
+        writeDefaultProducerProject()
+
+        consumerProjectSetup.writeDefaultBuildGradle(
+            prefix = """
+                plugins {
+                    id("com.android.library")
+                    id("androidx.baselineprofiles.consumer")
+                }
+                android {
+                    namespace 'com.example.namespace'
+                }
+                dependencies {
+                    baselineProfiles(project(":$producerModuleName"))
+                }
+            """.trimIndent(),
+            suffix = ""
+        )
+
+        gradleRunner
+            .withArguments("generateBaselineProfiles", "--stacktrace")
+            .build()
+
+        // This should not fail.
+    }
+
+    @Test
+    fun testPluginAppliedToNonApplicationAndNonLibraryModule() {
+        // For this test the producer is not important
+        writeDefaultProducerProject()
+
+        consumerProjectSetup.writeDefaultBuildGradle(
+            prefix = """
+                plugins {
+                    id("com.android.test")
+                    id("androidx.baselineprofiles.consumer")
+                }
+                android {
+                    namespace 'com.example.namespace'
+                }
+                dependencies {
+                    baselineProfiles(project(":$producerModuleName"))
+                }
+            """.trimIndent(),
+            suffix = ""
+        )
+
+        gradleRunner
+            .withArguments("generateReleaseBaselineProfiles", "--stacktrace")
+            .buildAndFail()
+    }
+
+    @Test
+    fun testBaselineProfilesSrcSetAreAddedToVariants() {
+        consumerProjectSetup.writeDefaultBuildGradle(
+            prefix = """
+                plugins {
+                    id("com.android.application")
+                    id("androidx.baselineprofiles.consumer")
+                }
+                android {
+                    namespace 'com.example.namespace'
+                    productFlavors {
+                        flavorDimensions = ["version"]
+                        free { dimension "version" }
+                        paid { dimension "version" }
+                    }
+                }
+                android.applicationVariants.all { variant ->
+                    tasks.register(variant.name + "Print") { t ->
+                        println(android.sourceSets[variant.name].baselineProfiles)
+                    }
+                }
+            """.trimIndent(),
+            suffix = ""
+        )
+        arrayOf("freeRelease", "paidRelease").forEach {
+            assertThat(
+                gradleRunner
+                    .withArguments("${it}Print", "--stacktrace")
+                    .build()
+                    .output
+                    .contains("source=[src/$it/baselineProfiles]")
+            )
+                .isTrue()
+        }
+    }
+
+    @Test
+    fun testBaselineProfilesFilterAndSortAndMerge() {
+        consumerProjectSetup.writeDefaultBuildGradle(
+            prefix = """
+                plugins {
+                    id("com.android.library")
+                    id("androidx.baselineprofiles.consumer")
+                }
+                android {
+                    namespace 'com.example.namespace'
+                    productFlavors {
+                        flavorDimensions = ["version"]
+                        free {
+                            dimension "version"
+                        }
+                        paid {
+                            dimension "version"
+                        }
+                    }
+                }
+                dependencies {
+                    baselineProfiles(project(":$producerModuleName"))
+                }
+                baselineProfilesProfileConsumer {
+                    filter { include("com.sample.Utils") }
+                }
+            """.trimIndent(),
+            suffix = ""
+        )
+        producerProjectSetup.writeDefaultBuildGradle(
+            prefix = MockProducerBuildGrade()
+                .withConfiguration(
+                    flavor = "free",
+                    buildType = "release"
+                )
+                .withConfiguration(
+                    flavor = "paid",
+                    buildType = "release"
+                )
+                .withProducedBaselineProfiles(
+                    lines = listOf(
+                        Fixtures.CLASS_1_METHOD_1,
+                        Fixtures.CLASS_1_METHOD_2,
+                        Fixtures.CLASS_1,
+                    ),
+                    flavor = "free",
+                    buildType = "release"
+                )
+                .withProducedBaselineProfiles(
+                    lines = listOf(
+                        Fixtures.CLASS_2_METHOD_1,
+                        Fixtures.CLASS_2_METHOD_2,
+                        Fixtures.CLASS_2_METHOD_3,
+                        Fixtures.CLASS_2_METHOD_4,
+                        Fixtures.CLASS_2_METHOD_5,
+                        Fixtures.CLASS_2,
+                    ),
                     flavor = "paid",
                     buildType = "release"
                 )
@@ -149,14 +485,21 @@
             .withArguments("generateBaselineProfiles", "--stacktrace")
             .build()
 
-        // The expected output should have each line sorted ascending
-        val baselineProf =
-            File(consumerProjectSetup.rootDir, "src/main/baseline-prof.txt").readLines()
-        assertThat(baselineProf).containsExactly("1", "2", "3", "4")
+        // In the final output there should be :
+        //  - one single file in src/main/generatedBaselineProfiles because merge = `all`.
+        //  - There should be only the Utils class [CLASS_2] because of the include filter.
+        //  - The method `someOtherMethod` [CLASS_2_METHOD_3] should be included only once.
+        assertThat(readBaselineProfileFileContent("main"))
+            .containsExactly(
+                Fixtures.CLASS_2,
+                Fixtures.CLASS_2_METHOD_1,
+                Fixtures.CLASS_2_METHOD_2,
+                Fixtures.CLASS_2_METHOD_3,
+            )
     }
 }
 
-private class MockProducerBuildGrade() {
+private class MockProducerBuildGrade {
 
     private var profileIndex = 0
     private var content = """
@@ -181,7 +524,7 @@
                 canBeConsumed = true
                 canBeResolved = false
                 attributes {
-                    attribute(Category.CATEGORY_ATTRIBUTE, objects.named(Category, "baselineprofile"))
+                    attribute(Category.CATEGORY_ATTRIBUTE, objects.named(Category, "baselineProfiles"))
                     attribute(Attribute.of("androidx.baselineprofiles.gradle.attributes.BuildType", String), "$buildType")
                     attribute(Attribute.of("androidx.baselineprofiles.gradle.attributes.Flavor", String), "$flavor")
                 }
@@ -218,3 +561,15 @@
 
 private fun configurationName(flavor: String, buildType: String): String =
     camelCase(flavor, buildType, CONFIGURATION_NAME_BASELINE_PROFILES)
+
+object Fixtures {
+    const val CLASS_1 = "Lcom/sample/Activity;"
+    const val CLASS_1_METHOD_1 = "HSPLcom/sample/Activity;-><init>()V"
+    const val CLASS_1_METHOD_2 = "HSPLcom/sample/Activity;->onCreate(Landroid/os/Bundle;)V"
+    const val CLASS_2 = "Lcom/sample/Utils;"
+    const val CLASS_2_METHOD_1 = "HSLcom/sample/Utils;-><init>()V"
+    const val CLASS_2_METHOD_2 = "HLcom/sample/Utils;->someMethod()V"
+    const val CLASS_2_METHOD_3 = "HLcom/sample/Utils;->someOtherMethod()V"
+    const val CLASS_2_METHOD_4 = "HSLcom/sample/Utils;->someOtherMethod()V"
+    const val CLASS_2_METHOD_5 = "HSPLcom/sample/Utils;->someOtherMethod()V"
+}
\ No newline at end of file
diff --git a/benchmark/baseline-profiles-gradle-plugin/src/test/kotlin/androidx/baselineprofiles/gradle/producer/BaselineProfilesProducerPluginTest.kt b/benchmark/baseline-profiles-gradle-plugin/src/test/kotlin/androidx/baselineprofiles/gradle/producer/BaselineProfilesProducerPluginTest.kt
index 2de52a4..0e8a36f 100644
--- a/benchmark/baseline-profiles-gradle-plugin/src/test/kotlin/androidx/baselineprofiles/gradle/producer/BaselineProfilesProducerPluginTest.kt
+++ b/benchmark/baseline-profiles-gradle-plugin/src/test/kotlin/androidx/baselineprofiles/gradle/producer/BaselineProfilesProducerPluginTest.kt
@@ -84,12 +84,12 @@
                     targetProjectPath = ":$buildProviderModuleName"
                     namespace 'com.example.namespace.test'
                 }
-                tasks.register("mergeNonObfuscatedReleaseTestResultProtos") { println("Stub") }
+                tasks.register("mergeNonMinifiedReleaseTestResultProtos") { println("Stub") }
             """.trimIndent(),
             suffix = ""
         )
 
         val output = gradleRunner.withArguments("tasks", "--stacktrace").build().output
-        assertTrue { output.contains("collectNonObfuscatedReleaseBaselineProfiles - ") }
+        assertTrue { output.contains("collectNonMinifiedReleaseBaselineProfiles - ") }
     }
 }
diff --git a/benchmark/integration-tests/baselineprofiles-consumer/build.gradle b/benchmark/integration-tests/baselineprofiles-consumer/build.gradle
index d3e1e45..81fb569 100644
--- a/benchmark/integration-tests/baselineprofiles-consumer/build.gradle
+++ b/benchmark/integration-tests/baselineprofiles-consumer/build.gradle
@@ -36,7 +36,14 @@
 dependencies {
     implementation(libs.kotlinStdlib)
     implementation(libs.constraintLayout)
-    baselineprofiles(project(":benchmark:integration-tests:baselineprofiles-producer"))
+    baselineProfiles(project(":benchmark:integration-tests:baselineprofiles-producer"))
+}
+
+baselineProfilesProfileConsumer {
+    onDemandGeneration = false
+    filter {
+        include "androidx.benchmark.integration.baselineprofiles.consumer.**"
+    }
 }
 
 apply(from: "../baselineprofiles-test-utils/utils.gradle")
diff --git a/benchmark/integration-tests/baselineprofiles-consumer/src/main/expected-baseline-prof.txt b/benchmark/integration-tests/baselineprofiles-consumer/src/main/expected-baseline-prof.txt
deleted file mode 100644
index a23b650..0000000
--- a/benchmark/integration-tests/baselineprofiles-consumer/src/main/expected-baseline-prof.txt
+++ /dev/null
@@ -1,301 +0,0 @@
-HSPLandroidx/benchmark/integration/baselineprofiles/consumer/EmptyActivity;-><init>()V
-HSPLandroidx/benchmark/integration/baselineprofiles/consumer/EmptyActivity;->onCreate(Landroid/os/Bundle;)V
-HSPLandroidx/constraintlayout/solver/ArrayLinkedVariables;-><clinit>()V
-HSPLandroidx/constraintlayout/solver/ArrayLinkedVariables;-><init>(Landroidx/constraintlayout/solver/ArrayRow;Landroidx/constraintlayout/solver/Cache;)V
-HSPLandroidx/constraintlayout/solver/ArrayRow;-><init>()V
-HSPLandroidx/constraintlayout/solver/ArrayRow;-><init>(Landroidx/constraintlayout/solver/Cache;)V
-HSPLandroidx/constraintlayout/solver/ArrayRow;->addError(Landroidx/constraintlayout/solver/LinearSystem;I)Landroidx/constraintlayout/solver/ArrayRow;
-HSPLandroidx/constraintlayout/solver/ArrayRow;->addSingleError(Landroidx/constraintlayout/solver/SolverVariable;I)Landroidx/constraintlayout/solver/ArrayRow;
-HSPLandroidx/constraintlayout/solver/ArrayRow;->chooseSubject(Landroidx/constraintlayout/solver/LinearSystem;)Z
-HSPLandroidx/constraintlayout/solver/ArrayRow;->chooseSubjectInVariables(Landroidx/constraintlayout/solver/LinearSystem;)Landroidx/constraintlayout/solver/SolverVariable;
-HSPLandroidx/constraintlayout/solver/ArrayRow;->createRowCentering(Landroidx/constraintlayout/solver/SolverVariable;Landroidx/constraintlayout/solver/SolverVariable;IFLandroidx/constraintlayout/solver/SolverVariable;Landroidx/constraintlayout/solver/SolverVariable;I)Landroidx/constraintlayout/solver/ArrayRow;
-HSPLandroidx/constraintlayout/solver/ArrayRow;->createRowEquals(Landroidx/constraintlayout/solver/SolverVariable;Landroidx/constraintlayout/solver/SolverVariable;I)Landroidx/constraintlayout/solver/ArrayRow;
-HSPLandroidx/constraintlayout/solver/ArrayRow;->createRowGreaterThan(Landroidx/constraintlayout/solver/SolverVariable;Landroidx/constraintlayout/solver/SolverVariable;Landroidx/constraintlayout/solver/SolverVariable;I)Landroidx/constraintlayout/solver/ArrayRow;
-HSPLandroidx/constraintlayout/solver/ArrayRow;->createRowLowerThan(Landroidx/constraintlayout/solver/SolverVariable;Landroidx/constraintlayout/solver/SolverVariable;Landroidx/constraintlayout/solver/SolverVariable;I)Landroidx/constraintlayout/solver/ArrayRow;
-HSPLandroidx/constraintlayout/solver/ArrayRow;->ensurePositiveConstant()V
-HSPLandroidx/constraintlayout/solver/ArrayRow;->getKey()Landroidx/constraintlayout/solver/SolverVariable;
-HSPLandroidx/constraintlayout/solver/ArrayRow;->hasKeyVariable()Z
-HSPLandroidx/constraintlayout/solver/ArrayRow;->isEmpty()Z
-HSPLandroidx/constraintlayout/solver/ArrayRow;->isNew(Landroidx/constraintlayout/solver/SolverVariable;Landroidx/constraintlayout/solver/LinearSystem;)Z
-HSPLandroidx/constraintlayout/solver/ArrayRow;->pivot(Landroidx/constraintlayout/solver/SolverVariable;)V
-HSPLandroidx/constraintlayout/solver/ArrayRow;->reset()V
-HSPLandroidx/constraintlayout/solver/ArrayRow;->updateFromFinalVariable(Landroidx/constraintlayout/solver/LinearSystem;Landroidx/constraintlayout/solver/SolverVariable;Z)V
-HSPLandroidx/constraintlayout/solver/ArrayRow;->updateFromRow(Landroidx/constraintlayout/solver/ArrayRow;Z)V
-HSPLandroidx/constraintlayout/solver/ArrayRow;->updateFromSystem(Landroidx/constraintlayout/solver/LinearSystem;)V
-HSPLandroidx/constraintlayout/solver/Cache;-><init>()V
-HSPLandroidx/constraintlayout/solver/LinearSystem$ValuesRow;-><init>(Landroidx/constraintlayout/solver/LinearSystem;Landroidx/constraintlayout/solver/Cache;)V
-HSPLandroidx/constraintlayout/solver/LinearSystem;-><clinit>()V
-HSPLandroidx/constraintlayout/solver/LinearSystem;-><init>()V
-HSPLandroidx/constraintlayout/solver/LinearSystem;->acquireSolverVariable(Landroidx/constraintlayout/solver/SolverVariable$Type;Ljava/lang/String;)Landroidx/constraintlayout/solver/SolverVariable;
-HSPLandroidx/constraintlayout/solver/LinearSystem;->addCentering(Landroidx/constraintlayout/solver/SolverVariable;Landroidx/constraintlayout/solver/SolverVariable;IFLandroidx/constraintlayout/solver/SolverVariable;Landroidx/constraintlayout/solver/SolverVariable;II)V
-HSPLandroidx/constraintlayout/solver/LinearSystem;->addConstraint(Landroidx/constraintlayout/solver/ArrayRow;)V
-HSPLandroidx/constraintlayout/solver/LinearSystem;->addEquality(Landroidx/constraintlayout/solver/SolverVariable;I)V
-HSPLandroidx/constraintlayout/solver/LinearSystem;->addEquality(Landroidx/constraintlayout/solver/SolverVariable;Landroidx/constraintlayout/solver/SolverVariable;II)Landroidx/constraintlayout/solver/ArrayRow;
-HSPLandroidx/constraintlayout/solver/LinearSystem;->addGreaterThan(Landroidx/constraintlayout/solver/SolverVariable;Landroidx/constraintlayout/solver/SolverVariable;II)V
-HSPLandroidx/constraintlayout/solver/LinearSystem;->addLowerThan(Landroidx/constraintlayout/solver/SolverVariable;Landroidx/constraintlayout/solver/SolverVariable;II)V
-HSPLandroidx/constraintlayout/solver/LinearSystem;->addRow(Landroidx/constraintlayout/solver/ArrayRow;)V
-HSPLandroidx/constraintlayout/solver/LinearSystem;->addSingleError(Landroidx/constraintlayout/solver/ArrayRow;II)V
-HSPLandroidx/constraintlayout/solver/LinearSystem;->computeValues()V
-HSPLandroidx/constraintlayout/solver/LinearSystem;->createErrorVariable(ILjava/lang/String;)Landroidx/constraintlayout/solver/SolverVariable;
-HSPLandroidx/constraintlayout/solver/LinearSystem;->createObjectVariable(Ljava/lang/Object;)Landroidx/constraintlayout/solver/SolverVariable;
-HSPLandroidx/constraintlayout/solver/LinearSystem;->createRow()Landroidx/constraintlayout/solver/ArrayRow;
-HSPLandroidx/constraintlayout/solver/LinearSystem;->createSlackVariable()Landroidx/constraintlayout/solver/SolverVariable;
-HSPLandroidx/constraintlayout/solver/LinearSystem;->enforceBFS(Landroidx/constraintlayout/solver/LinearSystem$Row;)I
-HSPLandroidx/constraintlayout/solver/LinearSystem;->getCache()Landroidx/constraintlayout/solver/Cache;
-HSPLandroidx/constraintlayout/solver/LinearSystem;->getMetrics()Landroidx/constraintlayout/solver/Metrics;
-HSPLandroidx/constraintlayout/solver/LinearSystem;->getObjectVariableValue(Ljava/lang/Object;)I
-HSPLandroidx/constraintlayout/solver/LinearSystem;->increaseTableSize()V
-HSPLandroidx/constraintlayout/solver/LinearSystem;->minimize()V
-HSPLandroidx/constraintlayout/solver/LinearSystem;->minimizeGoal(Landroidx/constraintlayout/solver/LinearSystem$Row;)V
-HSPLandroidx/constraintlayout/solver/LinearSystem;->optimize(Landroidx/constraintlayout/solver/LinearSystem$Row;Z)I
-HSPLandroidx/constraintlayout/solver/LinearSystem;->releaseRows()V
-HSPLandroidx/constraintlayout/solver/LinearSystem;->reset()V
-HSPLandroidx/constraintlayout/solver/Pools$SimplePool;-><init>(I)V
-HSPLandroidx/constraintlayout/solver/Pools$SimplePool;->acquire()Ljava/lang/Object;
-HSPLandroidx/constraintlayout/solver/Pools$SimplePool;->release(Ljava/lang/Object;)Z
-HSPLandroidx/constraintlayout/solver/Pools$SimplePool;->releaseAll([Ljava/lang/Object;I)V
-HSPLandroidx/constraintlayout/solver/PriorityGoalRow$GoalVariableAccessor;-><init>(Landroidx/constraintlayout/solver/PriorityGoalRow;Landroidx/constraintlayout/solver/PriorityGoalRow;)V
-HSPLandroidx/constraintlayout/solver/PriorityGoalRow$GoalVariableAccessor;->addToGoal(Landroidx/constraintlayout/solver/SolverVariable;F)Z
-HSPLandroidx/constraintlayout/solver/PriorityGoalRow$GoalVariableAccessor;->init(Landroidx/constraintlayout/solver/SolverVariable;)V
-HSPLandroidx/constraintlayout/solver/PriorityGoalRow$GoalVariableAccessor;->isNegative()Z
-HSPLandroidx/constraintlayout/solver/PriorityGoalRow$GoalVariableAccessor;->reset()V
-HSPLandroidx/constraintlayout/solver/PriorityGoalRow;-><init>(Landroidx/constraintlayout/solver/Cache;)V
-HSPLandroidx/constraintlayout/solver/PriorityGoalRow;->addError(Landroidx/constraintlayout/solver/SolverVariable;)V
-HSPLandroidx/constraintlayout/solver/PriorityGoalRow;->addToGoal(Landroidx/constraintlayout/solver/SolverVariable;)V
-HSPLandroidx/constraintlayout/solver/PriorityGoalRow;->clear()V
-HSPLandroidx/constraintlayout/solver/PriorityGoalRow;->getPivotCandidate(Landroidx/constraintlayout/solver/LinearSystem;[Z)Landroidx/constraintlayout/solver/SolverVariable;
-HSPLandroidx/constraintlayout/solver/PriorityGoalRow;->removeGoal(Landroidx/constraintlayout/solver/SolverVariable;)V
-HSPLandroidx/constraintlayout/solver/PriorityGoalRow;->updateFromRow(Landroidx/constraintlayout/solver/ArrayRow;Z)V
-HSPLandroidx/constraintlayout/solver/SolverVariable$Type;-><clinit>()V
-HSPLandroidx/constraintlayout/solver/SolverVariable$Type;-><init>(Ljava/lang/String;I)V
-HSPLandroidx/constraintlayout/solver/SolverVariable;-><clinit>()V
-HSPLandroidx/constraintlayout/solver/SolverVariable;-><init>(Landroidx/constraintlayout/solver/SolverVariable$Type;Ljava/lang/String;)V
-HSPLandroidx/constraintlayout/solver/SolverVariable;->addToRow(Landroidx/constraintlayout/solver/ArrayRow;)V
-HSPLandroidx/constraintlayout/solver/SolverVariable;->increaseErrorId()V
-HSPLandroidx/constraintlayout/solver/SolverVariable;->removeFromRow(Landroidx/constraintlayout/solver/ArrayRow;)V
-HSPLandroidx/constraintlayout/solver/SolverVariable;->reset()V
-HSPLandroidx/constraintlayout/solver/SolverVariable;->setFinalValue(Landroidx/constraintlayout/solver/LinearSystem;F)V
-HSPLandroidx/constraintlayout/solver/SolverVariable;->setType(Landroidx/constraintlayout/solver/SolverVariable$Type;Ljava/lang/String;)V
-HSPLandroidx/constraintlayout/solver/SolverVariable;->updateReferencesWithNewDefinition(Landroidx/constraintlayout/solver/ArrayRow;)V
-HSPLandroidx/constraintlayout/solver/SolverVariableValues;-><clinit>()V
-HSPLandroidx/constraintlayout/solver/SolverVariableValues;-><init>(Landroidx/constraintlayout/solver/ArrayRow;Landroidx/constraintlayout/solver/Cache;)V
-HSPLandroidx/constraintlayout/solver/SolverVariableValues;->add(Landroidx/constraintlayout/solver/SolverVariable;FZ)V
-HSPLandroidx/constraintlayout/solver/SolverVariableValues;->addToHashMap(Landroidx/constraintlayout/solver/SolverVariable;I)V
-HSPLandroidx/constraintlayout/solver/SolverVariableValues;->addVariable(ILandroidx/constraintlayout/solver/SolverVariable;F)V
-HSPLandroidx/constraintlayout/solver/SolverVariableValues;->clear()V
-HSPLandroidx/constraintlayout/solver/SolverVariableValues;->divideByAmount(F)V
-HSPLandroidx/constraintlayout/solver/SolverVariableValues;->findEmptySlot()I
-HSPLandroidx/constraintlayout/solver/SolverVariableValues;->get(Landroidx/constraintlayout/solver/SolverVariable;)F
-HSPLandroidx/constraintlayout/solver/SolverVariableValues;->getCurrentSize()I
-HSPLandroidx/constraintlayout/solver/SolverVariableValues;->getVariable(I)Landroidx/constraintlayout/solver/SolverVariable;
-HSPLandroidx/constraintlayout/solver/SolverVariableValues;->getVariableValue(I)F
-HSPLandroidx/constraintlayout/solver/SolverVariableValues;->indexOf(Landroidx/constraintlayout/solver/SolverVariable;)I
-HSPLandroidx/constraintlayout/solver/SolverVariableValues;->insertVariable(ILandroidx/constraintlayout/solver/SolverVariable;F)V
-HSPLandroidx/constraintlayout/solver/SolverVariableValues;->invert()V
-HSPLandroidx/constraintlayout/solver/SolverVariableValues;->put(Landroidx/constraintlayout/solver/SolverVariable;F)V
-HSPLandroidx/constraintlayout/solver/SolverVariableValues;->remove(Landroidx/constraintlayout/solver/SolverVariable;Z)F
-HSPLandroidx/constraintlayout/solver/SolverVariableValues;->removeFromHashMap(Landroidx/constraintlayout/solver/SolverVariable;)V
-HSPLandroidx/constraintlayout/solver/SolverVariableValues;->use(Landroidx/constraintlayout/solver/ArrayRow;Z)F
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintAnchor$Type;-><clinit>()V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintAnchor$Type;-><init>(Ljava/lang/String;I)V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintAnchor$Type;->values()[Landroidx/constraintlayout/solver/widgets/ConstraintAnchor$Type;
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintAnchor;-><init>(Landroidx/constraintlayout/solver/widgets/ConstraintWidget;Landroidx/constraintlayout/solver/widgets/ConstraintAnchor$Type;)V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintAnchor;->connect(Landroidx/constraintlayout/solver/widgets/ConstraintAnchor;IIZ)Z
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintAnchor;->getMargin()I
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintAnchor;->getSolverVariable()Landroidx/constraintlayout/solver/SolverVariable;
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintAnchor;->getTarget()Landroidx/constraintlayout/solver/widgets/ConstraintAnchor;
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintAnchor;->isConnected()Z
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintAnchor;->reset()V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintAnchor;->resetSolverVariable(Landroidx/constraintlayout/solver/Cache;)V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget$1;-><clinit>()V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget$DimensionBehaviour;-><clinit>()V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget$DimensionBehaviour;-><init>(Ljava/lang/String;I)V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget$DimensionBehaviour;->values()[Landroidx/constraintlayout/solver/widgets/ConstraintWidget$DimensionBehaviour;
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;-><clinit>()V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;-><init>()V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->addAnchors()V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->addFirst()Z
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->addToSolver(Landroidx/constraintlayout/solver/LinearSystem;)V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->applyConstraints(Landroidx/constraintlayout/solver/LinearSystem;ZZZZLandroidx/constraintlayout/solver/SolverVariable;Landroidx/constraintlayout/solver/SolverVariable;Landroidx/constraintlayout/solver/widgets/ConstraintWidget$DimensionBehaviour;ZLandroidx/constraintlayout/solver/widgets/ConstraintAnchor;Landroidx/constraintlayout/solver/widgets/ConstraintAnchor;IIIIFZZZZIIIIFZ)V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->createObjectVariables(Landroidx/constraintlayout/solver/LinearSystem;)V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->getAnchor(Landroidx/constraintlayout/solver/widgets/ConstraintAnchor$Type;)Landroidx/constraintlayout/solver/widgets/ConstraintAnchor;
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->getBaselineDistance()I
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->getCompanionWidget()Ljava/lang/Object;
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->getDimensionBehaviour(I)Landroidx/constraintlayout/solver/widgets/ConstraintWidget$DimensionBehaviour;
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->getHeight()I
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->getHorizontalDimensionBehaviour()Landroidx/constraintlayout/solver/widgets/ConstraintWidget$DimensionBehaviour;
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->getMinHeight()I
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->getMinWidth()I
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->getParent()Landroidx/constraintlayout/solver/widgets/ConstraintWidget;
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->getVerticalDimensionBehaviour()Landroidx/constraintlayout/solver/widgets/ConstraintWidget$DimensionBehaviour;
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->getVisibility()I
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->getWidth()I
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->getX()I
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->getY()I
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->immediateConnect(Landroidx/constraintlayout/solver/widgets/ConstraintAnchor$Type;Landroidx/constraintlayout/solver/widgets/ConstraintWidget;Landroidx/constraintlayout/solver/widgets/ConstraintAnchor$Type;II)V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->isChainHead(I)Z
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->isInHorizontalChain()Z
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->isInVerticalChain()Z
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->reset()V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->resetSolverVariables(Landroidx/constraintlayout/solver/Cache;)V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->setBaselineDistance(I)V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->setCompanionWidget(Ljava/lang/Object;)V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->setDimensionRatio(Ljava/lang/String;)V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->setFrame(IIII)V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->setHasBaseline(Z)V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->setHeight(I)V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->setHorizontalBiasPercent(F)V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->setHorizontalChainStyle(I)V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->setHorizontalDimensionBehaviour(Landroidx/constraintlayout/solver/widgets/ConstraintWidget$DimensionBehaviour;)V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->setHorizontalMatchStyle(IIIF)V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->setHorizontalWeight(F)V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->setInBarrier(IZ)V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->setMaxHeight(I)V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->setMaxWidth(I)V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->setMinHeight(I)V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->setMinWidth(I)V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->setParent(Landroidx/constraintlayout/solver/widgets/ConstraintWidget;)V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->setVerticalBiasPercent(F)V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->setVerticalChainStyle(I)V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->setVerticalDimensionBehaviour(Landroidx/constraintlayout/solver/widgets/ConstraintWidget$DimensionBehaviour;)V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->setVerticalMatchStyle(IIIF)V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->setVerticalWeight(F)V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->setVisibility(I)V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->setWidth(I)V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->setX(I)V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->setY(I)V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->updateFromSolver(Landroidx/constraintlayout/solver/LinearSystem;)V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidgetContainer;-><init>()V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidgetContainer;->addChildrenToSolver(Landroidx/constraintlayout/solver/LinearSystem;)Z
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidgetContainer;->getMeasurer()Landroidx/constraintlayout/solver/widgets/analyzer/BasicMeasure$Measurer;
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidgetContainer;->getOptimizationLevel()I
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidgetContainer;->invalidateGraph()V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidgetContainer;->invalidateMeasures()V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidgetContainer;->isHeightMeasuredTooSmall()Z
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidgetContainer;->isWidthMeasuredTooSmall()Z
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidgetContainer;->layout()V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidgetContainer;->measure(IIIIIIIII)J
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidgetContainer;->optimizeFor(I)Z
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidgetContainer;->resetChains()V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidgetContainer;->setMeasurer(Landroidx/constraintlayout/solver/widgets/analyzer/BasicMeasure$Measurer;)V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidgetContainer;->setOptimizationLevel(I)V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidgetContainer;->setRtl(Z)V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidgetContainer;->updateChildrenFromSolver(Landroidx/constraintlayout/solver/LinearSystem;[Z)V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidgetContainer;->updateHierarchy()V
-HSPLandroidx/constraintlayout/solver/widgets/Optimizer;-><clinit>()V
-HSPLandroidx/constraintlayout/solver/widgets/Optimizer;->checkMatchParent(Landroidx/constraintlayout/solver/widgets/ConstraintWidgetContainer;Landroidx/constraintlayout/solver/LinearSystem;Landroidx/constraintlayout/solver/widgets/ConstraintWidget;)V
-HSPLandroidx/constraintlayout/solver/widgets/Optimizer;->enabled(II)Z
-HSPLandroidx/constraintlayout/solver/widgets/WidgetContainer;-><init>()V
-HSPLandroidx/constraintlayout/solver/widgets/WidgetContainer;->add(Landroidx/constraintlayout/solver/widgets/ConstraintWidget;)V
-HSPLandroidx/constraintlayout/solver/widgets/WidgetContainer;->removeAllChildren()V
-HSPLandroidx/constraintlayout/solver/widgets/WidgetContainer;->resetSolverVariables(Landroidx/constraintlayout/solver/Cache;)V
-HSPLandroidx/constraintlayout/solver/widgets/analyzer/BasicMeasure$Measure;-><init>()V
-HSPLandroidx/constraintlayout/solver/widgets/analyzer/BasicMeasure;-><init>(Landroidx/constraintlayout/solver/widgets/ConstraintWidgetContainer;)V
-HSPLandroidx/constraintlayout/solver/widgets/analyzer/BasicMeasure;->measure(Landroidx/constraintlayout/solver/widgets/analyzer/BasicMeasure$Measurer;Landroidx/constraintlayout/solver/widgets/ConstraintWidget;Z)Z
-HSPLandroidx/constraintlayout/solver/widgets/analyzer/BasicMeasure;->measureChildren(Landroidx/constraintlayout/solver/widgets/ConstraintWidgetContainer;)V
-HSPLandroidx/constraintlayout/solver/widgets/analyzer/BasicMeasure;->solveLinearSystem(Landroidx/constraintlayout/solver/widgets/ConstraintWidgetContainer;Ljava/lang/String;II)V
-HSPLandroidx/constraintlayout/solver/widgets/analyzer/BasicMeasure;->solverMeasure(Landroidx/constraintlayout/solver/widgets/ConstraintWidgetContainer;IIIIIIIII)J
-HSPLandroidx/constraintlayout/solver/widgets/analyzer/BasicMeasure;->updateHierarchy(Landroidx/constraintlayout/solver/widgets/ConstraintWidgetContainer;)V
-HSPLandroidx/constraintlayout/solver/widgets/analyzer/DependencyGraph;-><init>(Landroidx/constraintlayout/solver/widgets/ConstraintWidgetContainer;)V
-HSPLandroidx/constraintlayout/solver/widgets/analyzer/DependencyGraph;->invalidateGraph()V
-HSPLandroidx/constraintlayout/solver/widgets/analyzer/DependencyGraph;->invalidateMeasures()V
-HSPLandroidx/constraintlayout/solver/widgets/analyzer/DependencyGraph;->setMeasurer(Landroidx/constraintlayout/solver/widgets/analyzer/BasicMeasure$Measurer;)V
-HSPLandroidx/constraintlayout/solver/widgets/analyzer/DependencyNode$Type;-><clinit>()V
-HSPLandroidx/constraintlayout/solver/widgets/analyzer/DependencyNode$Type;-><init>(Ljava/lang/String;I)V
-HSPLandroidx/constraintlayout/solver/widgets/analyzer/DependencyNode;-><init>(Landroidx/constraintlayout/solver/widgets/analyzer/WidgetRun;)V
-HSPLandroidx/constraintlayout/solver/widgets/analyzer/DimensionDependency;-><init>(Landroidx/constraintlayout/solver/widgets/analyzer/WidgetRun;)V
-HSPLandroidx/constraintlayout/solver/widgets/analyzer/HorizontalWidgetRun;-><clinit>()V
-HSPLandroidx/constraintlayout/solver/widgets/analyzer/HorizontalWidgetRun;-><init>(Landroidx/constraintlayout/solver/widgets/ConstraintWidget;)V
-HSPLandroidx/constraintlayout/solver/widgets/analyzer/VerticalWidgetRun;-><init>(Landroidx/constraintlayout/solver/widgets/ConstraintWidget;)V
-HSPLandroidx/constraintlayout/solver/widgets/analyzer/WidgetRun$RunType;-><clinit>()V
-HSPLandroidx/constraintlayout/solver/widgets/analyzer/WidgetRun$RunType;-><init>(Ljava/lang/String;I)V
-HSPLandroidx/constraintlayout/solver/widgets/analyzer/WidgetRun;-><init>(Landroidx/constraintlayout/solver/widgets/ConstraintWidget;)V
-HSPLandroidx/constraintlayout/widget/ConstraintLayout$1;-><clinit>()V
-HSPLandroidx/constraintlayout/widget/ConstraintLayout$LayoutParams$Table;-><clinit>()V
-HSPLandroidx/constraintlayout/widget/ConstraintLayout$LayoutParams;-><init>(Landroid/content/Context;Landroid/util/AttributeSet;)V
-HSPLandroidx/constraintlayout/widget/ConstraintLayout$LayoutParams;->resolveLayoutDirection(I)V
-HSPLandroidx/constraintlayout/widget/ConstraintLayout$LayoutParams;->validate()V
-HSPLandroidx/constraintlayout/widget/ConstraintLayout$Measurer;-><init>(Landroidx/constraintlayout/widget/ConstraintLayout;Landroidx/constraintlayout/widget/ConstraintLayout;)V
-HSPLandroidx/constraintlayout/widget/ConstraintLayout$Measurer;->captureLayoutInfos(IIIIII)V
-HSPLandroidx/constraintlayout/widget/ConstraintLayout$Measurer;->didMeasures()V
-HSPLandroidx/constraintlayout/widget/ConstraintLayout$Measurer;->measure(Landroidx/constraintlayout/solver/widgets/ConstraintWidget;Landroidx/constraintlayout/solver/widgets/analyzer/BasicMeasure$Measure;)V
-HSPLandroidx/constraintlayout/widget/ConstraintLayout;-><init>(Landroid/content/Context;Landroid/util/AttributeSet;)V
-HSPLandroidx/constraintlayout/widget/ConstraintLayout;->access$000(Landroidx/constraintlayout/widget/ConstraintLayout;)Ljava/util/ArrayList;
-HSPLandroidx/constraintlayout/widget/ConstraintLayout;->addView(Landroid/view/View;ILandroid/view/ViewGroup$LayoutParams;)V
-HSPLandroidx/constraintlayout/widget/ConstraintLayout;->applyConstraintsFromLayoutParams(ZLandroid/view/View;Landroidx/constraintlayout/solver/widgets/ConstraintWidget;Landroidx/constraintlayout/widget/ConstraintLayout$LayoutParams;Landroid/util/SparseArray;)V
-HSPLandroidx/constraintlayout/widget/ConstraintLayout;->checkLayoutParams(Landroid/view/ViewGroup$LayoutParams;)Z
-HSPLandroidx/constraintlayout/widget/ConstraintLayout;->dispatchDraw(Landroid/graphics/Canvas;)V
-HSPLandroidx/constraintlayout/widget/ConstraintLayout;->generateLayoutParams(Landroid/util/AttributeSet;)Landroid/view/ViewGroup$LayoutParams;
-HSPLandroidx/constraintlayout/widget/ConstraintLayout;->generateLayoutParams(Landroid/util/AttributeSet;)Landroidx/constraintlayout/widget/ConstraintLayout$LayoutParams;
-HSPLandroidx/constraintlayout/widget/ConstraintLayout;->getPaddingWidth()I
-HSPLandroidx/constraintlayout/widget/ConstraintLayout;->getViewWidget(Landroid/view/View;)Landroidx/constraintlayout/solver/widgets/ConstraintWidget;
-HSPLandroidx/constraintlayout/widget/ConstraintLayout;->init(Landroid/util/AttributeSet;II)V
-HSPLandroidx/constraintlayout/widget/ConstraintLayout;->isRtl()Z
-HSPLandroidx/constraintlayout/widget/ConstraintLayout;->markHierarchyDirty()V
-HSPLandroidx/constraintlayout/widget/ConstraintLayout;->onLayout(ZIIII)V
-HSPLandroidx/constraintlayout/widget/ConstraintLayout;->onMeasure(II)V
-HSPLandroidx/constraintlayout/widget/ConstraintLayout;->onViewAdded(Landroid/view/View;)V
-HSPLandroidx/constraintlayout/widget/ConstraintLayout;->requestLayout()V
-HSPLandroidx/constraintlayout/widget/ConstraintLayout;->resolveMeasuredDimension(IIIIZZ)V
-HSPLandroidx/constraintlayout/widget/ConstraintLayout;->resolveSystem(Landroidx/constraintlayout/solver/widgets/ConstraintWidgetContainer;III)V
-HSPLandroidx/constraintlayout/widget/ConstraintLayout;->setChildrenConstraints()V
-HSPLandroidx/constraintlayout/widget/ConstraintLayout;->setSelfDimensionBehaviour(Landroidx/constraintlayout/solver/widgets/ConstraintWidgetContainer;IIII)V
-HSPLandroidx/constraintlayout/widget/ConstraintLayout;->updateHierarchy()Z
-HSPLandroidx/constraintlayout/widget/R$styleable;-><clinit>()V
-HSPLandroidx/core/app/CoreComponentFactory;-><init>()V
-HSPLandroidx/core/app/CoreComponentFactory;->checkCompatWrapper(Ljava/lang/Object;)Ljava/lang/Object;
-HSPLandroidx/core/app/CoreComponentFactory;->instantiateActivity(Ljava/lang/ClassLoader;Ljava/lang/String;Landroid/content/Intent;)Landroid/app/Activity;
-HSPLandroidx/core/app/CoreComponentFactory;->instantiateApplication(Ljava/lang/ClassLoader;Ljava/lang/String;)Landroid/app/Application;
-Landroidx/benchmark/integration/baselineprofiles/consumer/EmptyActivity;
-Landroidx/constraintlayout/solver/ArrayLinkedVariables;
-Landroidx/constraintlayout/solver/ArrayRow$ArrayRowVariables;
-Landroidx/constraintlayout/solver/ArrayRow;
-Landroidx/constraintlayout/solver/Cache;
-Landroidx/constraintlayout/solver/LinearSystem$Row;
-Landroidx/constraintlayout/solver/LinearSystem$ValuesRow;
-Landroidx/constraintlayout/solver/LinearSystem;
-Landroidx/constraintlayout/solver/Pools$Pool;
-Landroidx/constraintlayout/solver/Pools$SimplePool;
-Landroidx/constraintlayout/solver/PriorityGoalRow$GoalVariableAccessor;
-Landroidx/constraintlayout/solver/PriorityGoalRow;
-Landroidx/constraintlayout/solver/SolverVariable$Type;
-Landroidx/constraintlayout/solver/SolverVariable;
-Landroidx/constraintlayout/solver/SolverVariableValues;
-Landroidx/constraintlayout/solver/widgets/Barrier;
-Landroidx/constraintlayout/solver/widgets/ChainHead;
-Landroidx/constraintlayout/solver/widgets/ConstraintAnchor$Type;
-Landroidx/constraintlayout/solver/widgets/ConstraintAnchor;
-Landroidx/constraintlayout/solver/widgets/ConstraintWidget$1;
-Landroidx/constraintlayout/solver/widgets/ConstraintWidget$DimensionBehaviour;
-Landroidx/constraintlayout/solver/widgets/ConstraintWidget;
-Landroidx/constraintlayout/solver/widgets/ConstraintWidgetContainer;
-Landroidx/constraintlayout/solver/widgets/Guideline;
-Landroidx/constraintlayout/solver/widgets/Helper;
-Landroidx/constraintlayout/solver/widgets/HelperWidget;
-Landroidx/constraintlayout/solver/widgets/Optimizer;
-Landroidx/constraintlayout/solver/widgets/VirtualLayout;
-Landroidx/constraintlayout/solver/widgets/WidgetContainer;
-Landroidx/constraintlayout/solver/widgets/analyzer/BasicMeasure$Measure;
-Landroidx/constraintlayout/solver/widgets/analyzer/BasicMeasure$Measurer;
-Landroidx/constraintlayout/solver/widgets/analyzer/BasicMeasure;
-Landroidx/constraintlayout/solver/widgets/analyzer/Dependency;
-Landroidx/constraintlayout/solver/widgets/analyzer/DependencyGraph;
-Landroidx/constraintlayout/solver/widgets/analyzer/DependencyNode$Type;
-Landroidx/constraintlayout/solver/widgets/analyzer/DependencyNode;
-Landroidx/constraintlayout/solver/widgets/analyzer/DimensionDependency;
-Landroidx/constraintlayout/solver/widgets/analyzer/HorizontalWidgetRun;
-Landroidx/constraintlayout/solver/widgets/analyzer/VerticalWidgetRun;
-Landroidx/constraintlayout/solver/widgets/analyzer/WidgetRun$RunType;
-Landroidx/constraintlayout/solver/widgets/analyzer/WidgetRun;
-Landroidx/constraintlayout/widget/ConstraintHelper;
-Landroidx/constraintlayout/widget/ConstraintLayout$1;
-Landroidx/constraintlayout/widget/ConstraintLayout$LayoutParams$Table;
-Landroidx/constraintlayout/widget/ConstraintLayout$LayoutParams;
-Landroidx/constraintlayout/widget/ConstraintLayout$Measurer;
-Landroidx/constraintlayout/widget/ConstraintLayout;
-Landroidx/constraintlayout/widget/Guideline;
-Landroidx/constraintlayout/widget/Placeholder;
-Landroidx/constraintlayout/widget/R$styleable;
-Landroidx/constraintlayout/widget/VirtualLayout;
-Landroidx/core/app/CoreComponentFactory$CompatWrapped;
-Landroidx/core/app/CoreComponentFactory;
\ No newline at end of file
diff --git a/benchmark/integration-tests/baselineprofiles-consumer/src/release/generated/baselineProfiles/expected-baseline-prof.txt b/benchmark/integration-tests/baselineprofiles-consumer/src/release/generated/baselineProfiles/expected-baseline-prof.txt
new file mode 100644
index 0000000..427affb
--- /dev/null
+++ b/benchmark/integration-tests/baselineprofiles-consumer/src/release/generated/baselineProfiles/expected-baseline-prof.txt
@@ -0,0 +1,3 @@
+Landroidx/benchmark/integration/baselineprofiles/consumer/EmptyActivity;
+HSPLandroidx/benchmark/integration/baselineprofiles/consumer/EmptyActivity;-><init>()V
+HSPLandroidx/benchmark/integration/baselineprofiles/consumer/EmptyActivity;->onCreate(Landroid/os/Bundle;)V
\ No newline at end of file
diff --git a/benchmark/integration-tests/baselineprofiles-flavors-consumer/build.gradle b/benchmark/integration-tests/baselineprofiles-flavors-consumer/build.gradle
index 8ae2e3f..2caef026 100644
--- a/benchmark/integration-tests/baselineprofiles-flavors-consumer/build.gradle
+++ b/benchmark/integration-tests/baselineprofiles-flavors-consumer/build.gradle
@@ -50,11 +50,20 @@
     implementation(libs.kotlinStdlib)
     implementation(libs.constraintLayout)
 
-    baselineprofiles(project(":benchmark:integration-tests:baselineprofiles-flavors-producer"))
+    baselineProfiles(project(":benchmark:integration-tests:baselineprofiles-flavors-producer"))
 }
 
 baselineProfilesProfileConsumer {
-    buildTypeName = "release"
+    onDemandGeneration = false
+    filter {
+        include "androidx.benchmark.integration.baselineprofiles.flavors.consumer.*"
+    }
+    filter("free") {
+        include "androidx.benchmark.integration.baselineprofiles.flavors.consumer.free.*"
+    }
+    filter("paid") {
+        include "androidx.benchmark.integration.baselineprofiles.flavors.consumer.paid.*"
+    }
 }
 
 apply(from: "../baselineprofiles-test-utils/utils.gradle")
diff --git a/appactions/interaction/interaction-service/src/main/java/androidx/appactions/interaction/service/proto/package-info.java b/benchmark/integration-tests/baselineprofiles-flavors-consumer/src/free/java/androidx/benchmark/integration/baselineprofiles/flavors/consumer/Utils.kt
similarity index 73%
copy from appactions/interaction/interaction-service/src/main/java/androidx/appactions/interaction/service/proto/package-info.java
copy to benchmark/integration-tests/baselineprofiles-flavors-consumer/src/free/java/androidx/benchmark/integration/baselineprofiles/flavors/consumer/Utils.kt
index 93db9d1..c7bc6b4 100644
--- a/appactions/interaction/interaction-service/src/main/java/androidx/appactions/interaction/service/proto/package-info.java
+++ b/benchmark/integration-tests/baselineprofiles-flavors-consumer/src/free/java/androidx/benchmark/integration/baselineprofiles/flavors/consumer/Utils.kt
@@ -14,8 +14,8 @@
  * limitations under the License.
  */
 
-/** @hide */
-@RestrictTo(RestrictTo.Scope.LIBRARY)
-package androidx.appactions.interaction.service.proto;
+package androidx.benchmark.integration.baselineprofiles.flavors.consumer
 
-import androidx.annotation.RestrictTo;
+import androidx.benchmark.integration.baselineprofiles.flavors.consumer.free.freeFlavorMethod
+
+fun callFlavorMethod() = freeFlavorMethod()
diff --git a/appactions/interaction/interaction-service/src/main/java/androidx/appactions/interaction/service/proto/package-info.java b/benchmark/integration-tests/baselineprofiles-flavors-consumer/src/free/java/androidx/benchmark/integration/baselineprofiles/flavors/consumer/free/FreeUtils.kt
similarity index 80%
copy from appactions/interaction/interaction-service/src/main/java/androidx/appactions/interaction/service/proto/package-info.java
copy to benchmark/integration-tests/baselineprofiles-flavors-consumer/src/free/java/androidx/benchmark/integration/baselineprofiles/flavors/consumer/free/FreeUtils.kt
index 93db9d1..ad47a65 100644
--- a/appactions/interaction/interaction-service/src/main/java/androidx/appactions/interaction/service/proto/package-info.java
+++ b/benchmark/integration-tests/baselineprofiles-flavors-consumer/src/free/java/androidx/benchmark/integration/baselineprofiles/flavors/consumer/free/FreeUtils.kt
@@ -14,8 +14,6 @@
  * limitations under the License.
  */
 
-/** @hide */
-@RestrictTo(RestrictTo.Scope.LIBRARY)
-package androidx.appactions.interaction.service.proto;
+package androidx.benchmark.integration.baselineprofiles.flavors.consumer.free
 
-import androidx.annotation.RestrictTo;
+fun freeFlavorMethod() = "free flavor"
diff --git a/benchmark/integration-tests/baselineprofiles-flavors-consumer/src/freeRelease/generated/baselineProfiles/expected-baseline-prof.txt b/benchmark/integration-tests/baselineprofiles-flavors-consumer/src/freeRelease/generated/baselineProfiles/expected-baseline-prof.txt
new file mode 100644
index 0000000..b30aa32
--- /dev/null
+++ b/benchmark/integration-tests/baselineprofiles-flavors-consumer/src/freeRelease/generated/baselineProfiles/expected-baseline-prof.txt
@@ -0,0 +1,7 @@
+Landroidx/benchmark/integration/baselineprofiles/flavors/consumer/EmptyActivity;
+HSPLandroidx/benchmark/integration/baselineprofiles/flavors/consumer/EmptyActivity;-><init>()V
+HSPLandroidx/benchmark/integration/baselineprofiles/flavors/consumer/EmptyActivity;->onCreate(Landroid/os/Bundle;)V
+Landroidx/benchmark/integration/baselineprofiles/flavors/consumer/UtilsKt;
+HSPLandroidx/benchmark/integration/baselineprofiles/flavors/consumer/UtilsKt;->callFlavorMethod()Ljava/lang/String;
+Landroidx/benchmark/integration/baselineprofiles/flavors/consumer/free/FreeUtilsKt;
+HSPLandroidx/benchmark/integration/baselineprofiles/flavors/consumer/free/FreeUtilsKt;->freeFlavorMethod()Ljava/lang/String;
\ No newline at end of file
diff --git a/benchmark/integration-tests/baselineprofiles-flavors-consumer/src/main/java/androidx/benchmark/integration/baselineprofiles/flavors/consumer/EmptyActivity.kt b/benchmark/integration-tests/baselineprofiles-flavors-consumer/src/main/java/androidx/benchmark/integration/baselineprofiles/flavors/consumer/EmptyActivity.kt
index 5119859..998aeed 100644
--- a/benchmark/integration-tests/baselineprofiles-flavors-consumer/src/main/java/androidx/benchmark/integration/baselineprofiles/flavors/consumer/EmptyActivity.kt
+++ b/benchmark/integration-tests/baselineprofiles-flavors-consumer/src/main/java/androidx/benchmark/integration/baselineprofiles/flavors/consumer/EmptyActivity.kt
@@ -24,6 +24,6 @@
     override fun onCreate(savedInstanceState: Bundle?) {
         super.onCreate(savedInstanceState)
         setContentView(R.layout.activity_main)
-        findViewById<TextView>(R.id.txtNotice).setText(R.string.app_notice)
+        findViewById<TextView>(R.id.txtNotice).text = callFlavorMethod()
     }
 }
diff --git a/appactions/interaction/interaction-service/src/main/java/androidx/appactions/interaction/service/proto/package-info.java b/benchmark/integration-tests/baselineprofiles-flavors-consumer/src/paid/java/androidx/benchmark/integration/baselineprofiles/flavors/consumer/Utils.kt
similarity index 73%
copy from appactions/interaction/interaction-service/src/main/java/androidx/appactions/interaction/service/proto/package-info.java
copy to benchmark/integration-tests/baselineprofiles-flavors-consumer/src/paid/java/androidx/benchmark/integration/baselineprofiles/flavors/consumer/Utils.kt
index 93db9d1..b13710f 100644
--- a/appactions/interaction/interaction-service/src/main/java/androidx/appactions/interaction/service/proto/package-info.java
+++ b/benchmark/integration-tests/baselineprofiles-flavors-consumer/src/paid/java/androidx/benchmark/integration/baselineprofiles/flavors/consumer/Utils.kt
@@ -14,8 +14,8 @@
  * limitations under the License.
  */
 
-/** @hide */
-@RestrictTo(RestrictTo.Scope.LIBRARY)
-package androidx.appactions.interaction.service.proto;
+package androidx.benchmark.integration.baselineprofiles.flavors.consumer
 
-import androidx.annotation.RestrictTo;
+import androidx.benchmark.integration.baselineprofiles.flavors.consumer.paid.paidFlavorMethod
+
+fun callFlavorMethod() = paidFlavorMethod()
diff --git a/appactions/interaction/interaction-service/src/main/java/androidx/appactions/interaction/service/proto/package-info.java b/benchmark/integration-tests/baselineprofiles-flavors-consumer/src/paid/java/androidx/benchmark/integration/baselineprofiles/flavors/consumer/paid/PaidUtils.kt
similarity index 80%
copy from appactions/interaction/interaction-service/src/main/java/androidx/appactions/interaction/service/proto/package-info.java
copy to benchmark/integration-tests/baselineprofiles-flavors-consumer/src/paid/java/androidx/benchmark/integration/baselineprofiles/flavors/consumer/paid/PaidUtils.kt
index 93db9d1..5ab5252 100644
--- a/appactions/interaction/interaction-service/src/main/java/androidx/appactions/interaction/service/proto/package-info.java
+++ b/benchmark/integration-tests/baselineprofiles-flavors-consumer/src/paid/java/androidx/benchmark/integration/baselineprofiles/flavors/consumer/paid/PaidUtils.kt
@@ -14,8 +14,6 @@
  * limitations under the License.
  */
 
-/** @hide */
-@RestrictTo(RestrictTo.Scope.LIBRARY)
-package androidx.appactions.interaction.service.proto;
+package androidx.benchmark.integration.baselineprofiles.flavors.consumer.paid
 
-import androidx.annotation.RestrictTo;
+fun paidFlavorMethod() = "paid flavor"
diff --git a/benchmark/integration-tests/baselineprofiles-flavors-consumer/src/paidRelease/generated/baselineProfiles/expected-baseline-prof.txt b/benchmark/integration-tests/baselineprofiles-flavors-consumer/src/paidRelease/generated/baselineProfiles/expected-baseline-prof.txt
new file mode 100644
index 0000000..97492d3
--- /dev/null
+++ b/benchmark/integration-tests/baselineprofiles-flavors-consumer/src/paidRelease/generated/baselineProfiles/expected-baseline-prof.txt
@@ -0,0 +1,7 @@
+Landroidx/benchmark/integration/baselineprofiles/flavors/consumer/EmptyActivity;
+HSPLandroidx/benchmark/integration/baselineprofiles/flavors/consumer/EmptyActivity;-><init>()V
+HSPLandroidx/benchmark/integration/baselineprofiles/flavors/consumer/EmptyActivity;->onCreate(Landroid/os/Bundle;)V
+Landroidx/benchmark/integration/baselineprofiles/flavors/consumer/UtilsKt;
+HSPLandroidx/benchmark/integration/baselineprofiles/flavors/consumer/UtilsKt;->callFlavorMethod()Ljava/lang/String;
+Landroidx/benchmark/integration/baselineprofiles/flavors/consumer/paid/PaidUtilsKt;
+HSPLandroidx/benchmark/integration/baselineprofiles/flavors/consumer/paid/PaidUtilsKt;->paidFlavorMethod()Ljava/lang/String;
\ No newline at end of file
diff --git a/benchmark/integration-tests/baselineprofiles-flavors-producer/build.gradle b/benchmark/integration-tests/baselineprofiles-flavors-producer/build.gradle
index d3e6cdb..66530fa 100644
--- a/benchmark/integration-tests/baselineprofiles-flavors-producer/build.gradle
+++ b/benchmark/integration-tests/baselineprofiles-flavors-producer/build.gradle
@@ -35,9 +35,6 @@
             systemImageSource = "aosp"
         }
     }
-    buildTypes {
-        release { }
-    }
     productFlavors {
         flavorDimensions = ["version"]
         free { dimension "version" }
@@ -59,8 +56,8 @@
 }
 
 baselineProfilesProfileProducer {
-    managedDevices += "pixel6Api31"
-    useConnectedDevices = false
+    enableEmulatorDisplay = true
+    useConnectedDevices = true
 }
 
 androidx {
diff --git a/benchmark/integration-tests/baselineprofiles-library-build-provider/build.gradle b/benchmark/integration-tests/baselineprofiles-library-build-provider/build.gradle
index 9c8061f..fc427b6 100644
--- a/benchmark/integration-tests/baselineprofiles-library-build-provider/build.gradle
+++ b/benchmark/integration-tests/baselineprofiles-library-build-provider/build.gradle
@@ -33,6 +33,7 @@
 }
 
 dependencies {
+    implementation(project(":benchmark:integration-tests:baselineprofiles-library-consumer"))
     implementation(libs.kotlinStdlib)
     implementation(libs.constraintLayout)
 }
diff --git a/benchmark/integration-tests/baselineprofiles-library-build-provider/src/main/java/androidx/benchmark/integration/baselineprofiles/library/buildprovider/EmptyActivity.kt b/benchmark/integration-tests/baselineprofiles-library-build-provider/src/main/java/androidx/benchmark/integration/baselineprofiles/library/buildprovider/EmptyActivity.kt
index 64b8ed7..c73e6e5 100644
--- a/benchmark/integration-tests/baselineprofiles-library-build-provider/src/main/java/androidx/benchmark/integration/baselineprofiles/library/buildprovider/EmptyActivity.kt
+++ b/benchmark/integration-tests/baselineprofiles-library-build-provider/src/main/java/androidx/benchmark/integration/baselineprofiles/library/buildprovider/EmptyActivity.kt
@@ -19,11 +19,21 @@
 import android.app.Activity
 import android.os.Bundle
 import android.widget.TextView
+import androidx.benchmark.integration.baselineprofiles.library.consumer.IncludeClass
+import androidx.benchmark.integration.baselineprofiles.library.consumer.exclude.ExcludeClass
 
 class EmptyActivity : Activity() {
     override fun onCreate(savedInstanceState: Bundle?) {
         super.onCreate(savedInstanceState)
         setContentView(R.layout.activity_main)
         findViewById<TextView>(R.id.txtNotice).setText(R.string.app_notice)
+
+        val includeClass = IncludeClass()
+        includeClass.doSomething()
+        includeClass.doSomethingWithArgument("a string")
+        includeClass.doSomethingWithReturnType(3, 5)
+
+        val excludeClass = ExcludeClass()
+        excludeClass.doSomething()
     }
 }
diff --git a/benchmark/integration-tests/baselineprofiles-library-consumer/build.gradle b/benchmark/integration-tests/baselineprofiles-library-consumer/build.gradle
index e314c41..8bbf3fe 100644
--- a/benchmark/integration-tests/baselineprofiles-library-consumer/build.gradle
+++ b/benchmark/integration-tests/baselineprofiles-library-consumer/build.gradle
@@ -26,11 +26,16 @@
 }
 
 dependencies {
-    baselineprofiles(project(":benchmark:integration-tests:baselineprofiles-library-producer"))
+    implementation(libs.kotlinStdlib)
+    baselineProfiles(project(":benchmark:integration-tests:baselineprofiles-library-producer"))
 }
 
-dependencies {
-    implementation(libs.kotlinStdlib)
+baselineProfilesProfileConsumer {
+    onDemandGeneration = false
+    filter {
+        include "androidx.benchmark.integration.baselineprofiles.library.consumer.**"
+        exclude "androidx.benchmark.integration.baselineprofiles.library.consumer.exclude.*"
+    }
 }
 
 apply(from: "../baselineprofiles-test-utils/utils.gradle")
diff --git a/benchmark/integration-tests/baselineprofiles-library-consumer/src/main/expected-baseline-prof.txt b/benchmark/integration-tests/baselineprofiles-library-consumer/src/main/expected-baseline-prof.txt
deleted file mode 100644
index 2dde749..0000000
--- a/benchmark/integration-tests/baselineprofiles-library-consumer/src/main/expected-baseline-prof.txt
+++ /dev/null
@@ -1,301 +0,0 @@
-HSPLandroidx/benchmark/integration/baselineprofiles/library/buildprovider/EmptyActivity;-><init>()V
-HSPLandroidx/benchmark/integration/baselineprofiles/library/buildprovider/EmptyActivity;->onCreate(Landroid/os/Bundle;)V
-HSPLandroidx/constraintlayout/solver/ArrayLinkedVariables;-><clinit>()V
-HSPLandroidx/constraintlayout/solver/ArrayLinkedVariables;-><init>(Landroidx/constraintlayout/solver/ArrayRow;Landroidx/constraintlayout/solver/Cache;)V
-HSPLandroidx/constraintlayout/solver/ArrayRow;-><init>()V
-HSPLandroidx/constraintlayout/solver/ArrayRow;-><init>(Landroidx/constraintlayout/solver/Cache;)V
-HSPLandroidx/constraintlayout/solver/ArrayRow;->addError(Landroidx/constraintlayout/solver/LinearSystem;I)Landroidx/constraintlayout/solver/ArrayRow;
-HSPLandroidx/constraintlayout/solver/ArrayRow;->addSingleError(Landroidx/constraintlayout/solver/SolverVariable;I)Landroidx/constraintlayout/solver/ArrayRow;
-HSPLandroidx/constraintlayout/solver/ArrayRow;->chooseSubject(Landroidx/constraintlayout/solver/LinearSystem;)Z
-HSPLandroidx/constraintlayout/solver/ArrayRow;->chooseSubjectInVariables(Landroidx/constraintlayout/solver/LinearSystem;)Landroidx/constraintlayout/solver/SolverVariable;
-HSPLandroidx/constraintlayout/solver/ArrayRow;->createRowCentering(Landroidx/constraintlayout/solver/SolverVariable;Landroidx/constraintlayout/solver/SolverVariable;IFLandroidx/constraintlayout/solver/SolverVariable;Landroidx/constraintlayout/solver/SolverVariable;I)Landroidx/constraintlayout/solver/ArrayRow;
-HSPLandroidx/constraintlayout/solver/ArrayRow;->createRowEquals(Landroidx/constraintlayout/solver/SolverVariable;Landroidx/constraintlayout/solver/SolverVariable;I)Landroidx/constraintlayout/solver/ArrayRow;
-HSPLandroidx/constraintlayout/solver/ArrayRow;->createRowGreaterThan(Landroidx/constraintlayout/solver/SolverVariable;Landroidx/constraintlayout/solver/SolverVariable;Landroidx/constraintlayout/solver/SolverVariable;I)Landroidx/constraintlayout/solver/ArrayRow;
-HSPLandroidx/constraintlayout/solver/ArrayRow;->createRowLowerThan(Landroidx/constraintlayout/solver/SolverVariable;Landroidx/constraintlayout/solver/SolverVariable;Landroidx/constraintlayout/solver/SolverVariable;I)Landroidx/constraintlayout/solver/ArrayRow;
-HSPLandroidx/constraintlayout/solver/ArrayRow;->ensurePositiveConstant()V
-HSPLandroidx/constraintlayout/solver/ArrayRow;->getKey()Landroidx/constraintlayout/solver/SolverVariable;
-HSPLandroidx/constraintlayout/solver/ArrayRow;->hasKeyVariable()Z
-HSPLandroidx/constraintlayout/solver/ArrayRow;->isEmpty()Z
-HSPLandroidx/constraintlayout/solver/ArrayRow;->isNew(Landroidx/constraintlayout/solver/SolverVariable;Landroidx/constraintlayout/solver/LinearSystem;)Z
-HSPLandroidx/constraintlayout/solver/ArrayRow;->pivot(Landroidx/constraintlayout/solver/SolverVariable;)V
-HSPLandroidx/constraintlayout/solver/ArrayRow;->reset()V
-HSPLandroidx/constraintlayout/solver/ArrayRow;->updateFromFinalVariable(Landroidx/constraintlayout/solver/LinearSystem;Landroidx/constraintlayout/solver/SolverVariable;Z)V
-HSPLandroidx/constraintlayout/solver/ArrayRow;->updateFromRow(Landroidx/constraintlayout/solver/ArrayRow;Z)V
-HSPLandroidx/constraintlayout/solver/ArrayRow;->updateFromSystem(Landroidx/constraintlayout/solver/LinearSystem;)V
-HSPLandroidx/constraintlayout/solver/Cache;-><init>()V
-HSPLandroidx/constraintlayout/solver/LinearSystem$ValuesRow;-><init>(Landroidx/constraintlayout/solver/LinearSystem;Landroidx/constraintlayout/solver/Cache;)V
-HSPLandroidx/constraintlayout/solver/LinearSystem;-><clinit>()V
-HSPLandroidx/constraintlayout/solver/LinearSystem;-><init>()V
-HSPLandroidx/constraintlayout/solver/LinearSystem;->acquireSolverVariable(Landroidx/constraintlayout/solver/SolverVariable$Type;Ljava/lang/String;)Landroidx/constraintlayout/solver/SolverVariable;
-HSPLandroidx/constraintlayout/solver/LinearSystem;->addCentering(Landroidx/constraintlayout/solver/SolverVariable;Landroidx/constraintlayout/solver/SolverVariable;IFLandroidx/constraintlayout/solver/SolverVariable;Landroidx/constraintlayout/solver/SolverVariable;II)V
-HSPLandroidx/constraintlayout/solver/LinearSystem;->addConstraint(Landroidx/constraintlayout/solver/ArrayRow;)V
-HSPLandroidx/constraintlayout/solver/LinearSystem;->addEquality(Landroidx/constraintlayout/solver/SolverVariable;I)V
-HSPLandroidx/constraintlayout/solver/LinearSystem;->addEquality(Landroidx/constraintlayout/solver/SolverVariable;Landroidx/constraintlayout/solver/SolverVariable;II)Landroidx/constraintlayout/solver/ArrayRow;
-HSPLandroidx/constraintlayout/solver/LinearSystem;->addGreaterThan(Landroidx/constraintlayout/solver/SolverVariable;Landroidx/constraintlayout/solver/SolverVariable;II)V
-HSPLandroidx/constraintlayout/solver/LinearSystem;->addLowerThan(Landroidx/constraintlayout/solver/SolverVariable;Landroidx/constraintlayout/solver/SolverVariable;II)V
-HSPLandroidx/constraintlayout/solver/LinearSystem;->addRow(Landroidx/constraintlayout/solver/ArrayRow;)V
-HSPLandroidx/constraintlayout/solver/LinearSystem;->addSingleError(Landroidx/constraintlayout/solver/ArrayRow;II)V
-HSPLandroidx/constraintlayout/solver/LinearSystem;->computeValues()V
-HSPLandroidx/constraintlayout/solver/LinearSystem;->createErrorVariable(ILjava/lang/String;)Landroidx/constraintlayout/solver/SolverVariable;
-HSPLandroidx/constraintlayout/solver/LinearSystem;->createObjectVariable(Ljava/lang/Object;)Landroidx/constraintlayout/solver/SolverVariable;
-HSPLandroidx/constraintlayout/solver/LinearSystem;->createRow()Landroidx/constraintlayout/solver/ArrayRow;
-HSPLandroidx/constraintlayout/solver/LinearSystem;->createSlackVariable()Landroidx/constraintlayout/solver/SolverVariable;
-HSPLandroidx/constraintlayout/solver/LinearSystem;->enforceBFS(Landroidx/constraintlayout/solver/LinearSystem$Row;)I
-HSPLandroidx/constraintlayout/solver/LinearSystem;->getCache()Landroidx/constraintlayout/solver/Cache;
-HSPLandroidx/constraintlayout/solver/LinearSystem;->getMetrics()Landroidx/constraintlayout/solver/Metrics;
-HSPLandroidx/constraintlayout/solver/LinearSystem;->getObjectVariableValue(Ljava/lang/Object;)I
-HSPLandroidx/constraintlayout/solver/LinearSystem;->increaseTableSize()V
-HSPLandroidx/constraintlayout/solver/LinearSystem;->minimize()V
-HSPLandroidx/constraintlayout/solver/LinearSystem;->minimizeGoal(Landroidx/constraintlayout/solver/LinearSystem$Row;)V
-HSPLandroidx/constraintlayout/solver/LinearSystem;->optimize(Landroidx/constraintlayout/solver/LinearSystem$Row;Z)I
-HSPLandroidx/constraintlayout/solver/LinearSystem;->releaseRows()V
-HSPLandroidx/constraintlayout/solver/LinearSystem;->reset()V
-HSPLandroidx/constraintlayout/solver/Pools$SimplePool;-><init>(I)V
-HSPLandroidx/constraintlayout/solver/Pools$SimplePool;->acquire()Ljava/lang/Object;
-HSPLandroidx/constraintlayout/solver/Pools$SimplePool;->release(Ljava/lang/Object;)Z
-HSPLandroidx/constraintlayout/solver/Pools$SimplePool;->releaseAll([Ljava/lang/Object;I)V
-HSPLandroidx/constraintlayout/solver/PriorityGoalRow$GoalVariableAccessor;-><init>(Landroidx/constraintlayout/solver/PriorityGoalRow;Landroidx/constraintlayout/solver/PriorityGoalRow;)V
-HSPLandroidx/constraintlayout/solver/PriorityGoalRow$GoalVariableAccessor;->addToGoal(Landroidx/constraintlayout/solver/SolverVariable;F)Z
-HSPLandroidx/constraintlayout/solver/PriorityGoalRow$GoalVariableAccessor;->init(Landroidx/constraintlayout/solver/SolverVariable;)V
-HSPLandroidx/constraintlayout/solver/PriorityGoalRow$GoalVariableAccessor;->isNegative()Z
-HSPLandroidx/constraintlayout/solver/PriorityGoalRow$GoalVariableAccessor;->reset()V
-HSPLandroidx/constraintlayout/solver/PriorityGoalRow;-><init>(Landroidx/constraintlayout/solver/Cache;)V
-HSPLandroidx/constraintlayout/solver/PriorityGoalRow;->addError(Landroidx/constraintlayout/solver/SolverVariable;)V
-HSPLandroidx/constraintlayout/solver/PriorityGoalRow;->addToGoal(Landroidx/constraintlayout/solver/SolverVariable;)V
-HSPLandroidx/constraintlayout/solver/PriorityGoalRow;->clear()V
-HSPLandroidx/constraintlayout/solver/PriorityGoalRow;->getPivotCandidate(Landroidx/constraintlayout/solver/LinearSystem;[Z)Landroidx/constraintlayout/solver/SolverVariable;
-HSPLandroidx/constraintlayout/solver/PriorityGoalRow;->removeGoal(Landroidx/constraintlayout/solver/SolverVariable;)V
-HSPLandroidx/constraintlayout/solver/PriorityGoalRow;->updateFromRow(Landroidx/constraintlayout/solver/ArrayRow;Z)V
-HSPLandroidx/constraintlayout/solver/SolverVariable$Type;-><clinit>()V
-HSPLandroidx/constraintlayout/solver/SolverVariable$Type;-><init>(Ljava/lang/String;I)V
-HSPLandroidx/constraintlayout/solver/SolverVariable;-><clinit>()V
-HSPLandroidx/constraintlayout/solver/SolverVariable;-><init>(Landroidx/constraintlayout/solver/SolverVariable$Type;Ljava/lang/String;)V
-HSPLandroidx/constraintlayout/solver/SolverVariable;->addToRow(Landroidx/constraintlayout/solver/ArrayRow;)V
-HSPLandroidx/constraintlayout/solver/SolverVariable;->increaseErrorId()V
-HSPLandroidx/constraintlayout/solver/SolverVariable;->removeFromRow(Landroidx/constraintlayout/solver/ArrayRow;)V
-HSPLandroidx/constraintlayout/solver/SolverVariable;->reset()V
-HSPLandroidx/constraintlayout/solver/SolverVariable;->setFinalValue(Landroidx/constraintlayout/solver/LinearSystem;F)V
-HSPLandroidx/constraintlayout/solver/SolverVariable;->setType(Landroidx/constraintlayout/solver/SolverVariable$Type;Ljava/lang/String;)V
-HSPLandroidx/constraintlayout/solver/SolverVariable;->updateReferencesWithNewDefinition(Landroidx/constraintlayout/solver/ArrayRow;)V
-HSPLandroidx/constraintlayout/solver/SolverVariableValues;-><clinit>()V
-HSPLandroidx/constraintlayout/solver/SolverVariableValues;-><init>(Landroidx/constraintlayout/solver/ArrayRow;Landroidx/constraintlayout/solver/Cache;)V
-HSPLandroidx/constraintlayout/solver/SolverVariableValues;->add(Landroidx/constraintlayout/solver/SolverVariable;FZ)V
-HSPLandroidx/constraintlayout/solver/SolverVariableValues;->addToHashMap(Landroidx/constraintlayout/solver/SolverVariable;I)V
-HSPLandroidx/constraintlayout/solver/SolverVariableValues;->addVariable(ILandroidx/constraintlayout/solver/SolverVariable;F)V
-HSPLandroidx/constraintlayout/solver/SolverVariableValues;->clear()V
-HSPLandroidx/constraintlayout/solver/SolverVariableValues;->divideByAmount(F)V
-HSPLandroidx/constraintlayout/solver/SolverVariableValues;->findEmptySlot()I
-HSPLandroidx/constraintlayout/solver/SolverVariableValues;->get(Landroidx/constraintlayout/solver/SolverVariable;)F
-HSPLandroidx/constraintlayout/solver/SolverVariableValues;->getCurrentSize()I
-HSPLandroidx/constraintlayout/solver/SolverVariableValues;->getVariable(I)Landroidx/constraintlayout/solver/SolverVariable;
-HSPLandroidx/constraintlayout/solver/SolverVariableValues;->getVariableValue(I)F
-HSPLandroidx/constraintlayout/solver/SolverVariableValues;->indexOf(Landroidx/constraintlayout/solver/SolverVariable;)I
-HSPLandroidx/constraintlayout/solver/SolverVariableValues;->insertVariable(ILandroidx/constraintlayout/solver/SolverVariable;F)V
-HSPLandroidx/constraintlayout/solver/SolverVariableValues;->invert()V
-HSPLandroidx/constraintlayout/solver/SolverVariableValues;->put(Landroidx/constraintlayout/solver/SolverVariable;F)V
-HSPLandroidx/constraintlayout/solver/SolverVariableValues;->remove(Landroidx/constraintlayout/solver/SolverVariable;Z)F
-HSPLandroidx/constraintlayout/solver/SolverVariableValues;->removeFromHashMap(Landroidx/constraintlayout/solver/SolverVariable;)V
-HSPLandroidx/constraintlayout/solver/SolverVariableValues;->use(Landroidx/constraintlayout/solver/ArrayRow;Z)F
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintAnchor$Type;-><clinit>()V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintAnchor$Type;-><init>(Ljava/lang/String;I)V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintAnchor$Type;->values()[Landroidx/constraintlayout/solver/widgets/ConstraintAnchor$Type;
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintAnchor;-><init>(Landroidx/constraintlayout/solver/widgets/ConstraintWidget;Landroidx/constraintlayout/solver/widgets/ConstraintAnchor$Type;)V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintAnchor;->connect(Landroidx/constraintlayout/solver/widgets/ConstraintAnchor;IIZ)Z
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintAnchor;->getMargin()I
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintAnchor;->getSolverVariable()Landroidx/constraintlayout/solver/SolverVariable;
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintAnchor;->getTarget()Landroidx/constraintlayout/solver/widgets/ConstraintAnchor;
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintAnchor;->isConnected()Z
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintAnchor;->reset()V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintAnchor;->resetSolverVariable(Landroidx/constraintlayout/solver/Cache;)V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget$1;-><clinit>()V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget$DimensionBehaviour;-><clinit>()V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget$DimensionBehaviour;-><init>(Ljava/lang/String;I)V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget$DimensionBehaviour;->values()[Landroidx/constraintlayout/solver/widgets/ConstraintWidget$DimensionBehaviour;
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;-><clinit>()V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;-><init>()V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->addAnchors()V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->addFirst()Z
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->addToSolver(Landroidx/constraintlayout/solver/LinearSystem;)V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->applyConstraints(Landroidx/constraintlayout/solver/LinearSystem;ZZZZLandroidx/constraintlayout/solver/SolverVariable;Landroidx/constraintlayout/solver/SolverVariable;Landroidx/constraintlayout/solver/widgets/ConstraintWidget$DimensionBehaviour;ZLandroidx/constraintlayout/solver/widgets/ConstraintAnchor;Landroidx/constraintlayout/solver/widgets/ConstraintAnchor;IIIIFZZZZIIIIFZ)V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->createObjectVariables(Landroidx/constraintlayout/solver/LinearSystem;)V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->getAnchor(Landroidx/constraintlayout/solver/widgets/ConstraintAnchor$Type;)Landroidx/constraintlayout/solver/widgets/ConstraintAnchor;
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->getBaselineDistance()I
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->getCompanionWidget()Ljava/lang/Object;
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->getDimensionBehaviour(I)Landroidx/constraintlayout/solver/widgets/ConstraintWidget$DimensionBehaviour;
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->getHeight()I
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->getHorizontalDimensionBehaviour()Landroidx/constraintlayout/solver/widgets/ConstraintWidget$DimensionBehaviour;
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->getMinHeight()I
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->getMinWidth()I
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->getParent()Landroidx/constraintlayout/solver/widgets/ConstraintWidget;
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->getVerticalDimensionBehaviour()Landroidx/constraintlayout/solver/widgets/ConstraintWidget$DimensionBehaviour;
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->getVisibility()I
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->getWidth()I
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->getX()I
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->getY()I
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->immediateConnect(Landroidx/constraintlayout/solver/widgets/ConstraintAnchor$Type;Landroidx/constraintlayout/solver/widgets/ConstraintWidget;Landroidx/constraintlayout/solver/widgets/ConstraintAnchor$Type;II)V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->isChainHead(I)Z
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->isInHorizontalChain()Z
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->isInVerticalChain()Z
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->reset()V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->resetSolverVariables(Landroidx/constraintlayout/solver/Cache;)V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->setBaselineDistance(I)V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->setCompanionWidget(Ljava/lang/Object;)V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->setDimensionRatio(Ljava/lang/String;)V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->setFrame(IIII)V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->setHasBaseline(Z)V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->setHeight(I)V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->setHorizontalBiasPercent(F)V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->setHorizontalChainStyle(I)V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->setHorizontalDimensionBehaviour(Landroidx/constraintlayout/solver/widgets/ConstraintWidget$DimensionBehaviour;)V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->setHorizontalMatchStyle(IIIF)V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->setHorizontalWeight(F)V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->setInBarrier(IZ)V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->setMaxHeight(I)V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->setMaxWidth(I)V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->setMinHeight(I)V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->setMinWidth(I)V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->setParent(Landroidx/constraintlayout/solver/widgets/ConstraintWidget;)V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->setVerticalBiasPercent(F)V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->setVerticalChainStyle(I)V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->setVerticalDimensionBehaviour(Landroidx/constraintlayout/solver/widgets/ConstraintWidget$DimensionBehaviour;)V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->setVerticalMatchStyle(IIIF)V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->setVerticalWeight(F)V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->setVisibility(I)V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->setWidth(I)V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->setX(I)V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->setY(I)V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidget;->updateFromSolver(Landroidx/constraintlayout/solver/LinearSystem;)V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidgetContainer;-><init>()V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidgetContainer;->addChildrenToSolver(Landroidx/constraintlayout/solver/LinearSystem;)Z
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidgetContainer;->getMeasurer()Landroidx/constraintlayout/solver/widgets/analyzer/BasicMeasure$Measurer;
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidgetContainer;->getOptimizationLevel()I
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidgetContainer;->invalidateGraph()V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidgetContainer;->invalidateMeasures()V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidgetContainer;->isHeightMeasuredTooSmall()Z
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidgetContainer;->isWidthMeasuredTooSmall()Z
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidgetContainer;->layout()V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidgetContainer;->measure(IIIIIIIII)J
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidgetContainer;->optimizeFor(I)Z
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidgetContainer;->resetChains()V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidgetContainer;->setMeasurer(Landroidx/constraintlayout/solver/widgets/analyzer/BasicMeasure$Measurer;)V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidgetContainer;->setOptimizationLevel(I)V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidgetContainer;->setRtl(Z)V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidgetContainer;->updateChildrenFromSolver(Landroidx/constraintlayout/solver/LinearSystem;[Z)V
-HSPLandroidx/constraintlayout/solver/widgets/ConstraintWidgetContainer;->updateHierarchy()V
-HSPLandroidx/constraintlayout/solver/widgets/Optimizer;-><clinit>()V
-HSPLandroidx/constraintlayout/solver/widgets/Optimizer;->checkMatchParent(Landroidx/constraintlayout/solver/widgets/ConstraintWidgetContainer;Landroidx/constraintlayout/solver/LinearSystem;Landroidx/constraintlayout/solver/widgets/ConstraintWidget;)V
-HSPLandroidx/constraintlayout/solver/widgets/Optimizer;->enabled(II)Z
-HSPLandroidx/constraintlayout/solver/widgets/WidgetContainer;-><init>()V
-HSPLandroidx/constraintlayout/solver/widgets/WidgetContainer;->add(Landroidx/constraintlayout/solver/widgets/ConstraintWidget;)V
-HSPLandroidx/constraintlayout/solver/widgets/WidgetContainer;->removeAllChildren()V
-HSPLandroidx/constraintlayout/solver/widgets/WidgetContainer;->resetSolverVariables(Landroidx/constraintlayout/solver/Cache;)V
-HSPLandroidx/constraintlayout/solver/widgets/analyzer/BasicMeasure$Measure;-><init>()V
-HSPLandroidx/constraintlayout/solver/widgets/analyzer/BasicMeasure;-><init>(Landroidx/constraintlayout/solver/widgets/ConstraintWidgetContainer;)V
-HSPLandroidx/constraintlayout/solver/widgets/analyzer/BasicMeasure;->measure(Landroidx/constraintlayout/solver/widgets/analyzer/BasicMeasure$Measurer;Landroidx/constraintlayout/solver/widgets/ConstraintWidget;Z)Z
-HSPLandroidx/constraintlayout/solver/widgets/analyzer/BasicMeasure;->measureChildren(Landroidx/constraintlayout/solver/widgets/ConstraintWidgetContainer;)V
-HSPLandroidx/constraintlayout/solver/widgets/analyzer/BasicMeasure;->solveLinearSystem(Landroidx/constraintlayout/solver/widgets/ConstraintWidgetContainer;Ljava/lang/String;II)V
-HSPLandroidx/constraintlayout/solver/widgets/analyzer/BasicMeasure;->solverMeasure(Landroidx/constraintlayout/solver/widgets/ConstraintWidgetContainer;IIIIIIIII)J
-HSPLandroidx/constraintlayout/solver/widgets/analyzer/BasicMeasure;->updateHierarchy(Landroidx/constraintlayout/solver/widgets/ConstraintWidgetContainer;)V
-HSPLandroidx/constraintlayout/solver/widgets/analyzer/DependencyGraph;-><init>(Landroidx/constraintlayout/solver/widgets/ConstraintWidgetContainer;)V
-HSPLandroidx/constraintlayout/solver/widgets/analyzer/DependencyGraph;->invalidateGraph()V
-HSPLandroidx/constraintlayout/solver/widgets/analyzer/DependencyGraph;->invalidateMeasures()V
-HSPLandroidx/constraintlayout/solver/widgets/analyzer/DependencyGraph;->setMeasurer(Landroidx/constraintlayout/solver/widgets/analyzer/BasicMeasure$Measurer;)V
-HSPLandroidx/constraintlayout/solver/widgets/analyzer/DependencyNode$Type;-><clinit>()V
-HSPLandroidx/constraintlayout/solver/widgets/analyzer/DependencyNode$Type;-><init>(Ljava/lang/String;I)V
-HSPLandroidx/constraintlayout/solver/widgets/analyzer/DependencyNode;-><init>(Landroidx/constraintlayout/solver/widgets/analyzer/WidgetRun;)V
-HSPLandroidx/constraintlayout/solver/widgets/analyzer/DimensionDependency;-><init>(Landroidx/constraintlayout/solver/widgets/analyzer/WidgetRun;)V
-HSPLandroidx/constraintlayout/solver/widgets/analyzer/HorizontalWidgetRun;-><clinit>()V
-HSPLandroidx/constraintlayout/solver/widgets/analyzer/HorizontalWidgetRun;-><init>(Landroidx/constraintlayout/solver/widgets/ConstraintWidget;)V
-HSPLandroidx/constraintlayout/solver/widgets/analyzer/VerticalWidgetRun;-><init>(Landroidx/constraintlayout/solver/widgets/ConstraintWidget;)V
-HSPLandroidx/constraintlayout/solver/widgets/analyzer/WidgetRun$RunType;-><clinit>()V
-HSPLandroidx/constraintlayout/solver/widgets/analyzer/WidgetRun$RunType;-><init>(Ljava/lang/String;I)V
-HSPLandroidx/constraintlayout/solver/widgets/analyzer/WidgetRun;-><init>(Landroidx/constraintlayout/solver/widgets/ConstraintWidget;)V
-HSPLandroidx/constraintlayout/widget/ConstraintLayout$1;-><clinit>()V
-HSPLandroidx/constraintlayout/widget/ConstraintLayout$LayoutParams$Table;-><clinit>()V
-HSPLandroidx/constraintlayout/widget/ConstraintLayout$LayoutParams;-><init>(Landroid/content/Context;Landroid/util/AttributeSet;)V
-HSPLandroidx/constraintlayout/widget/ConstraintLayout$LayoutParams;->resolveLayoutDirection(I)V
-HSPLandroidx/constraintlayout/widget/ConstraintLayout$LayoutParams;->validate()V
-HSPLandroidx/constraintlayout/widget/ConstraintLayout$Measurer;-><init>(Landroidx/constraintlayout/widget/ConstraintLayout;Landroidx/constraintlayout/widget/ConstraintLayout;)V
-HSPLandroidx/constraintlayout/widget/ConstraintLayout$Measurer;->captureLayoutInfos(IIIIII)V
-HSPLandroidx/constraintlayout/widget/ConstraintLayout$Measurer;->didMeasures()V
-HSPLandroidx/constraintlayout/widget/ConstraintLayout$Measurer;->measure(Landroidx/constraintlayout/solver/widgets/ConstraintWidget;Landroidx/constraintlayout/solver/widgets/analyzer/BasicMeasure$Measure;)V
-HSPLandroidx/constraintlayout/widget/ConstraintLayout;-><init>(Landroid/content/Context;Landroid/util/AttributeSet;)V
-HSPLandroidx/constraintlayout/widget/ConstraintLayout;->access$000(Landroidx/constraintlayout/widget/ConstraintLayout;)Ljava/util/ArrayList;
-HSPLandroidx/constraintlayout/widget/ConstraintLayout;->addView(Landroid/view/View;ILandroid/view/ViewGroup$LayoutParams;)V
-HSPLandroidx/constraintlayout/widget/ConstraintLayout;->applyConstraintsFromLayoutParams(ZLandroid/view/View;Landroidx/constraintlayout/solver/widgets/ConstraintWidget;Landroidx/constraintlayout/widget/ConstraintLayout$LayoutParams;Landroid/util/SparseArray;)V
-HSPLandroidx/constraintlayout/widget/ConstraintLayout;->checkLayoutParams(Landroid/view/ViewGroup$LayoutParams;)Z
-HSPLandroidx/constraintlayout/widget/ConstraintLayout;->dispatchDraw(Landroid/graphics/Canvas;)V
-HSPLandroidx/constraintlayout/widget/ConstraintLayout;->generateLayoutParams(Landroid/util/AttributeSet;)Landroid/view/ViewGroup$LayoutParams;
-HSPLandroidx/constraintlayout/widget/ConstraintLayout;->generateLayoutParams(Landroid/util/AttributeSet;)Landroidx/constraintlayout/widget/ConstraintLayout$LayoutParams;
-HSPLandroidx/constraintlayout/widget/ConstraintLayout;->getPaddingWidth()I
-HSPLandroidx/constraintlayout/widget/ConstraintLayout;->getViewWidget(Landroid/view/View;)Landroidx/constraintlayout/solver/widgets/ConstraintWidget;
-HSPLandroidx/constraintlayout/widget/ConstraintLayout;->init(Landroid/util/AttributeSet;II)V
-HSPLandroidx/constraintlayout/widget/ConstraintLayout;->isRtl()Z
-HSPLandroidx/constraintlayout/widget/ConstraintLayout;->markHierarchyDirty()V
-HSPLandroidx/constraintlayout/widget/ConstraintLayout;->onLayout(ZIIII)V
-HSPLandroidx/constraintlayout/widget/ConstraintLayout;->onMeasure(II)V
-HSPLandroidx/constraintlayout/widget/ConstraintLayout;->onViewAdded(Landroid/view/View;)V
-HSPLandroidx/constraintlayout/widget/ConstraintLayout;->requestLayout()V
-HSPLandroidx/constraintlayout/widget/ConstraintLayout;->resolveMeasuredDimension(IIIIZZ)V
-HSPLandroidx/constraintlayout/widget/ConstraintLayout;->resolveSystem(Landroidx/constraintlayout/solver/widgets/ConstraintWidgetContainer;III)V
-HSPLandroidx/constraintlayout/widget/ConstraintLayout;->setChildrenConstraints()V
-HSPLandroidx/constraintlayout/widget/ConstraintLayout;->setSelfDimensionBehaviour(Landroidx/constraintlayout/solver/widgets/ConstraintWidgetContainer;IIII)V
-HSPLandroidx/constraintlayout/widget/ConstraintLayout;->updateHierarchy()Z
-HSPLandroidx/constraintlayout/widget/R$styleable;-><clinit>()V
-HSPLandroidx/core/app/CoreComponentFactory;-><init>()V
-HSPLandroidx/core/app/CoreComponentFactory;->checkCompatWrapper(Ljava/lang/Object;)Ljava/lang/Object;
-HSPLandroidx/core/app/CoreComponentFactory;->instantiateActivity(Ljava/lang/ClassLoader;Ljava/lang/String;Landroid/content/Intent;)Landroid/app/Activity;
-HSPLandroidx/core/app/CoreComponentFactory;->instantiateApplication(Ljava/lang/ClassLoader;Ljava/lang/String;)Landroid/app/Application;
-Landroidx/benchmark/integration/baselineprofiles/library/buildprovider/EmptyActivity;
-Landroidx/constraintlayout/solver/ArrayLinkedVariables;
-Landroidx/constraintlayout/solver/ArrayRow$ArrayRowVariables;
-Landroidx/constraintlayout/solver/ArrayRow;
-Landroidx/constraintlayout/solver/Cache;
-Landroidx/constraintlayout/solver/LinearSystem$Row;
-Landroidx/constraintlayout/solver/LinearSystem$ValuesRow;
-Landroidx/constraintlayout/solver/LinearSystem;
-Landroidx/constraintlayout/solver/Pools$Pool;
-Landroidx/constraintlayout/solver/Pools$SimplePool;
-Landroidx/constraintlayout/solver/PriorityGoalRow$GoalVariableAccessor;
-Landroidx/constraintlayout/solver/PriorityGoalRow;
-Landroidx/constraintlayout/solver/SolverVariable$Type;
-Landroidx/constraintlayout/solver/SolverVariable;
-Landroidx/constraintlayout/solver/SolverVariableValues;
-Landroidx/constraintlayout/solver/widgets/Barrier;
-Landroidx/constraintlayout/solver/widgets/ChainHead;
-Landroidx/constraintlayout/solver/widgets/ConstraintAnchor$Type;
-Landroidx/constraintlayout/solver/widgets/ConstraintAnchor;
-Landroidx/constraintlayout/solver/widgets/ConstraintWidget$1;
-Landroidx/constraintlayout/solver/widgets/ConstraintWidget$DimensionBehaviour;
-Landroidx/constraintlayout/solver/widgets/ConstraintWidget;
-Landroidx/constraintlayout/solver/widgets/ConstraintWidgetContainer;
-Landroidx/constraintlayout/solver/widgets/Guideline;
-Landroidx/constraintlayout/solver/widgets/Helper;
-Landroidx/constraintlayout/solver/widgets/HelperWidget;
-Landroidx/constraintlayout/solver/widgets/Optimizer;
-Landroidx/constraintlayout/solver/widgets/VirtualLayout;
-Landroidx/constraintlayout/solver/widgets/WidgetContainer;
-Landroidx/constraintlayout/solver/widgets/analyzer/BasicMeasure$Measure;
-Landroidx/constraintlayout/solver/widgets/analyzer/BasicMeasure$Measurer;
-Landroidx/constraintlayout/solver/widgets/analyzer/BasicMeasure;
-Landroidx/constraintlayout/solver/widgets/analyzer/Dependency;
-Landroidx/constraintlayout/solver/widgets/analyzer/DependencyGraph;
-Landroidx/constraintlayout/solver/widgets/analyzer/DependencyNode$Type;
-Landroidx/constraintlayout/solver/widgets/analyzer/DependencyNode;
-Landroidx/constraintlayout/solver/widgets/analyzer/DimensionDependency;
-Landroidx/constraintlayout/solver/widgets/analyzer/HorizontalWidgetRun;
-Landroidx/constraintlayout/solver/widgets/analyzer/VerticalWidgetRun;
-Landroidx/constraintlayout/solver/widgets/analyzer/WidgetRun$RunType;
-Landroidx/constraintlayout/solver/widgets/analyzer/WidgetRun;
-Landroidx/constraintlayout/widget/ConstraintHelper;
-Landroidx/constraintlayout/widget/ConstraintLayout$1;
-Landroidx/constraintlayout/widget/ConstraintLayout$LayoutParams$Table;
-Landroidx/constraintlayout/widget/ConstraintLayout$LayoutParams;
-Landroidx/constraintlayout/widget/ConstraintLayout$Measurer;
-Landroidx/constraintlayout/widget/ConstraintLayout;
-Landroidx/constraintlayout/widget/Guideline;
-Landroidx/constraintlayout/widget/Placeholder;
-Landroidx/constraintlayout/widget/R$styleable;
-Landroidx/constraintlayout/widget/VirtualLayout;
-Landroidx/core/app/CoreComponentFactory$CompatWrapped;
-Landroidx/core/app/CoreComponentFactory;
\ No newline at end of file
diff --git a/benchmark/integration-tests/baselineprofiles-library-consumer/src/main/generated/baselineProfiles/expected-baseline-prof.txt b/benchmark/integration-tests/baselineprofiles-library-consumer/src/main/generated/baselineProfiles/expected-baseline-prof.txt
new file mode 100644
index 0000000..0013a0f
--- /dev/null
+++ b/benchmark/integration-tests/baselineprofiles-library-consumer/src/main/generated/baselineProfiles/expected-baseline-prof.txt
@@ -0,0 +1,5 @@
+Landroidx/benchmark/integration/baselineprofiles/library/consumer/IncludeClass;
+HSPLandroidx/benchmark/integration/baselineprofiles/library/consumer/IncludeClass;-><init>()V
+HSPLandroidx/benchmark/integration/baselineprofiles/library/consumer/IncludeClass;->doSomething()V
+HSPLandroidx/benchmark/integration/baselineprofiles/library/consumer/IncludeClass;->doSomethingWithArgument(Ljava/lang/String;)V
+HSPLandroidx/benchmark/integration/baselineprofiles/library/consumer/IncludeClass;->doSomethingWithReturnType(II)I
\ No newline at end of file
diff --git a/benchmark/integration-tests/baselineprofiles-library-consumer/src/main/java/androidx/benchmark/integration/baselineprofiles/library/consumer/IncludeClass.kt b/benchmark/integration-tests/baselineprofiles-library-consumer/src/main/java/androidx/benchmark/integration/baselineprofiles/library/consumer/IncludeClass.kt
new file mode 100644
index 0000000..7e9be83
--- /dev/null
+++ b/benchmark/integration-tests/baselineprofiles-library-consumer/src/main/java/androidx/benchmark/integration/baselineprofiles/library/consumer/IncludeClass.kt
@@ -0,0 +1,32 @@
+/*
+ * Copyright 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.benchmark.integration.baselineprofiles.library.consumer
+
+import android.util.Log
+
+class IncludeClass {
+
+    fun doSomething() {
+        Log.d("IncludeClass", "Done.")
+    }
+
+    fun doSomethingWithArgument(what: String) {
+        Log.d("IncludeClass", "Done $what")
+    }
+
+    fun doSomethingWithReturnType(a: Int, b: Int) = a + b
+}
diff --git a/appactions/interaction/interaction-service/src/main/java/androidx/appactions/interaction/service/proto/package-info.java b/benchmark/integration-tests/baselineprofiles-library-consumer/src/main/java/androidx/benchmark/integration/baselineprofiles/library/consumer/exclude/ExcludeClass.kt
similarity index 63%
copy from appactions/interaction/interaction-service/src/main/java/androidx/appactions/interaction/service/proto/package-info.java
copy to benchmark/integration-tests/baselineprofiles-library-consumer/src/main/java/androidx/benchmark/integration/baselineprofiles/library/consumer/exclude/ExcludeClass.kt
index 93db9d1..033254d 100644
--- a/appactions/interaction/interaction-service/src/main/java/androidx/appactions/interaction/service/proto/package-info.java
+++ b/benchmark/integration-tests/baselineprofiles-library-consumer/src/main/java/androidx/benchmark/integration/baselineprofiles/library/consumer/exclude/ExcludeClass.kt
@@ -1,5 +1,5 @@
 /*
- * Copyright 2023 The Android Open Source Project
+ * Copyright 2022 The Android Open Source Project
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -14,8 +14,17 @@
  * limitations under the License.
  */
 
-/** @hide */
-@RestrictTo(RestrictTo.Scope.LIBRARY)
-package androidx.appactions.interaction.service.proto;
+package androidx.benchmark.integration.baselineprofiles.library.consumer.exclude
 
-import androidx.annotation.RestrictTo;
+import android.util.Log
+
+class ExcludeClass {
+
+    companion object {
+        private const val TAG = "ExcludeClass"
+    }
+
+    fun doSomething() {
+        Log.d(TAG, "Done.")
+    }
+}
diff --git a/benchmark/integration-tests/baselineprofiles-library-producer/build.gradle b/benchmark/integration-tests/baselineprofiles-library-producer/build.gradle
index c797473..9aaaa2a0 100644
--- a/benchmark/integration-tests/baselineprofiles-library-producer/build.gradle
+++ b/benchmark/integration-tests/baselineprofiles-library-producer/build.gradle
@@ -51,8 +51,8 @@
 }
 
 baselineProfilesProfileProducer {
-    managedDevices += "pixel6Api31"
-    useConnectedDevices = false
+    enableEmulatorDisplay = true
+    useConnectedDevices = true
 }
 
 androidx {
diff --git a/benchmark/integration-tests/baselineprofiles-producer/build.gradle b/benchmark/integration-tests/baselineprofiles-producer/build.gradle
index c49546c..9719631 100644
--- a/benchmark/integration-tests/baselineprofiles-producer/build.gradle
+++ b/benchmark/integration-tests/baselineprofiles-producer/build.gradle
@@ -51,8 +51,8 @@
 }
 
 baselineProfilesProfileProducer {
-    managedDevices += "pixel6Api31"
-    useConnectedDevices = false
+    enableEmulatorDisplay = true
+    useConnectedDevices = true
 }
 
 androidx {
diff --git a/benchmark/integration-tests/baselineprofiles-test-utils/utils.gradle b/benchmark/integration-tests/baselineprofiles-test-utils/utils.gradle
index 4a0a8dd..2f34b05 100644
--- a/benchmark/integration-tests/baselineprofiles-test-utils/utils.gradle
+++ b/benchmark/integration-tests/baselineprofiles-test-utils/utils.gradle
@@ -1,26 +1,104 @@
+import org.gradle.work.DisableCachingByDefault
+
+import static androidx.baselineprofiles.gradle.utils.UtilsKt.camelCase
+
 // To trigger the baseline profile generation using the different modules the test will call
 // the base generation task `generateBaselineProfiles`. The `AssertEqualsAndCleanUpTask` asserts
 // that the final output is the expected one and if there are no failures cleans up the
 // generated baseline-prof.txt.
-@CacheableTask
+@DisableCachingByDefault(because = "Integration test task")
 abstract class AssertEqualsAndCleanUpTask extends DefaultTask {
+
     @InputFile
     @PathSensitive(PathSensitivity.NONE)
     abstract RegularFileProperty getExpectedFile()
 
-    @InputFile
-    @PathSensitive(PathSensitivity.NONE)
-    abstract RegularFileProperty getActualFile()
+    // This property is passed as path and not as a file because it might not exist
+    @Input
+    abstract Property<String> getActualFilePath()
 
     @TaskAction
     void exec() {
-        assert getExpectedFile().get().asFile.text == getActualFile().get().asFile.text
+
+        File actualFile = new File(actualFilePath.get())
+        if (!actualFile.exists()) {
+            throw new GradleException(
+                    "A baseline profile was expected in ${actualFile.absolutePath}."
+            )
+        }
+
+        def expectedIter = getExpectedFile().get().asFile.text.lines().iterator()
+        def actualIter = actualFile.text.lines().iterator()
+
+        def lineCounter = 0
+        def diff = new ArrayList<String>()
+        while (expectedIter.hasNext() || actualIter.hasNext()) {
+            def expected = expectedIter.hasNext() ? expectedIter.next() : ""
+            def actual = actualIter.hasNext() ? actualIter.next() : ""
+            if (expected != actual) {
+                diff.add("Line: $lineCounter, Expected: `$expected`, Actual: `$actual`")
+            }
+            lineCounter++
+        }
+
+        if (!diff.isEmpty()) {
+            logger.error("Actual generated baseline profile differs from expected one: \n\t"
+                    + diff.join("\n\t"))
+            throw new GradleException(
+                    "Actual generated baseline profile differs from expected one."
+            )
+        }
 
         // This deletes the actual file since it's a test artifact
-        getActualFile().get().asFile.delete()
+        actualFile.delete()
     }
 }
-tasks.register("testBaselineProfilesGeneration", AssertEqualsAndCleanUpTask).configure {
-    it.expectedFile.set(project.layout.projectDirectory.file("src/main/expected-baseline-prof.txt"))
-    it.actualFile.set(tasks.named("generateBaselineProfiles").flatMap { it.baselineProfileFile })
+
+// For each variant we expect `expected-baseline-prof.txt` and `baseline-prof.txt` to be
+// present and have the same content.
+def testTaskProviders = []
+
+def registerAssertTask(ArrayList<TaskProvider<Task>> testTaskProviders, String variantName) {
+
+    def expectedFile = project
+            .layout
+            .projectDirectory
+            .file("src/$variantName/generated/baselineProfiles/expected-baseline-prof.txt")
+
+    // If there is no expected file then skip testing this variant.
+    if (!expectedFile.asFile.exists()) {
+        return
+    }
+
+    def taskProvider = project.tasks.register(
+            camelCase("test", variantName, "baselineProfilesGeneration"),
+            AssertEqualsAndCleanUpTask
+    ) {
+        it.expectedFile.set(expectedFile)
+        it.actualFilePath.set(project
+                .layout
+                .projectDirectory
+                .file("src/$variantName/generated/baselineProfiles/baseline-prof.txt")
+                .getAsFile()
+                .absolutePath)
+
+        // Depend on the main profile generation task
+        it.dependsOn(tasks.named(camelCase("generate", variantName, "baselineProfiles")))
+    }
+
+    testTaskProviders.add(taskProvider)
+}
+
+// An assert task is defined per variant
+androidComponents {
+    onVariants(selector().all()) { variant ->
+        registerAssertTask(testTaskProviders, variant.name)
+    }
+}
+
+// Ensures that calling `testBaselineProfilesGeneration` runs all the test tasks
+afterEvaluate {
+    tasks.register("testBaselineProfilesGeneration").configure {
+        it.dependsOn(testTaskProviders)
+    }
 }
diff --git a/bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/MainActivity.kt b/bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/MainActivity.kt
index b177d15..634a638 100644
--- a/bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/MainActivity.kt
+++ b/bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/MainActivity.kt
@@ -25,8 +25,6 @@
 import androidx.navigation.findNavController
 import androidx.navigation.ui.AppBarConfiguration
 import androidx.navigation.ui.setupActionBarWithNavController
-import androidx.navigation.ui.setupWithNavController
-import com.google.android.material.bottomnavigation.BottomNavigationView
 
 class MainActivity : AppCompatActivity() {
 
@@ -49,14 +47,11 @@
         binding = ActivityMainBinding.inflate(layoutInflater)
         setContentView(binding.root)
 
-        val navView: BottomNavigationView = binding.navView
-
         val navController = findNavController(R.id.nav_host_fragment_activity_main)
         val appBarConfiguration = AppBarConfiguration(
-            setOf(R.id.navigation_fwk, R.id.navigation_btx)
+            setOf(R.id.navigation_home)
         )
         setupActionBarWithNavController(navController, appBarConfiguration)
-        navView.setupWithNavController(navController)
     }
 
     override fun onResume() {
diff --git a/appactions/interaction/interaction-service/src/main/java/androidx/appactions/interaction/service/proto/package-info.java b/bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/data/SampleAdvertiseData.kt
similarity index 73%
copy from appactions/interaction/interaction-service/src/main/java/androidx/appactions/interaction/service/proto/package-info.java
copy to bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/data/SampleAdvertiseData.kt
index 93db9d1..84d9567 100644
--- a/appactions/interaction/interaction-service/src/main/java/androidx/appactions/interaction/service/proto/package-info.java
+++ b/bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/data/SampleAdvertiseData.kt
@@ -14,8 +14,11 @@
  * limitations under the License.
  */
 
-/** @hide */
-@RestrictTo(RestrictTo.Scope.LIBRARY)
-package androidx.appactions.interaction.service.proto;
+package androidx.bluetooth.integration.testapp.data
 
-import androidx.annotation.RestrictTo;
+import android.os.ParcelUuid
+
+object SampleAdvertiseData {
+    val testUUID: ParcelUuid =
+        ParcelUuid.fromString("00000000-0000-0000-0000-000000000001")
+}
diff --git a/bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/ui/framework/FwkFragment.kt b/bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/ui/framework/FwkFragment.kt
deleted file mode 100644
index 42431d7..0000000
--- a/bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/ui/framework/FwkFragment.kt
+++ /dev/null
@@ -1,378 +0,0 @@
-/*
- * Copyright 2022 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package androidx.bluetooth.integration.testapp.ui.framework
-
-import android.annotation.SuppressLint
-import android.bluetooth.BluetoothDevice
-import android.bluetooth.BluetoothGattCharacteristic
-import android.bluetooth.BluetoothGattDescriptor
-import android.bluetooth.BluetoothGattServer
-import android.bluetooth.BluetoothGattServerCallback
-import android.bluetooth.BluetoothGattService
-import android.bluetooth.BluetoothManager
-import android.bluetooth.le.AdvertiseCallback
-import android.bluetooth.le.AdvertiseData
-import android.bluetooth.le.AdvertiseSettings
-import android.bluetooth.le.ScanCallback
-import android.bluetooth.le.ScanResult
-import android.bluetooth.le.ScanSettings
-import android.content.Context
-import android.os.Bundle
-import android.os.ParcelUuid
-import android.util.Log
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
-import android.widget.Toast
-import androidx.bluetooth.integration.testapp.R
-import androidx.bluetooth.integration.testapp.databinding.FragmentFwkBinding
-import androidx.bluetooth.integration.testapp.ui.common.ScanResultAdapter
-import androidx.fragment.app.Fragment
-import androidx.lifecycle.ViewModelProvider
-
-class FwkFragment : Fragment() {
-
-    companion object {
-        const val TAG = "FwkFragment"
-        val ServiceUUID: ParcelUuid = ParcelUuid.fromString("0000b81d-0000-1000-8000-00805f9b34fb")
-    }
-
-    private var scanResultAdapter: ScanResultAdapter? = null
-
-    private val scanCallback = object : ScanCallback() {
-        override fun onScanResult(callbackType: Int, result: ScanResult) {
-            Log.d(TAG, "onScanResult() called with: callbackType = $callbackType, result = $result")
-
-            fwkViewModel.scanResults[result.device.address] = result
-            scanResultAdapter?.submitList(fwkViewModel.scanResults.values.toMutableList())
-            scanResultAdapter?.notifyItemInserted(fwkViewModel.scanResults.size)
-        }
-
-        override fun onBatchScanResults(results: MutableList<ScanResult>) {
-            Log.d(TAG, "onBatchScanResults() called with: results = $results")
-        }
-
-        override fun onScanFailed(errorCode: Int) {
-            Log.d(TAG, "onScanFailed() called with: errorCode = $errorCode")
-        }
-    }
-
-    private val advertiseCallback = object : AdvertiseCallback() {
-        override fun onStartFailure(errorCode: Int) {
-            Log.d(TAG, "onStartFailure() called with: errorCode = $errorCode")
-        }
-
-        override fun onStartSuccess(settingsInEffect: AdvertiseSettings?) {
-            Log.d(TAG, "onStartSuccess() called")
-        }
-    }
-
-    private var isScanning = false
-
-    private lateinit var fwkViewModel: FwkViewModel
-
-    private var _binding: FragmentFwkBinding? = null
-
-    // This property is only valid between onCreateView and onDestroyView.
-    private val binding get() = _binding!!
-
-    override fun onCreateView(
-        inflater: LayoutInflater,
-        container: ViewGroup?,
-        savedInstanceState: Bundle?
-    ): View {
-        Log.d(
-            TAG, "onCreateView() called with: inflater = $inflater, " +
-                "container = $container, savedInstanceState = $savedInstanceState"
-        )
-        fwkViewModel = ViewModelProvider(this)[FwkViewModel::class.java]
-
-        _binding = FragmentFwkBinding.inflate(inflater, container, false)
-        return binding.root
-    }
-
-    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
-        super.onViewCreated(view, savedInstanceState)
-
-        scanResultAdapter = ScanResultAdapter { scanResult -> scanResultOnClick(scanResult) }
-        binding.recyclerView.adapter = scanResultAdapter
-
-        binding.buttonScan.setOnClickListener {
-            if (isScanning) stopScan()
-            else startScan()
-        }
-
-        binding.switchAdvertise.setOnCheckedChangeListener { _, isChecked ->
-            if (isChecked) startAdvertise()
-            else stopAdvertise()
-        }
-
-        binding.switchGattServer.setOnCheckedChangeListener { _, isChecked ->
-            if (isChecked) openGattServer()
-            else closeGattServer()
-        }
-    }
-
-    // Permissions are handled by MainActivity requestBluetoothPermissions
-    @SuppressLint("MissingPermission")
-    override fun onDestroyView() {
-        super.onDestroyView()
-        _binding = null
-        stopScan()
-        bluetoothGattServer?.close()
-    }
-
-    // Permissions are handled by MainActivity requestBluetoothPermissions
-    @SuppressLint("MissingPermission")
-    private fun startScan() {
-        Log.d(TAG, "startScan() called")
-
-        val bluetoothManager =
-            context?.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager
-
-        val bluetoothAdapter = bluetoothManager?.adapter
-
-        val bleScanner = bluetoothAdapter?.bluetoothLeScanner
-
-        val scanSettings = ScanSettings.Builder()
-            .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
-            .build()
-
-        bleScanner?.startScan(null, scanSettings, scanCallback)
-
-        isScanning = true
-        binding.buttonScan.text = getString(R.string.stop_scanning)
-
-        Toast.makeText(context, getString(R.string.scan_start_message), Toast.LENGTH_SHORT)
-            .show()
-    }
-
-    // Permissions are handled by MainActivity requestBluetoothPermissions
-    @SuppressLint("MissingPermission")
-    private fun stopScan() {
-        Log.d(TAG, "stopScan() called")
-
-        val bluetoothManager =
-            context?.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager
-
-        val bluetoothAdapter = bluetoothManager?.adapter
-
-        val bleScanner = bluetoothAdapter?.bluetoothLeScanner
-
-        bleScanner?.stopScan(scanCallback)
-
-        isScanning = false
-        _binding?.buttonScan?.text = getString(R.string.scan_using_fwk)
-    }
-
-    private fun scanResultOnClick(scanResult: ScanResult) {
-        Log.d(TAG, "scanResultOnClick() called with: scanResult = $scanResult")
-    }
-
-    // Permissions are handled by MainActivity requestBluetoothPermissions
-    @SuppressLint("MissingPermission")
-    private fun startAdvertise() {
-        Log.d(TAG, "startAdvertise() called")
-
-        val bluetoothManager =
-            context?.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager
-
-        val bluetoothAdapter = bluetoothManager?.adapter
-
-        val bleAdvertiser = bluetoothAdapter?.bluetoothLeAdvertiser
-
-        val advertiseSettings = AdvertiseSettings.Builder()
-            .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY)
-            .setTimeout(0)
-            .build()
-
-        val advertiseData = AdvertiseData.Builder()
-            .addServiceUuid(ServiceUUID)
-            .setIncludeDeviceName(true)
-            .build()
-
-        bleAdvertiser?.startAdvertising(advertiseSettings, advertiseData, advertiseCallback)
-
-        Toast.makeText(context, getString(R.string.advertise_start_message), Toast.LENGTH_SHORT)
-            .show()
-    }
-
-    // Permissions are handled by MainActivity requestBluetoothPermissions
-    @SuppressLint("MissingPermission")
-    private fun stopAdvertise() {
-        Log.d(TAG, "stopAdvertise() called")
-
-        val bluetoothManager =
-            context?.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager
-
-        val bluetoothAdapter = bluetoothManager?.adapter
-
-        val bleAdvertiser = bluetoothAdapter?.bluetoothLeAdvertiser
-
-        bleAdvertiser?.stopAdvertising(advertiseCallback)
-    }
-
-    private var bluetoothGattServer: BluetoothGattServer? = null
-
-    // Permissions are handled by MainActivity requestBluetoothPermissions
-    @SuppressLint("MissingPermission")
-    private fun openGattServer() {
-        Log.d(TAG, "openGattServer() called")
-
-        val bluetoothManager =
-            context?.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager
-
-        bluetoothGattServer = bluetoothManager?.openGattServer(
-            requireContext(),
-            object : BluetoothGattServerCallback() {
-                override fun onConnectionStateChange(
-                    device: BluetoothDevice?,
-                    status: Int,
-                    newState: Int
-                ) {
-                    Log.d(
-                        TAG,
-                        "onConnectionStateChange() called with: device = $device" +
-                            ", status = $status, newState = $newState"
-                    )
-                }
-
-                override fun onServiceAdded(status: Int, service: BluetoothGattService?) {
-                    Log.d(TAG, "onServiceAdded() called with: status = $status, service = $service")
-                }
-
-                override fun onCharacteristicReadRequest(
-                    device: BluetoothDevice?,
-                    requestId: Int,
-                    offset: Int,
-                    characteristic: BluetoothGattCharacteristic?
-                ) {
-                    Log.d(
-                        TAG,
-                        "onCharacteristicReadRequest() called with: device = $device" +
-                            ", requestId = $requestId, offset = $offset" +
-                            ", characteristic = $characteristic"
-                    )
-                }
-
-                override fun onCharacteristicWriteRequest(
-                    device: BluetoothDevice?,
-                    requestId: Int,
-                    characteristic: BluetoothGattCharacteristic?,
-                    preparedWrite: Boolean,
-                    responseNeeded: Boolean,
-                    offset: Int,
-                    value: ByteArray?
-                ) {
-                    Log.d(
-                        TAG,
-                        "onCharacteristicWriteRequest() called with: device = $device" +
-                            ", requestId = $requestId, characteristic = $characteristic" +
-                            ", preparedWrite = $preparedWrite, responseNeeded = $responseNeeded" +
-                            ", offset = $offset, value = $value"
-                    )
-                }
-
-                override fun onDescriptorReadRequest(
-                    device: BluetoothDevice?,
-                    requestId: Int,
-                    offset: Int,
-                    descriptor: BluetoothGattDescriptor?
-                ) {
-                    Log.d(
-                        TAG,
-                        "onDescriptorReadRequest() called with: device = $device" +
-                            ", requestId = $requestId, offset = $offset, descriptor = $descriptor"
-                    )
-                }
-
-                override fun onDescriptorWriteRequest(
-                    device: BluetoothDevice?,
-                    requestId: Int,
-                    descriptor: BluetoothGattDescriptor?,
-                    preparedWrite: Boolean,
-                    responseNeeded: Boolean,
-                    offset: Int,
-                    value: ByteArray?
-                ) {
-                    Log.d(
-                        TAG,
-                        "onDescriptorWriteRequest() called with: device = $device" +
-                            ", requestId = $requestId, descriptor = $descriptor" +
-                            ", preparedWrite = $preparedWrite, responseNeeded = $responseNeeded" +
-                            ", offset = $offset, value = $value"
-                    )
-                }
-
-                override fun onExecuteWrite(
-                    device: BluetoothDevice?,
-                    requestId: Int,
-                    execute: Boolean
-                ) {
-                    Log.d(
-                        TAG,
-                        "onExecuteWrite() called with: device = $device, requestId = $requestId" +
-                            ", execute = $execute"
-                    )
-                }
-
-                override fun onNotificationSent(device: BluetoothDevice?, status: Int) {
-                    Log.d(
-                        TAG,
-                        "onNotificationSent() called with: device = $device, status = $status"
-                    )
-                }
-
-                override fun onMtuChanged(device: BluetoothDevice?, mtu: Int) {
-                    Log.d(TAG, "onMtuChanged() called with: device = $device, mtu = $mtu")
-                }
-
-                override fun onPhyUpdate(
-                    device: BluetoothDevice?,
-                    txPhy: Int,
-                    rxPhy: Int,
-                    status: Int
-                ) {
-                    Log.d(
-                        TAG, "onPhyUpdate() called with: device = $device, txPhy = $txPhy" +
-                            ", rxPhy = $rxPhy, status = $status"
-                    )
-                }
-
-                override fun onPhyRead(
-                    device: BluetoothDevice?,
-                    txPhy: Int,
-                    rxPhy: Int,
-                    status: Int
-                ) {
-                    Log.d(
-                        TAG,
-                        "onPhyRead() called with: device = $device, txPhy = $txPhy" +
-                            ", rxPhy = $rxPhy, status = $status"
-                    )
-                }
-            })
-    }
-
-    // Permissions are handled by MainActivity requestBluetoothPermissions
-    @SuppressLint("MissingPermission")
-    private fun closeGattServer() {
-        Log.d(TAG, "closeGattServer() called")
-
-        bluetoothGattServer?.close()
-    }
-}
diff --git a/bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/ui/framework/FwkViewModel.kt b/bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/ui/framework/FwkViewModel.kt
deleted file mode 100644
index 855f208..0000000
--- a/bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/ui/framework/FwkViewModel.kt
+++ /dev/null
@@ -1,47 +0,0 @@
-/*
- * Copyright 2023 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package androidx.bluetooth.integration.testapp.ui.framework
-
-import android.bluetooth.le.ScanResult
-import android.util.Log
-import androidx.lifecycle.ViewModel
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.SupervisorJob
-import kotlinx.coroutines.cancel
-
-class FwkViewModel(
-    private val coroutineScope: CoroutineScope =
-        CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
-) : ViewModel() {
-
-    companion object {
-        const val TAG = "FwkViewModel"
-    }
-
-    val scanResults = mutableMapOf<String, ScanResult>()
-
-    init {
-        Log.d(TAG, "init called")
-    }
-
-    override fun onCleared() {
-        Log.d(TAG, "onCleared() called")
-
-        coroutineScope.cancel()
-    }
-}
diff --git a/bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/ui/bluetoothx/BtxFragment.kt b/bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/ui/home/HomeFragment.kt
similarity index 92%
rename from bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/ui/bluetoothx/BtxFragment.kt
rename to bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/ui/home/HomeFragment.kt
index bc1e69a..3b43f19 100644
--- a/bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/ui/bluetoothx/BtxFragment.kt
+++ b/bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/ui/home/HomeFragment.kt
@@ -1,5 +1,5 @@
 /*
- * Copyright 2022 The Android Open Source Project
+ * 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.
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-package androidx.bluetooth.integration.testapp.ui.bluetoothx
+package androidx.bluetooth.integration.testapp.ui.home
 
 import android.annotation.SuppressLint
 import android.bluetooth.le.AdvertiseData
@@ -29,12 +29,12 @@
 import android.widget.Toast
 
 import androidx.bluetooth.integration.testapp.R
-import androidx.bluetooth.integration.testapp.databinding.FragmentBtxBinding
+import androidx.bluetooth.integration.testapp.data.SampleAdvertiseData
+import androidx.bluetooth.integration.testapp.databinding.FragmentHomeBinding
 import androidx.bluetooth.integration.testapp.experimental.AdvertiseResult
 import androidx.bluetooth.integration.testapp.experimental.BluetoothLe
 import androidx.bluetooth.integration.testapp.experimental.GattServerCallback
 import androidx.bluetooth.integration.testapp.ui.common.ScanResultAdapter
-import androidx.bluetooth.integration.testapp.ui.framework.FwkFragment
 import androidx.fragment.app.Fragment
 import androidx.lifecycle.ViewModelProvider
 
@@ -43,19 +43,19 @@
 import kotlinx.coroutines.Job
 import kotlinx.coroutines.launch
 
-class BtxFragment : Fragment() {
+class HomeFragment : Fragment() {
 
     companion object {
-        const val TAG = "BtxFragment"
+        const val TAG = "HomeFragment"
     }
 
     private var scanResultAdapter: ScanResultAdapter? = null
 
     private lateinit var bluetoothLe: BluetoothLe
 
-    private lateinit var btxViewModel: BtxViewModel
+    private lateinit var mHomeViewModel: HomeViewModel
 
-    private var _binding: FragmentBtxBinding? = null
+    private var _binding: FragmentHomeBinding? = null
 
     // This property is only valid between onCreateView and onDestroyView.
     private val binding get() = _binding!!
@@ -69,9 +69,9 @@
             TAG, "onCreateView() called with: inflater = $inflater, " +
                 "container = $container, savedInstanceState = $savedInstanceState"
         )
-        btxViewModel = ViewModelProvider(this).get(BtxViewModel::class.java)
+        mHomeViewModel = ViewModelProvider(this).get(HomeViewModel::class.java)
 
-        _binding = FragmentBtxBinding.inflate(inflater, container, false)
+        _binding = FragmentHomeBinding.inflate(inflater, container, false)
         return binding.root
     }
 
@@ -86,7 +86,7 @@
         binding.buttonScan.setOnClickListener {
             if (scanJob?.isActive == true) {
                 scanJob?.cancel()
-                binding.buttonScan.text = getString(R.string.scan_using_btx)
+                binding.buttonScan.text = getString(R.string.scan_using_androidx_bluetooth)
             } else {
                 startScan()
             }
@@ -131,9 +131,9 @@
                 .collect {
                     Log.d(TAG, "ScanResult collected: $it")
 
-                    btxViewModel.scanResults[it.device.address] = it
-                    scanResultAdapter?.submitList(btxViewModel.scanResults.values.toMutableList())
-                    scanResultAdapter?.notifyItemInserted(btxViewModel.scanResults.size)
+                    mHomeViewModel.scanResults[it.device.address] = it
+                    scanResultAdapter?.submitList(mHomeViewModel.scanResults.values.toMutableList())
+                    scanResultAdapter?.notifyItemInserted(mHomeViewModel.scanResults.size)
                 }
         }
     }
@@ -156,7 +156,7 @@
             .build()
 
         val advertiseData = AdvertiseData.Builder()
-            .addServiceUuid(FwkFragment.ServiceUUID)
+            .addServiceUuid(SampleAdvertiseData.testUUID)
             .setIncludeDeviceName(true)
             .build()
 
diff --git a/bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/ui/bluetoothx/BtxViewModel.kt b/bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/ui/home/HomeViewModel.kt
similarity index 87%
rename from bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/ui/bluetoothx/BtxViewModel.kt
rename to bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/ui/home/HomeViewModel.kt
index 8e89270..059d7e1 100644
--- a/bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/ui/bluetoothx/BtxViewModel.kt
+++ b/bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/ui/home/HomeViewModel.kt
@@ -14,16 +14,16 @@
  * limitations under the License.
  */
 
-package androidx.bluetooth.integration.testapp.ui.bluetoothx
+package androidx.bluetooth.integration.testapp.ui.home
 
 import android.bluetooth.le.ScanResult
 import android.util.Log
 import androidx.lifecycle.ViewModel
 
-class BtxViewModel : ViewModel() {
+class HomeViewModel : ViewModel() {
 
     companion object {
-        const val TAG = "BtxViewModel"
+        const val TAG = "HomeViewModel"
     }
 
     val scanResults = mutableMapOf<String, ScanResult>()
diff --git a/bluetooth/integration-tests/testapp/src/main/res/drawable/ic_filter_frames_24.xml b/bluetooth/integration-tests/testapp/src/main/res/drawable/ic_filter_frames_24.xml
deleted file mode 100644
index 244584b1..0000000
--- a/bluetooth/integration-tests/testapp/src/main/res/drawable/ic_filter_frames_24.xml
+++ /dev/null
@@ -1,5 +0,0 @@
-<vector android:height="24dp" android:tint="#000000"
-    android:viewportHeight="24" android:viewportWidth="24"
-    android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
-    <path android:fillColor="@android:color/white" android:pathData="M20,4h-4l-4,-4 -4,4L4,4c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2L22,6c0,-1.1 -0.9,-2 -2,-2zM20,20L4,20L4,6h4.52l3.52,-3.5L15.52,6L20,6v14zM18,8L6,8v10h12"/>
-</vector>
diff --git a/bluetooth/integration-tests/testapp/src/main/res/layout/activity_main.xml b/bluetooth/integration-tests/testapp/src/main/res/layout/activity_main.xml
index 5d9372f..ad75688 100644
--- a/bluetooth/integration-tests/testapp/src/main/res/layout/activity_main.xml
+++ b/bluetooth/integration-tests/testapp/src/main/res/layout/activity_main.xml
@@ -23,25 +23,13 @@
     android:layout_height="match_parent"
     tools:context=".MainActivity">
 
-    <com.google.android.material.bottomnavigation.BottomNavigationView
-        android:id="@+id/nav_view"
-        android:layout_width="0dp"
-        android:layout_height="wrap_content"
-        android:layout_marginStart="0dp"
-        android:layout_marginEnd="0dp"
-        android:background="?android:attr/windowBackground"
-        app:layout_constraintBottom_toBottomOf="parent"
-        app:layout_constraintLeft_toLeftOf="parent"
-        app:layout_constraintRight_toRightOf="parent"
-        app:menu="@menu/bottom_nav_menu" />
-
     <fragment
         android:id="@+id/nav_host_fragment_activity_main"
         android:name="androidx.navigation.fragment.NavHostFragment"
         android:layout_width="0dp"
         android:layout_height="0dp"
         app:defaultNavHost="true"
-        app:layout_constraintBottom_toTopOf="@id/nav_view"
+        app:layout_constraintBottom_toBottomOf="parent"
         app:layout_constraintLeft_toLeftOf="parent"
         app:layout_constraintRight_toRightOf="parent"
         app:layout_constraintTop_toTopOf="parent"
diff --git a/bluetooth/integration-tests/testapp/src/main/res/layout/fragment_fwk.xml b/bluetooth/integration-tests/testapp/src/main/res/layout/fragment_fwk.xml
deleted file mode 100644
index 46c919e..0000000
--- a/bluetooth/integration-tests/testapp/src/main/res/layout/fragment_fwk.xml
+++ /dev/null
@@ -1,65 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<!--
-  Copyright 2022 The Android Open Source Project
-
-  Licensed under the Apache License, Version 2.0 (the "License");
-  you may not use this file except in compliance with the License.
-  You may obtain a copy of the License at
-
-       http://www.apache.org/licenses/LICENSE-2.0
-
-  Unless required by applicable law or agreed to in writing, software
-  distributed under the License is distributed on an "AS IS" BASIS,
-  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-  See the License for the specific language governing permissions and
-  limitations under the License.
-  -->
-<androidx.constraintlayout.widget.ConstraintLayout
-    xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto"
-    xmlns:tools="http://schemas.android.com/tools"
-    android:layout_width="match_parent"
-    android:layout_height="match_parent"
-    tools:context=".ui.framework.FwkFragment">
-
-    <Button
-        android:id="@+id/button_scan"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:layout_marginTop="16dp"
-        android:text="@string/scan_using_fwk"
-        app:layout_constraintEnd_toEndOf="parent"
-        app:layout_constraintStart_toStartOf="parent"
-        app:layout_constraintTop_toTopOf="parent" />
-
-    <androidx.appcompat.widget.SwitchCompat
-        android:id="@+id/switch_advertise"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:layout_marginTop="16dp"
-        android:text="@string/advertise_using_fwk"
-        app:layout_constraintEnd_toEndOf="parent"
-        app:layout_constraintStart_toStartOf="parent"
-        app:layout_constraintTop_toBottomOf="@id/button_scan" />
-
-    <androidx.appcompat.widget.SwitchCompat
-        android:id="@+id/switch_gatt_server"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:layout_marginTop="16dp"
-        android:text="@string/open_gatt_server_using_fwk"
-        app:layout_constraintEnd_toEndOf="parent"
-        app:layout_constraintStart_toStartOf="parent"
-        app:layout_constraintTop_toBottomOf="@id/switch_advertise" />
-
-    <androidx.recyclerview.widget.RecyclerView
-        android:id="@+id/recycler_view"
-        android:layout_width="match_parent"
-        android:layout_height="0dp"
-        app:layoutManager="LinearLayoutManager"
-        app:layout_constraintBottom_toBottomOf="parent"
-        app:layout_constraintTop_toBottomOf="@+id/switch_gatt_server"
-        tools:itemCount="3"
-        tools:listitem="@layout/scan_result_item" />
-
-</androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/bluetooth/integration-tests/testapp/src/main/res/layout/fragment_btx.xml b/bluetooth/integration-tests/testapp/src/main/res/layout/fragment_home.xml
similarity index 90%
rename from bluetooth/integration-tests/testapp/src/main/res/layout/fragment_btx.xml
rename to bluetooth/integration-tests/testapp/src/main/res/layout/fragment_home.xml
index ef892a9..9b242ce 100644
--- a/bluetooth/integration-tests/testapp/src/main/res/layout/fragment_btx.xml
+++ b/bluetooth/integration-tests/testapp/src/main/res/layout/fragment_home.xml
@@ -20,14 +20,14 @@
     xmlns:tools="http://schemas.android.com/tools"
     android:layout_width="match_parent"
     android:layout_height="match_parent"
-    tools:context=".ui.bluetoothx.BtxFragment">
+    tools:context=".ui.home.HomeFragment">
 
     <Button
         android:id="@+id/button_scan"
         android:layout_width="wrap_content"
         android:layout_height="wrap_content"
         android:layout_marginTop="16dp"
-        android:text="@string/scan_using_btx"
+        android:text="@string/scan_using_androidx_bluetooth"
         app:layout_constraintEnd_toEndOf="parent"
         app:layout_constraintStart_toStartOf="parent"
         app:layout_constraintTop_toTopOf="parent" />
@@ -37,7 +37,7 @@
         android:layout_width="wrap_content"
         android:layout_height="wrap_content"
         android:layout_marginTop="16dp"
-        android:text="@string/advertise_using_btx"
+        android:text="@string/advertise_using_androidx_bluetooth"
         app:layout_constraintEnd_toEndOf="parent"
         app:layout_constraintStart_toStartOf="parent"
         app:layout_constraintTop_toBottomOf="@id/button_scan" />
@@ -47,7 +47,7 @@
         android:layout_width="wrap_content"
         android:layout_height="wrap_content"
         android:layout_marginTop="16dp"
-        android:text="@string/open_gatt_server_using_btx"
+        android:text="@string/open_gatt_server_using_androidx_bluetooth"
         app:layout_constraintEnd_toEndOf="parent"
         app:layout_constraintStart_toStartOf="parent"
         app:layout_constraintTop_toBottomOf="@id/switch_advertise" />
diff --git a/bluetooth/integration-tests/testapp/src/main/res/menu/bottom_nav_menu.xml b/bluetooth/integration-tests/testapp/src/main/res/menu/bottom_nav_menu.xml
index b5bcafb..87e60bd 100644
--- a/bluetooth/integration-tests/testapp/src/main/res/menu/bottom_nav_menu.xml
+++ b/bluetooth/integration-tests/testapp/src/main/res/menu/bottom_nav_menu.xml
@@ -17,13 +17,8 @@
 <menu xmlns:android="http://schemas.android.com/apk/res/android">
 
     <item
-        android:id="@+id/navigation_fwk"
-        android:icon="@drawable/ic_filter_frames_24"
-        android:title="@string/title_fwk" />
-
-    <item
-        android:id="@+id/navigation_btx"
+        android:id="@+id/navigation_home"
         android:icon="@drawable/ic_bluetooth_24"
-        android:title="@string/title_btx" />
+        android:title="@string/title_home" />
 
 </menu>
diff --git a/bluetooth/integration-tests/testapp/src/main/res/navigation/nav_graph.xml b/bluetooth/integration-tests/testapp/src/main/res/navigation/nav_graph.xml
index 6fb7139..2713795 100644
--- a/bluetooth/integration-tests/testapp/src/main/res/navigation/nav_graph.xml
+++ b/bluetooth/integration-tests/testapp/src/main/res/navigation/nav_graph.xml
@@ -18,18 +18,12 @@
     xmlns:app="http://schemas.android.com/apk/res-auto"
     xmlns:tools="http://schemas.android.com/tools"
     android:id="@+id/nav_graph"
-    app:startDestination="@id/navigation_fwk">
+    app:startDestination="@id/navigation_home">
 
     <fragment
-        android:id="@+id/navigation_fwk"
-        android:name="androidx.bluetooth.integration.testapp.ui.framework.FwkFragment"
-        android:label="@string/title_fwk"
-        tools:layout="@layout/fragment_fwk" />
-
-    <fragment
-        android:id="@+id/navigation_btx"
-        android:name="androidx.bluetooth.integration.testapp.ui.bluetoothx.BtxFragment"
-        android:label="@string/title_btx"
-        tools:layout="@layout/fragment_btx" />
+        android:id="@+id/navigation_home"
+        android:name="androidx.bluetooth.integration.testapp.ui.home.HomeFragment"
+        android:label="@string/title_home"
+        tools:layout="@layout/fragment_home" />
 
 </navigation>
diff --git a/bluetooth/integration-tests/testapp/src/main/res/values/donottranslate-strings.xml b/bluetooth/integration-tests/testapp/src/main/res/values/donottranslate-strings.xml
index 7925a2b..4ec5c99 100644
--- a/bluetooth/integration-tests/testapp/src/main/res/values/donottranslate-strings.xml
+++ b/bluetooth/integration-tests/testapp/src/main/res/values/donottranslate-strings.xml
@@ -15,21 +15,17 @@
   limitations under the License.
   -->
 <resources>
-    <string name="app_name">BluetoothX Test App</string>
+    <string name="app_name">AndroidX Bluetooth Test App</string>
 
-    <string name="title_fwk">Framework</string>
-    <string name="title_btx">BluetoothX</string>
+    <string name="title_home">AndroidX Bluetooth</string>
 
-    <string name="scan_using_fwk">Scan using Framework Bluetooth APIs</string>
-    <string name="scan_using_btx">Scan using BluetoothX APIs</string>
+    <string name="scan_using_androidx_bluetooth">Scan using AndroidX Bluetooth APIs</string>
     <string name="scan_start_message">Scan started. Results are in Logcat</string>
     <string name="stop_scanning">Stop scanning</string>
 
-    <string name="advertise_using_fwk">Advertise using Framework Bluetooth APIs</string>
-    <string name="advertise_using_btx">Advertise using BluetoothX APIs</string>
+    <string name="advertise_using_androidx_bluetooth">Advertise using AndroidX Bluetooth APIs</string>
     <string name="advertise_start_message">Advertise started</string>
 
-    <string name="open_gatt_server_using_fwk">Open GATT Server using Framework Bluetooth APIs</string>
-    <string name="open_gatt_server_using_btx">Open GATT Server using BluetoothX APIs</string>
+    <string name="open_gatt_server_using_androidx_bluetooth">Open GATT Server using AndroidX Bluetooth APIs</string>
     <string name="gatt_server_open">GATT Server open</string>
 </resources>
diff --git a/buildSrc/private/src/main/kotlin/androidx/build/FtlRunner.kt b/buildSrc/private/src/main/kotlin/androidx/build/FtlRunner.kt
index b5b8118..78e9bf4 100644
--- a/buildSrc/private/src/main/kotlin/androidx/build/FtlRunner.kt
+++ b/buildSrc/private/src/main/kotlin/androidx/build/FtlRunner.kt
@@ -135,6 +135,8 @@
 private val devicesToRunOn = listOf(
     "ftlpixel2api33" to "Pixel2.arm,version=33",
     "ftlpixel2api30" to "Pixel2.arm,version=30",
+    "ftlpixel2api28" to "Pixel2.arm,version=28",
+    "ftlpixel2api26" to "Pixel2.arm,version=26",
     "ftlnexus4api21" to "Nexus4,version=21",
 )
 
diff --git a/buildSrc/private/src/main/kotlin/androidx/build/dependencyTracker/AffectedModuleDetector.kt b/buildSrc/private/src/main/kotlin/androidx/build/dependencyTracker/AffectedModuleDetector.kt
index 597648d..2b4d9f1 100644
--- a/buildSrc/private/src/main/kotlin/androidx/build/dependencyTracker/AffectedModuleDetector.kt
+++ b/buildSrc/private/src/main/kotlin/androidx/build/dependencyTracker/AffectedModuleDetector.kt
@@ -594,26 +594,32 @@
                 ":benchmark:integration-tests:macrobenchmark-target"
             ), // link benchmark-macro's correctness test and its target
             setOf(
+                ":benchmark:integration-tests",
                 ":benchmark:integration-tests:macrobenchmark",
                 ":benchmark:integration-tests:macrobenchmark-target"
             ), // link benchmark's macrobenchmark and its target
             setOf(
+                ":compose:integration-tests",
                 ":compose:integration-tests:macrobenchmark",
                 ":compose:integration-tests:macrobenchmark-target"
             ),
             setOf(
+                ":emoji2:integration-tests",
                 ":emoji2:integration-tests:init-disabled-macrobenchmark",
                 ":emoji2:integration-tests:init-disabled-macrobenchmark-target",
             ),
             setOf(
+                ":emoji2:integration-tests",
                 ":emoji2:integration-tests:init-enabled-macrobenchmark",
                 ":emoji2:integration-tests:init-enabled-macrobenchmark-target",
             ),
             setOf(
+                ":wear:benchmark:integration-tests",
                 ":wear:benchmark:integration-tests:macrobenchmark",
                 ":wear:benchmark:integration-tests:macrobenchmark-target"
             ),
             setOf(
+                ":wear:compose:integration-tests",
                 ":wear:compose:integration-tests:macrobenchmark",
                 ":wear:compose:integration-tests:macrobenchmark-target"
             ),
@@ -625,10 +631,12 @@
             ),
             // Link glance-appwidget macrobenchmark and its target.
             setOf(
+                ":glance:glance-appwidget:integration-tests",
                 ":glance:glance-appwidget:integration-tests:macrobenchmark",
                 ":glance:glance-appwidget:integration-tests:macrobenchmark-target"
             ),
             setOf(
+                ":constraintlayout:constraintlayout-compose:integration-tests",
                 ":constraintlayout:constraintlayout-compose:integration-tests:macrobenchmark",
                 ":constraintlayout:constraintlayout-compose:integration-tests:macrobenchmark-target"
             )
diff --git a/buildSrc/shared.gradle b/buildSrc/shared.gradle
index 331238c..80f64c1 100644
--- a/buildSrc/shared.gradle
+++ b/buildSrc/shared.gradle
@@ -28,7 +28,6 @@
 
     // variety of json parsers
     implementation(libs.gson)
-    implementation(libs.json) // b/241475613
     implementation(libs.jsonSimple)
 
     // XML parsers used in MavenUploadHelper.kt
diff --git a/camera/camera-camera2-pipe-integration/src/androidTest/java/androidx/camera/camera2/pipe/integration/CameraControlAdapterDeviceTest.kt b/camera/camera-camera2-pipe-integration/src/androidTest/java/androidx/camera/camera2/pipe/integration/CameraControlAdapterDeviceTest.kt
index 67f3c4f..1a18808 100644
--- a/camera/camera-camera2-pipe-integration/src/androidTest/java/androidx/camera/camera2/pipe/integration/CameraControlAdapterDeviceTest.kt
+++ b/camera/camera-camera2-pipe-integration/src/androidTest/java/androidx/camera/camera2/pipe/integration/CameraControlAdapterDeviceTest.kt
@@ -283,6 +283,9 @@
         val action = FocusMeteringAction.Builder(factory.createPoint(0f, 0f)).build()
         bindUseCase(imageAnalysis)
 
+        // TODO(b/269968191): wait till camera is ready for submitting requests
+        waitForResult(1).verify({ _, _ -> true }, TIMEOUT)
+
         // Act.
         cameraControl.startFocusAndMetering(action).await()
 
@@ -327,6 +330,9 @@
         val action = FocusMeteringAction.Builder(factory.createPoint(0f, 0f)).build()
         bindUseCase(imageAnalysis)
 
+        // TODO(b/269968191): wait till camera is ready for submitting requests
+        waitForResult(1).verify({ _, _ -> true }, TIMEOUT)
+
         // Act.
         cameraControl.startFocusAndMetering(action).await()
         cameraControl.cancelFocusAndMetering().await()
diff --git a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/adapter/CameraFactoryAdapter.kt b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/adapter/CameraFactoryAdapter.kt
index 56257d4..a6d6b1f 100644
--- a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/adapter/CameraFactoryAdapter.kt
+++ b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/adapter/CameraFactoryAdapter.kt
@@ -91,7 +91,37 @@
 
     override fun getCameraCoordinator(): CameraCoordinator? {
         // TODO(b/262772650): camera-pipe support for concurrent camera.
-        return null
+        return object : CameraCoordinator {
+            override fun getConcurrentCameraSelectors(): MutableList<MutableList<CameraSelector>> {
+                return mutableListOf()
+            }
+
+            override fun getActiveConcurrentCameraSelectors(): MutableList<CameraSelector> {
+                return mutableListOf()
+            }
+
+            override fun setActiveConcurrentCameraSelectors(
+                cameraSelectors: MutableList<CameraSelector>
+            ) {
+            }
+
+            override fun getPairedConcurrentCameraId(cameraId: String): String? {
+                return null
+            }
+
+            override fun getCameraOperatingMode(): Int {
+                return CameraCoordinator.CAMERA_OPERATING_MODE_UNSPECIFIED
+            }
+
+            override fun setCameraOperatingMode(cameraOperatingMode: Int) {
+            }
+
+            override fun addListener(listener: CameraCoordinator.ConcurrentCameraModeListener) {
+            }
+
+            override fun removeListener(listener: CameraCoordinator.ConcurrentCameraModeListener) {
+            }
+        }
     }
 
     override fun getCameraManager(): Any? = appComponent
diff --git a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/impl/FocusMeteringControl.kt b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/impl/FocusMeteringControl.kt
index a7c456f..d2de017 100644
--- a/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/impl/FocusMeteringControl.kt
+++ b/camera/camera-camera2-pipe-integration/src/main/java/androidx/camera/camera2/pipe/integration/impl/FocusMeteringControl.kt
@@ -25,6 +25,7 @@
 import android.util.Rational
 import androidx.annotation.RequiresApi
 import androidx.camera.camera2.pipe.AeMode
+import androidx.camera.camera2.pipe.AfMode
 import androidx.camera.camera2.pipe.Result3A
 import androidx.camera.camera2.pipe.integration.adapter.asListenableFuture
 import androidx.camera.camera2.pipe.integration.adapter.propagateTo
@@ -154,10 +155,18 @@
                         afRegions = afRectangles,
                         awbRegions = awbRectangles,
                         afTriggerStartAeMode = cameraProperties.getSupportedAeMode(AeMode.ON)
-                    ).await().toFocusMeteringResult(true)
-                }.let { focusMeteringResult ->
-                    if (focusMeteringResult != null) {
-                        signal.complete(focusMeteringResult)
+                    ).await()
+                }.let { result3A ->
+                    if (result3A != null) {
+                        if (result3A.status == Result3A.Status.SUBMIT_FAILED) {
+                            signal.completeExceptionally(
+                                OperationCanceledException("Camera is not active.")
+                            )
+                        } else {
+                            signal.complete(result3A.toFocusMeteringResult(
+                                shouldTriggerAf = afRectangles.isNotEmpty()
+                            ))
+                        }
                     } else {
                         if (isCancelEnabled) {
                             if (signal.isActive) {
@@ -261,15 +270,42 @@
         if (this.status != Result3A.Status.OK) {
             return FocusMeteringResult.create(false)
         }
-        val isFocusSuccessful =
-            if (shouldTriggerAf)
-                this.frameMetadata?.get(CaptureResult.CONTROL_AF_STATE) ==
-                    CaptureResult.CONTROL_AF_STATE_FOCUSED_LOCKED
-            else true
+
+        val resultAfState = frameMetadata?.get(CaptureResult.CONTROL_AF_STATE)
+
+        /**
+         * The sequence in which the conditions for isFocusSuccessful are checked is important,
+         * since they represent the priorities for the conditions.
+         *
+         * For example, if isAfModeSupported is false, CameraX documentation dictates that
+         * isFocusSuccessful will be true in result. However, CameraPipe will set
+         * frameMetadata = null in this case as a kind of operation not allowed by camera.
+         *
+         * So we have to check isAfModeSupported first as it is a more specific case and higher
+         * in priority. On the other hand, resultAfState == null matters only if the result comes
+         * from a submitted request, so it should be checked after frameMetadata == null.
+         *
+         * Ref: [FocusMeteringAction] and [Controller3A.lock3A] documentations.
+         */
+        val isFocusSuccessful = when {
+            !shouldTriggerAf -> false
+            !cameraProperties.isAfModeSupported(AfMode.AUTO) -> true
+            frameMetadata == null -> false
+            resultAfState == null -> true
+            else -> resultAfState == CaptureResult.CONTROL_AF_STATE_FOCUSED_LOCKED
+        }
 
         return FocusMeteringResult.create(isFocusSuccessful)
     }
 
+    private fun CameraProperties.isAfModeSupported(afMode: AfMode): Boolean {
+        val modes = metadata[CameraCharacteristics.CONTROL_AF_AVAILABLE_MODES]?.map {
+            AfMode.fromIntOrNull(it)
+        } ?: return false
+
+        return modes.contains(afMode)
+    }
+
     companion object {
         const val METERING_WEIGHT_DEFAULT = MeteringRectangle.METERING_WEIGHT_MAX
         const val AUTO_FOCUS_TIMEOUT_DURATION = 5000L
diff --git a/camera/camera-camera2-pipe-integration/src/test/java/androidx/camera/camera2/pipe/integration/adapter/FocusMeteringControlTest.kt b/camera/camera-camera2-pipe-integration/src/test/java/androidx/camera/camera2/pipe/integration/adapter/FocusMeteringControlTest.kt
index d1c277d..3ea7d96 100644
--- a/camera/camera-camera2-pipe-integration/src/test/java/androidx/camera/camera2/pipe/integration/adapter/FocusMeteringControlTest.kt
+++ b/camera/camera-camera2-pipe-integration/src/test/java/androidx/camera/camera2/pipe/integration/adapter/FocusMeteringControlTest.kt
@@ -77,7 +77,6 @@
 import org.junit.After
 import org.junit.Assert.assertThrows
 import org.junit.Before
-import org.junit.Ignore
 import org.junit.Test
 import org.junit.runner.RunWith
 import org.robolectric.annotation.Config
@@ -159,7 +158,8 @@
             // "IllegalStateException: Dispatchers.Main is used concurrently with setting it"
             fakeUseCaseThreads.scope.cancel()
             fakeUseCaseThreads.sequentialScope.cancel()
-        } catch (_: Exception) {}
+        } catch (_: Exception) {
+        }
     }
 
     @Test
@@ -607,7 +607,7 @@
     }
 
     @Test
-    fun startFocusMetering_AfLocked_completesWithFocusFalse() {
+    fun startFocusMetering_AfLocked_completesWithFocusTrue() {
         fakeRequestControl.focusMeteringResult = CompletableDeferred(
             Result3A(
                 status = Result3A.Status.OK,
@@ -645,7 +645,6 @@
     }
 
     @Test
-    @Ignore("b/263323720: When AfState is null, it means AF is not supported")
     fun startFocusMetering_AfStateIsNull_completesWithFocusTrue() {
         fakeRequestControl.focusMeteringResult = CompletableDeferred(
             Result3A(
@@ -668,7 +667,6 @@
     }
 
     @Test
-    @Ignore("b/263323720: When AF is not supported, focus should be reported as successful")
     fun startFocusMeteringAfRequested_CameraNotSupportAfAuto_CompletesWithTrue() {
         // Use camera which does not support AF_AUTO
         focusMeteringControl = initFocusMeteringControl(CAMERA_ID_2)
@@ -686,31 +684,31 @@
     @Test
     fun startFocusMetering_cancelledBeforeCompletion_failsWithOperationCanceledOperation() =
         runBlocking {
-        // Arrange. Set a delay CompletableDeferred
-        fakeRequestControl.focusMeteringResult = CompletableDeferred<Result3A>().apply {
-            async(Dispatchers.Default) {
-                delay(500)
-                complete(
-                    Result3A(
-                        status = Result3A.Status.OK,
-                        frameMetadata = FakeFrameMetadata(
-                            extraMetadata = mapOf(
-                                CONTROL_AF_STATE to CONTROL_AF_STATE_FOCUSED_LOCKED
+            // Arrange. Set a delay CompletableDeferred
+            fakeRequestControl.focusMeteringResult = CompletableDeferred<Result3A>().apply {
+                async(Dispatchers.Default) {
+                    delay(500)
+                    complete(
+                        Result3A(
+                            status = Result3A.Status.OK,
+                            frameMetadata = FakeFrameMetadata(
+                                extraMetadata = mapOf(
+                                    CONTROL_AF_STATE to CONTROL_AF_STATE_FOCUSED_LOCKED
+                                )
                             )
                         )
                     )
-                )
+                }
             }
+            val action = FocusMeteringAction.Builder(point1).build()
+            val future = focusMeteringControl.startFocusAndMetering(action)
+
+            // Act.
+            focusMeteringControl.cancelFocusAndMeteringAsync()
+
+            // Assert.
+            assertFutureFailedWithOperationCancellation(future)
         }
-        val action = FocusMeteringAction.Builder(point1).build()
-        val future = focusMeteringControl.startFocusAndMetering(action)
-
-        // Act.
-        focusMeteringControl.cancelFocusAndMeteringAsync()
-
-        // Assert.
-        assertFutureFailedWithOperationCancellation(future)
-    }
 
     @Test
     fun startThenCancelThenStart_previous2FuturesFailsWithOperationCanceled() {
@@ -773,7 +771,7 @@
         val action = FocusMeteringAction.Builder(point1).build()
 
         val result = focusMeteringControl.startFocusAndMetering(action).apply {
-           get(3, TimeUnit.SECONDS)
+            get(3, TimeUnit.SECONDS)
         }
 
         // Act. Cancel it and then ensure the returned ListenableFuture still completes.
@@ -835,7 +833,18 @@
     @Test
     fun startFocusMetering_morePointsThanSupported_futureCompletes() {
         // Camera 0 supports only 3 AF, 3 AE, 1 AWB regions, here we try to have 1 AE region, 2 AWB
-        // regions. It should still complete the future.
+        // regions. It should still complete the future, even though focus is not locked.
+        fakeRequestControl.focusMeteringResult = CompletableDeferred(
+            Result3A(
+                status = Result3A.Status.OK,
+                frameMetadata = FakeFrameMetadata(
+                    extraMetadata = mapOf(
+                        CONTROL_AF_STATE to CONTROL_AF_STATE_NOT_FOCUSED_LOCKED
+                    )
+                )
+            )
+        )
+
         val action = FocusMeteringAction.Builder(
             point1,
             FocusMeteringAction.FLAG_AE or FocusMeteringAction.FLAG_AWB
@@ -1106,6 +1115,58 @@
         ).isEqualTo(null)
     }
 
+    @Test
+    fun startFocusMetering_submitFailed_failsWithOperationCanceledOperation() = runBlocking {
+        fakeRequestControl.focusMeteringResult = CompletableDeferred(
+            Result3A(
+                status = Result3A.Status.SUBMIT_FAILED,
+                frameMetadata = null,
+            )
+        )
+
+        val result = focusMeteringControl.startFocusAndMetering(
+            FocusMeteringAction.Builder(point1).build()
+        )
+
+        assertFutureFailedWithOperationCancellation(result)
+    }
+
+    @Test
+    fun startFocusMetering_noAfPoint_futureCompletesWithFocusUnsuccessful() {
+        val focusMeteringControl = initFocusMeteringControl(CAMERA_ID_1)
+        val action = FocusMeteringAction.Builder(
+            point1,
+            FocusMeteringAction.FLAG_AE or FocusMeteringAction.FLAG_AWB
+        ).build()
+        val future = focusMeteringControl.startFocusAndMetering(action)
+
+        assertFutureFocusCompleted(future, false)
+    }
+
+    @Test
+    fun startFocusMetering_frameMetadataNullWithOkStatus_futureCompletesWithFocusSuccessful() {
+        /**
+         * According to [Controller3A.lock3A] method documentation,
+         * if the operation is not supported by the camera device, then this method returns early
+         * with Result3A made of 'OK' status and 'null' metadata.
+         */
+        fakeRequestControl.focusMeteringResult = CompletableDeferred(
+            Result3A(
+                status = Result3A.Status.OK,
+                frameMetadata = null,
+            )
+        )
+
+        val focusMeteringControl = initFocusMeteringControl(CAMERA_ID_0)
+        val future = focusMeteringControl.startFocusAndMetering(
+            FocusMeteringAction.Builder(
+                point1
+            ).build()
+        )
+
+        assertFutureFocusCompleted(future, false)
+    }
+
     // TODO: Port the following tests once their corresponding logics have been implemented.
     //  - [b/255679866] triggerAfWithTemplate, triggerAePrecaptureWithTemplate,
     //          cancelAfAeTriggerWithTemplate
diff --git a/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/concurrent/Camera2CameraCoordinator.java b/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/concurrent/Camera2CameraCoordinator.java
index e1f4760..66dc731 100644
--- a/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/concurrent/Camera2CameraCoordinator.java
+++ b/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/concurrent/Camera2CameraCoordinator.java
@@ -49,30 +49,20 @@
     private static final String TAG = "Camera2CameraCoordinator";
 
     @NonNull private final CameraManagerCompat mCameraManager;
-    @NonNull private Map<String, String> mConcurrentCameraIdMap;
-    @NonNull private Set<Set<String>> mConcurrentCameraIds;
     @NonNull private final List<ConcurrentCameraModeListener> mConcurrentCameraModeListeners;
+    @NonNull private final Map<String, String> mConcurrentCameraIdMap;
+    @NonNull private List<CameraSelector> mActiveConcurrentCameraSelectors;
+    @NonNull private Set<Set<String>> mConcurrentCameraIds;
 
-    private boolean mIsConcurrentCameraModeOn;
+    @CameraOperatingMode private int mCameraOperatingMode = CAMERA_OPERATING_MODE_UNSPECIFIED;
 
     public Camera2CameraCoordinator(@NonNull CameraManagerCompat cameraManager) {
         mCameraManager = cameraManager;
         mConcurrentCameraIdMap = new HashMap<>();
         mConcurrentCameraIds = new HashSet<>();
         mConcurrentCameraModeListeners = new ArrayList<>();
-    }
-
-    @Override
-    public void init() {
-        mConcurrentCameraIds = retrieveConcurrentCameraIds(mCameraManager);
-        for (Set<String> concurrentCameraIdList: mConcurrentCameraIds) {
-            List<String> cameraIdList = new ArrayList<>(concurrentCameraIdList);
-
-            // TODO(b/268531569): enumerate concurrent camera ids and convert to a map for
-            //  paired camera id lookup.
-            mConcurrentCameraIdMap.put(cameraIdList.get(0), cameraIdList.get(1));
-            mConcurrentCameraIdMap.put(cameraIdList.get(1), cameraIdList.get(0));
-        }
+        mActiveConcurrentCameraSelectors = new ArrayList<>();
+        retrieveConcurrentCameraIds();
     }
 
     @NonNull
@@ -89,6 +79,17 @@
         return concurrentCameraSelectorLists;
     }
 
+    @NonNull
+    @Override
+    public List<CameraSelector> getActiveConcurrentCameraSelectors() {
+        return mActiveConcurrentCameraSelectors;
+    }
+
+    @Override
+    public void setActiveConcurrentCameraSelectors(@NonNull List<CameraSelector> cameraSelectors) {
+        mActiveConcurrentCameraSelectors = cameraSelectors;
+    }
+
     @Nullable
     @Override
     public String getPairedConcurrentCameraId(@NonNull String cameraId) {
@@ -98,19 +99,20 @@
         return null;
     }
 
+    @CameraOperatingMode
     @Override
-    public boolean isConcurrentCameraModeOn() {
-        return mIsConcurrentCameraModeOn;
+    public int getCameraOperatingMode() {
+        return mCameraOperatingMode;
     }
 
     @Override
-    public void setConcurrentCameraMode(boolean isConcurrentCameraModeOn) {
-        if (isConcurrentCameraModeOn != mIsConcurrentCameraModeOn) {
+    public void setCameraOperatingMode(@CameraOperatingMode int cameraOperatingMode) {
+        if (cameraOperatingMode != mCameraOperatingMode) {
             for (ConcurrentCameraModeListener listener : mConcurrentCameraModeListeners) {
-                listener.notifyConcurrentCameraModeUpdated(isConcurrentCameraModeOn);
+                listener.notifyConcurrentCameraModeUpdated(cameraOperatingMode);
             }
         }
-        mIsConcurrentCameraModeOn = isConcurrentCameraModeOn;
+        mCameraOperatingMode = cameraOperatingMode;
     }
 
     @Override
@@ -123,16 +125,23 @@
         mConcurrentCameraModeListeners.remove(listener);
     }
 
-    @NonNull
-    private static Set<Set<String>> retrieveConcurrentCameraIds(
-            @NonNull CameraManagerCompat cameraManager) {
-        Set<Set<String>> map = new HashSet<>();
+    private void retrieveConcurrentCameraIds() {
         try {
-            map = cameraManager.getConcurrentCameraIds();
+            mConcurrentCameraIds = mCameraManager.getConcurrentCameraIds();
         } catch (CameraAccessExceptionCompat e) {
             Logger.e(TAG, "Failed to get concurrent camera ids");
         }
-        return map;
+
+        for (Set<String> concurrentCameraIdList: mConcurrentCameraIds) {
+            List<String> cameraIdList = new ArrayList<>(concurrentCameraIdList);
+
+            // TODO(b/268531569): enumerate concurrent camera ids and convert to a map for
+            //  paired camera id lookup.
+            if (cameraIdList.size() >= 2) {
+                mConcurrentCameraIdMap.put(cameraIdList.get(0), cameraIdList.get(1));
+                mConcurrentCameraIdMap.put(cameraIdList.get(1), cameraIdList.get(0));
+            }
+        }
     }
 
     @OptIn(markerClass = ExperimentalCamera2Interop.class)
diff --git a/camera/camera-camera2/src/test/java/androidx/camera/camera2/internal/concurrent/Camera2CameraCoordinatorTest.kt b/camera/camera-camera2/src/test/java/androidx/camera/camera2/internal/concurrent/Camera2CameraCoordinatorTest.kt
index 4eae589..908c5a9 100644
--- a/camera/camera-camera2/src/test/java/androidx/camera/camera2/internal/concurrent/Camera2CameraCoordinatorTest.kt
+++ b/camera/camera-camera2/src/test/java/androidx/camera/camera2/internal/concurrent/Camera2CameraCoordinatorTest.kt
@@ -22,6 +22,9 @@
 import android.os.Build
 import androidx.camera.camera2.internal.compat.CameraManagerCompat
 import androidx.camera.core.concurrent.CameraCoordinator
+import androidx.camera.core.concurrent.CameraCoordinator.CAMERA_OPERATING_MODE_CONCURRENT
+import androidx.camera.core.concurrent.CameraCoordinator.CAMERA_OPERATING_MODE_SINGLE
+import androidx.camera.core.concurrent.CameraCoordinator.CAMERA_OPERATING_MODE_UNSPECIFIED
 import androidx.camera.core.impl.utils.MainThreadAsyncHandler
 import androidx.test.core.app.ApplicationProvider
 import com.google.common.truth.Truth.assertThat
@@ -60,11 +63,11 @@
         fakeCameraImpl.addCamera("0", cameraCharacteristics0)
         fakeCameraImpl.addCamera("1", cameraCharacteristics1)
         cameraCoordinator = Camera2CameraCoordinator(CameraManagerCompat.from(fakeCameraImpl))
-        cameraCoordinator.init()
     }
 
     @Test
     fun getConcurrentCameraSelectors() {
+        cameraCoordinator.cameraOperatingMode = CAMERA_OPERATING_MODE_CONCURRENT
         assertThat(cameraCoordinator.concurrentCameraSelectors).isNotEmpty()
         assertThat(cameraCoordinator.concurrentCameraSelectors[0]).isNotEmpty()
         assertThat(cameraCoordinator.concurrentCameraSelectors[0][0].lensFacing)
@@ -81,26 +84,30 @@
 
     @Test
     fun setAndIsConcurrentCameraMode() {
-        assertThat(cameraCoordinator.isConcurrentCameraModeOn).isFalse()
-        cameraCoordinator.setConcurrentCameraMode(true)
-        assertThat(cameraCoordinator.isConcurrentCameraModeOn).isTrue()
-        cameraCoordinator.setConcurrentCameraMode(false)
-        assertThat(cameraCoordinator.isConcurrentCameraModeOn).isFalse()
+        assertThat(cameraCoordinator.cameraOperatingMode).isEqualTo(
+            CAMERA_OPERATING_MODE_UNSPECIFIED)
+        cameraCoordinator.cameraOperatingMode = CAMERA_OPERATING_MODE_CONCURRENT
+        assertThat(cameraCoordinator.cameraOperatingMode).isEqualTo(
+            CAMERA_OPERATING_MODE_CONCURRENT)
+        cameraCoordinator.cameraOperatingMode = CAMERA_OPERATING_MODE_SINGLE
+        assertThat(cameraCoordinator.cameraOperatingMode).isEqualTo(
+            CAMERA_OPERATING_MODE_SINGLE)
     }
 
     @Test
     fun addAndRemoveListener() {
         val listener = mock(CameraCoordinator.ConcurrentCameraModeListener::class.java)
         cameraCoordinator.addListener(listener)
-        cameraCoordinator.setConcurrentCameraMode(true)
-        verify(listener).notifyConcurrentCameraModeUpdated(true)
-        cameraCoordinator.setConcurrentCameraMode(false)
-        verify(listener).notifyConcurrentCameraModeUpdated(false)
+        cameraCoordinator.cameraOperatingMode = CAMERA_OPERATING_MODE_CONCURRENT
+        verify(listener).notifyConcurrentCameraModeUpdated(CAMERA_OPERATING_MODE_CONCURRENT)
+        cameraCoordinator.cameraOperatingMode = CAMERA_OPERATING_MODE_SINGLE
+        verify(listener).notifyConcurrentCameraModeUpdated(CAMERA_OPERATING_MODE_SINGLE)
 
         reset(listener)
         cameraCoordinator.removeListener(listener)
-        cameraCoordinator.setConcurrentCameraMode(true)
-        verify(listener, never()).notifyConcurrentCameraModeUpdated(true)
+        cameraCoordinator.cameraOperatingMode = CAMERA_OPERATING_MODE_CONCURRENT
+        verify(listener, never()).notifyConcurrentCameraModeUpdated(
+            CAMERA_OPERATING_MODE_CONCURRENT)
     }
 
     private class FakeCameraManagerImpl : CameraManagerCompat.CameraManagerCompatImpl {
diff --git a/camera/camera-core/src/androidTest/java/androidx/camera/core/UseCaseTest.kt b/camera/camera-core/src/androidTest/java/androidx/camera/core/UseCaseTest.kt
index 4ad6236..30f4671 100644
--- a/camera/camera-core/src/androidTest/java/androidx/camera/core/UseCaseTest.kt
+++ b/camera/camera-core/src/androidTest/java/androidx/camera/core/UseCaseTest.kt
@@ -62,7 +62,7 @@
         // Arrange.
         val testUseCase = createFakeUseCase(targetRotation = Surface.ROTATION_90)
         val fakeCamera = FakeCamera()
-        testUseCase.hasCameraTransform = false
+        fakeCamera.hasTransform = false
         // Act/Assert:
         assertThat(testUseCase.getRelativeRotation(fakeCamera, true)).isEqualTo(90)
         assertThat(testUseCase.getRelativeRotation(fakeCamera, false)).isEqualTo(270)
diff --git a/camera/camera-core/src/androidTest/java/androidx/camera/core/processing/DefaultSurfaceProcessorTest.kt b/camera/camera-core/src/androidTest/java/androidx/camera/core/processing/DefaultSurfaceProcessorTest.kt
index 6762961..adf6232 100644
--- a/camera/camera-core/src/androidTest/java/androidx/camera/core/processing/DefaultSurfaceProcessorTest.kt
+++ b/camera/camera-core/src/androidTest/java/androidx/camera/core/processing/DefaultSurfaceProcessorTest.kt
@@ -24,6 +24,7 @@
 import androidx.camera.core.CameraEffect
 import androidx.camera.core.SurfaceRequest
 import androidx.camera.core.impl.DeferrableSurface
+import androidx.camera.core.impl.ImageFormatConstants.INTERNAL_DEFINED_IMAGE_FORMAT_PRIVATE
 import androidx.camera.core.impl.utils.executor.CameraXExecutors
 import androidx.camera.testing.CameraUtil
 import androidx.camera.testing.HandlerUtil
@@ -311,6 +312,7 @@
         SurfaceOutputImpl(
             surface,
             CameraEffect.PREVIEW,
+            INTERNAL_DEFINED_IMAGE_FORMAT_PRIVATE,
             Size(WIDTH, HEIGHT),
             Size(WIDTH, HEIGHT),
             Rect(0, 0, WIDTH, HEIGHT),
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/CameraEffect.java b/camera/camera-core/src/main/java/androidx/camera/core/CameraEffect.java
index 8525ddd..01f37d2 100644
--- a/camera/camera-core/src/main/java/androidx/camera/core/CameraEffect.java
+++ b/camera/camera-core/src/main/java/androidx/camera/core/CameraEffect.java
@@ -15,10 +15,13 @@
  */
 package androidx.camera.core;
 
+import static androidx.camera.core.impl.ImageFormatConstants.INTERNAL_DEFINED_IMAGE_FORMAT_PRIVATE;
 import static androidx.core.util.Preconditions.checkArgument;
 
 import static java.util.Objects.requireNonNull;
 
+import android.graphics.ImageFormat;
+
 import androidx.annotation.IntDef;
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
@@ -90,6 +93,17 @@
     }
 
     /**
+     * Bitmask options for the effect targets.
+     *
+     * @hide
+     */
+    @Retention(RetentionPolicy.SOURCE)
+    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+    @IntDef(flag = true, value = {INTERNAL_DEFINED_IMAGE_FORMAT_PRIVATE, ImageFormat.JPEG})
+    public @interface Formats {
+    }
+
+    /**
      * Bitmask option to indicate that CameraX should apply this effect to {@link Preview}.
      */
     public static final int PREVIEW = 1;
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/CameraProvider.java b/camera/camera-core/src/main/java/androidx/camera/core/CameraProvider.java
index ffec29fc..79e1c4a 100644
--- a/camera/camera-core/src/main/java/androidx/camera/core/CameraProvider.java
+++ b/camera/camera-core/src/main/java/androidx/camera/core/CameraProvider.java
@@ -18,6 +18,7 @@
 
 import androidx.annotation.NonNull;
 import androidx.annotation.RequiresApi;
+import androidx.annotation.RestrictTo;
 
 import java.util.List;
 
@@ -59,4 +60,29 @@
      */
     @NonNull
     List<CameraInfo> getAvailableCameraInfos();
+
+    /**
+     * Returns list of {@link CameraInfo} instances of the available concurrent cameras.
+     *
+     * <p>The available concurrent cameras include all combinations of cameras which could
+     * operate concurrently on the device. Each list maps to one combination of these camera's
+     * {@link CameraInfo}.
+     *
+     * @return list of combinations of {@link CameraInfo}.
+     *
+     * @hide
+     */
+    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+    @NonNull
+    List<List<CameraInfo>> getAvailableConcurrentCameraInfos();
+
+    /**
+     * Returns concurrent camera mode.
+     *
+     * @return true if concurrent mode is enabled, otherwise false.
+     *
+     * @hide
+     */
+    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+    boolean isConcurrentCameraModeOn();
 }
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/Preview.java b/camera/camera-core/src/main/java/androidx/camera/core/Preview.java
index f301aaf..5091707 100644
--- a/camera/camera-core/src/main/java/androidx/camera/core/Preview.java
+++ b/camera/camera-core/src/main/java/androidx/camera/core/Preview.java
@@ -17,6 +17,7 @@
 package androidx.camera.core;
 
 import static androidx.camera.core.CameraEffect.PREVIEW;
+import static androidx.camera.core.impl.ImageFormatConstants.INTERNAL_DEFINED_IMAGE_FORMAT_PRIVATE;
 import static androidx.camera.core.impl.ImageInputConfig.OPTION_INPUT_FORMAT;
 import static androidx.camera.core.impl.ImageOutputConfig.OPTION_APP_TARGET_ROTATION;
 import static androidx.camera.core.impl.ImageOutputConfig.OPTION_CUSTOM_ORDERED_RESOLUTIONS;
@@ -71,7 +72,6 @@
 import androidx.camera.core.impl.Config;
 import androidx.camera.core.impl.ConfigProvider;
 import androidx.camera.core.impl.DeferrableSurface;
-import androidx.camera.core.impl.ImageFormatConstants;
 import androidx.camera.core.impl.ImageOutputConfig;
 import androidx.camera.core.impl.MutableConfig;
 import androidx.camera.core.impl.MutableOptionsBundle;
@@ -259,12 +259,13 @@
         checkState(mCameraEdge == null);
         mCameraEdge = new SurfaceEdge(
                 PREVIEW,
+                INTERNAL_DEFINED_IMAGE_FORMAT_PRIVATE,
                 streamSpec,
                 new Matrix(),
-                getHasCameraTransform(),
+                camera.getHasTransform(),
                 requireNonNull(getCropRect(streamSpec.getResolution())),
                 getRelativeRotation(camera, camera.isFrontFacing()),
-                shouldMirror());
+                shouldMirror(camera));
         mCameraEdge.addOnInvalidatedListener(this::notifyReset);
         SurfaceProcessorNode.OutConfig outConfig = SurfaceProcessorNode.OutConfig.of(mCameraEdge);
         SurfaceProcessorNode.In nodeInput = SurfaceProcessorNode.In.of(mCameraEdge,
@@ -297,13 +298,11 @@
         }
     }
 
-    private boolean shouldMirror() {
-        boolean isFrontCamera = requireNonNull(getCamera()).getCameraInfoInternal().getLensFacing()
-                == CameraSelector.LENS_FACING_FRONT;
+    private boolean shouldMirror(@NonNull CameraInternal camera) {
         // Since PreviewView cannot mirror, we will always mirror preview stream during buffer
         // copy. If there has been a buffer copy, it means it's already mirrored. Otherwise,
         // mirror it for the front camera.
-        return getHasCameraTransform() && isFrontCamera;
+        return camera.getHasTransform() && camera.isFrontFacing();
     }
 
     /**
@@ -400,7 +399,7 @@
                         cropRect,
                         getRelativeRotation(cameraInternal, cameraInternal.isFrontFacing()),
                         getAppTargetRotation(),
-                        getHasCameraTransform()));
+                        cameraInternal.getHasTransform()));
             } else {
                 mCameraEdge.setRotationDegrees(
                         getRelativeRotation(cameraInternal, cameraInternal.isFrontFacing()));
@@ -570,7 +569,7 @@
     protected UseCaseConfig<?> onMergeConfig(@NonNull CameraInfoInternal cameraInfo,
             @NonNull UseCaseConfig.Builder<?, ?, ?> builder) {
         builder.getMutableConfig().insertOption(OPTION_INPUT_FORMAT,
-                ImageFormatConstants.INTERNAL_DEFINED_IMAGE_FORMAT_PRIVATE);
+                INTERNAL_DEFINED_IMAGE_FORMAT_PRIVATE);
 
         // Merges Preview's default max resolution setting when resolution selector is used
         ResolutionSelector resolutionSelector =
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/SurfaceOutput.java b/camera/camera-core/src/main/java/androidx/camera/core/SurfaceOutput.java
index f865b2c..3833e19 100644
--- a/camera/camera-core/src/main/java/androidx/camera/core/SurfaceOutput.java
+++ b/camera/camera-core/src/main/java/androidx/camera/core/SurfaceOutput.java
@@ -66,6 +66,15 @@
     int getTargets();
 
     /**
+     * This field indicates the format of the {@link Surface}.
+     *
+     * @hide
+     */
+    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+    @CameraEffect.Formats
+    int getFormat();
+
+    /**
      * Gets the size of the {@link Surface}.
      */
     @NonNull
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/UseCase.java b/camera/camera-core/src/main/java/androidx/camera/core/UseCase.java
index 9a31cc4..4a3ce53 100644
--- a/camera/camera-core/src/main/java/androidx/camera/core/UseCase.java
+++ b/camera/camera-core/src/main/java/androidx/camera/core/UseCase.java
@@ -21,7 +21,6 @@
 import android.annotation.SuppressLint;
 import android.graphics.Matrix;
 import android.graphics.Rect;
-import android.graphics.SurfaceTexture;
 import android.media.ImageReader;
 import android.util.Size;
 import android.view.Surface;
@@ -49,7 +48,6 @@
 import androidx.camera.core.internal.CameraUseCaseAdapter;
 import androidx.camera.core.internal.TargetConfig;
 import androidx.camera.core.internal.utils.UseCaseConfigUtil;
-import androidx.camera.core.streamsharing.StreamSharing;
 import androidx.core.util.Preconditions;
 import androidx.lifecycle.LifecycleOwner;
 
@@ -130,17 +128,6 @@
     private Rect mViewPortCropRect;
 
     /**
-     * Whether the producer writes camera transform to the {@link Surface}.
-     *
-     * <p> Camera2 writes the camera transform to the {@link Surface}, which can be used to
-     * correct the output. However, if the producer is not the camera, for example, a OpenGL
-     * renderer in {@link StreamSharing}, then this field will be false.
-     *
-     * @see SurfaceTexture#getTransformMatrix
-     */
-    private boolean mHasCameraTransform = true;
-
-    /**
      * The sensor to image buffer transform matrix.
      */
     @NonNull
@@ -373,7 +360,7 @@
         // Parent UseCase always mirror the stream if the child requires it. No camera transform
         // means that the stream is copied by a parent, and if the child also requires mirroring,
         // we know that the stream has been mirrored.
-        boolean inputStreamMirrored = !mHasCameraTransform && requireMirroring;
+        boolean inputStreamMirrored = !cameraInternal.getHasTransform() && requireMirroring;
         if (inputStreamMirrored) {
             // Flip rotation if the stream has been mirrored.
             rotation = within360(-rotation);
@@ -817,28 +804,6 @@
     }
 
     /**
-     * Sets whether the producer writes camera transform to the {@link Surface}.
-     *
-     * @hide
-     */
-    @RestrictTo(Scope.LIBRARY_GROUP)
-    @CallSuper
-    public void setHasCameraTransform(boolean hasCameraTransform) {
-        mHasCameraTransform = hasCameraTransform;
-    }
-
-    /**
-     * Gets whether the producer writes camera transform to the {@link Surface}.
-     *
-     * @hide
-     */
-    @RestrictTo(Scope.LIBRARY_GROUP)
-    @CallSuper
-    public boolean getHasCameraTransform() {
-        return mHasCameraTransform;
-    }
-
-    /**
      * Gets the view port crop rect.
      *
      * @hide
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/concurrent/CameraCoordinator.java b/camera/camera-core/src/main/java/androidx/camera/core/concurrent/CameraCoordinator.java
index 7ade18a..31b6bfc 100644
--- a/camera/camera-core/src/main/java/androidx/camera/core/concurrent/CameraCoordinator.java
+++ b/camera/camera-core/src/main/java/androidx/camera/core/concurrent/CameraCoordinator.java
@@ -19,6 +19,7 @@
 
 import android.hardware.camera2.CameraManager;
 
+import androidx.annotation.IntDef;
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
@@ -26,6 +27,8 @@
 import androidx.camera.core.CameraSelector;
 import androidx.camera.core.impl.CameraStateRegistry;
 
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
 import java.util.List;
 
 /**
@@ -41,10 +44,19 @@
 @RequiresApi(21)
 public interface CameraCoordinator {
 
-    /**
-     * Initializes the map for concurrent camera ids and convert camera ids to camera selectors.
-     */
-    void init();
+    int CAMERA_OPERATING_MODE_UNSPECIFIED = 0;
+
+    int CAMERA_OPERATING_MODE_SINGLE = 1;
+
+    int CAMERA_OPERATING_MODE_CONCURRENT = 2;
+
+    @RestrictTo(RestrictTo.Scope.LIBRARY)
+    @IntDef({CAMERA_OPERATING_MODE_UNSPECIFIED,
+            CAMERA_OPERATING_MODE_SINGLE,
+            CAMERA_OPERATING_MODE_CONCURRENT})
+    @Retention(RetentionPolicy.SOURCE)
+    public @interface CameraOperatingMode {
+    }
 
     /**
      * Returns concurrent camera selectors, which are converted from concurrent camera ids
@@ -59,6 +71,21 @@
     List<List<CameraSelector>> getConcurrentCameraSelectors();
 
     /**
+     * Gets active concurrent camera selectors.
+     *
+     * @return list of active concurrent camera selectors.
+     */
+    @NonNull
+    List<CameraSelector> getActiveConcurrentCameraSelectors();
+
+    /**
+     * Sets active concurrent camera selectors.
+     *
+     * @param cameraSelectors list of active concurrent camera selectors.
+     */
+    void setActiveConcurrentCameraSelectors(@NonNull List<CameraSelector> cameraSelectors);
+
+    /**
      * Returns paired camera id in concurrent mode.
      *
      * <p>The paired camera id dictionary is constructed when {@link CameraCoordinator#init()} is
@@ -73,11 +100,12 @@
     String getPairedConcurrentCameraId(@NonNull String cameraId);
 
     /**
-     * Returns concurrent camera mode.
+     * Returns camera operating mode.
      *
-     * @return true if concurrent mode is on, otherwise returns false.
+     * @return camera operating mode including unspecific, single or concurrent.
      */
-    boolean isConcurrentCameraModeOn();
+    @CameraOperatingMode
+    int getCameraOperatingMode();
 
     /**
      * Sets concurrent camera mode.
@@ -85,19 +113,19 @@
      * <p>This internal API will be called when user binds user cases to cameras, which will
      * enable or disable concurrent camera mode based on the input config.
      *
-     * @param enabled true if concurrent camera mode is enabled, otherwise false.
+     * @param cameraOperatingMode camera operating mode including unspecific, single or concurrent.
      */
-    void setConcurrentCameraMode(boolean enabled);
+    void setCameraOperatingMode(@CameraOperatingMode int cameraOperatingMode);
 
     /**
      * Adds listener for concurrent camera mode update.
-     * @param listener
+     * @param listener {@link ConcurrentCameraModeListener}.
      */
     void addListener(@NonNull ConcurrentCameraModeListener listener);
 
     /**
      * Removes listener for concurrent camera mode update.
-     * @param listener
+     * @param listener {@link ConcurrentCameraModeListener}.
      */
     void removeListener(@NonNull ConcurrentCameraModeListener listener);
 
@@ -110,6 +138,6 @@
      * allowed cameras if concurrent mode is set.
      */
     interface ConcurrentCameraModeListener {
-        void notifyConcurrentCameraModeUpdated(boolean isConcurrentCameraModeOn);
+        void notifyConcurrentCameraModeUpdated(@CameraOperatingMode int cameraOperatingMode);
     }
 }
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/imagecapture/CaptureNode.java b/camera/camera-core/src/main/java/androidx/camera/core/imagecapture/CaptureNode.java
index 94052e1..3cdb6fe 100644
--- a/camera/camera-core/src/main/java/androidx/camera/core/imagecapture/CaptureNode.java
+++ b/camera/camera-core/src/main/java/androidx/camera/core/imagecapture/CaptureNode.java
@@ -280,7 +280,7 @@
 
         void setSurface(@NonNull Surface surface) {
             checkState(mSurface == null, "The surface is already set.");
-            mSurface = new ImmediateSurface(surface);
+            mSurface = new ImmediateSurface(surface, getSize(), getFormat());
         }
 
         /**
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/impl/CameraInternal.java b/camera/camera-core/src/main/java/androidx/camera/core/impl/CameraInternal.java
index 71f1696fe..76831a0 100644
--- a/camera/camera-core/src/main/java/androidx/camera/core/impl/CameraInternal.java
+++ b/camera/camera-core/src/main/java/androidx/camera/core/impl/CameraInternal.java
@@ -16,6 +16,9 @@
 
 package androidx.camera.core.impl;
 
+import android.graphics.SurfaceTexture;
+import android.view.Surface;
+
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
@@ -24,6 +27,7 @@
 import androidx.camera.core.CameraInfo;
 import androidx.camera.core.CameraSelector;
 import androidx.camera.core.UseCase;
+import androidx.camera.core.streamsharing.StreamSharing;
 
 import com.google.common.util.concurrent.ListenableFuture;
 
@@ -189,6 +193,19 @@
     }
 
     /**
+     * Whether the camera writes the camera transform to the Surface.
+     *
+     * <p> Camera2 writes the camera transform to the {@link Surface}, which can be used to
+     * correct the output. However, if the producer is not the camera, for example, a OpenGL
+     * renderer in {@link StreamSharing}, then this field will be false.
+     *
+     * @see SurfaceTexture#getTransformMatrix
+     */
+    default boolean getHasTransform() {
+        return true;
+    }
+
+    /**
      * Always returns only itself since there is only ever one CameraInternal.
      */
     @NonNull
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/impl/utils/Threads.java b/camera/camera-core/src/main/java/androidx/camera/core/impl/utils/Threads.java
index d1d3f68..408ffc7 100644
--- a/camera/camera-core/src/main/java/androidx/camera/core/impl/utils/Threads.java
+++ b/camera/camera-core/src/main/java/androidx/camera/core/impl/utils/Threads.java
@@ -16,12 +16,13 @@
 
 package androidx.camera.core.impl.utils;
 
+import static androidx.core.util.Preconditions.checkState;
+
 import android.os.Handler;
 import android.os.Looper;
 
 import androidx.annotation.NonNull;
 import androidx.annotation.RequiresApi;
-import androidx.core.util.Preconditions;
 
 import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.TimeUnit;
@@ -53,7 +54,7 @@
      * @throws IllegalStateException If the caller is not running on the main thread,
      */
     public static void checkMainThread() {
-        Preconditions.checkState(isMainThread(), "Not in application's main thread");
+        checkState(isMainThread(), "Not in application's main thread");
     }
 
     /**
@@ -62,11 +63,24 @@
      * @throws IllegalStateException if the caller is running on the main thread.
      */
     public static void checkBackgroundThread() {
-        Preconditions.checkState(isBackgroundThread(), "In application's main thread");
+        checkState(isBackgroundThread(), "In application's main thread");
+    }
+    /**
+     * Executes the {@link Runnable} on main thread.
+     *
+     * <p>If the caller thread is already main thread, then runnable will be executed immediately.
+     * Otherwise, the runnable will be posted to main thread.
+     */
+    public static void runOnMain(@NonNull Runnable runnable) {
+        if (isMainThread()) {
+            runnable.run();
+            return;
+        }
+        checkState(getMainHandler().post(runnable), "Unable to post to main thread");
     }
 
     /**
-     * Executes the {@link Runnable} on main thread.
+     * Executes the {@link Runnable} on main thread and block until the Runnable is complete.
      *
      * <p>If the caller thread is already main thread, then runnable will be executed immediately.
      * Otherwise, the runnable will be posted to main thread and caller thread will be blocked until
@@ -95,7 +109,7 @@
                 latch.countDown();
             }
         });
-        Preconditions.checkState(postResult, "Unable to post to main thread");
+        checkState(postResult, "Unable to post to main thread");
         try {
             if (!latch.await(TIMEOUT_RUN_ON_MAIN_MS, TimeUnit.MILLISECONDS)) {
                 throw new IllegalStateException("Timeout to wait main thread execution");
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 2e191cb..f3f7a4e 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
@@ -301,7 +301,6 @@
             // Attach new UseCases.
             for (UseCase useCase : cameraUseCasesToAttach) {
                 ConfigPair configPair = requireNonNull(configs.get(useCase));
-                useCase.setHasCameraTransform(true);
                 useCase.bindToCamera(mCameraInternal, configPair.mExtendedConfig,
                         configPair.mCameraConfig);
                 useCase.updateSuggestedStreamSpec(
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/processing/DefaultSurfaceProcessor.java b/camera/camera-core/src/main/java/androidx/camera/core/processing/DefaultSurfaceProcessor.java
index 61190dc..d144363 100644
--- a/camera/camera-core/src/main/java/androidx/camera/core/processing/DefaultSurfaceProcessor.java
+++ b/camera/camera-core/src/main/java/androidx/camera/core/processing/DefaultSurfaceProcessor.java
@@ -16,6 +16,10 @@
 
 package androidx.camera.core.processing;
 
+import static androidx.camera.core.impl.ImageFormatConstants.INTERNAL_DEFINED_IMAGE_FORMAT_PRIVATE;
+import static androidx.core.util.Preconditions.checkState;
+
+import android.graphics.ImageFormat;
 import android.graphics.SurfaceTexture;
 import android.os.Handler;
 import android.os.HandlerThread;
@@ -165,9 +169,15 @@
         for (Map.Entry<SurfaceOutput, Surface> entry : mOutputSurfaces.entrySet()) {
             Surface surface = entry.getValue();
             SurfaceOutput surfaceOutput = entry.getKey();
-
-            surfaceOutput.updateTransformMatrix(mSurfaceOutputMatrix, mTextureMatrix);
-            mGlRenderer.render(surfaceTexture.getTimestamp(), mSurfaceOutputMatrix, surface);
+            if (surfaceOutput.getFormat() == INTERNAL_DEFINED_IMAGE_FORMAT_PRIVATE) {
+                // Render GPU output directly.
+                surfaceOutput.updateTransformMatrix(mSurfaceOutputMatrix, mTextureMatrix);
+                mGlRenderer.render(surfaceTexture.getTimestamp(), mSurfaceOutputMatrix, surface);
+            } else {
+                checkState(surfaceOutput.getFormat() == ImageFormat.JPEG,
+                        "Unsupported format: " + surfaceOutput.getFormat());
+                // TODO: download RGB from GPU and encode to JPEG bytes before writing to Surface.
+            }
         }
     }
 
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/processing/SurfaceEdge.java b/camera/camera-core/src/main/java/androidx/camera/core/processing/SurfaceEdge.java
index c44bea5..13dafc4 100644
--- a/camera/camera-core/src/main/java/androidx/camera/core/processing/SurfaceEdge.java
+++ b/camera/camera-core/src/main/java/androidx/camera/core/processing/SurfaceEdge.java
@@ -18,6 +18,7 @@
 
 import static androidx.camera.core.impl.ImageOutputConfig.ROTATION_NOT_SPECIFIED;
 import static androidx.camera.core.impl.utils.Threads.checkMainThread;
+import static androidx.camera.core.impl.utils.Threads.runOnMain;
 import static androidx.camera.core.impl.utils.executor.CameraXExecutors.directExecutor;
 import static androidx.camera.core.impl.utils.executor.CameraXExecutors.mainThreadExecutor;
 import static androidx.camera.core.impl.utils.futures.Futures.immediateFailedFuture;
@@ -27,7 +28,6 @@
 import static androidx.core.util.Preconditions.checkNotNull;
 import static androidx.core.util.Preconditions.checkState;
 
-import android.graphics.ImageFormat;
 import android.graphics.Matrix;
 import android.graphics.Rect;
 import android.os.Build;
@@ -97,6 +97,7 @@
 @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
 public class SurfaceEdge {
 
+    private final int mFormat;
     private final Matrix mSensorToBufferTransform;
     private final boolean mHasCameraTransform;
     private final Rect mCropRect;
@@ -135,6 +136,7 @@
      */
     public SurfaceEdge(
             @CameraEffect.Targets int targets,
+            @CameraEffect.Formats int format,
             @NonNull StreamSpec streamSpec,
             @NonNull Matrix sensorToBufferTransform,
             boolean hasCameraTransform,
@@ -142,13 +144,14 @@
             int rotationDegrees,
             boolean mirroring) {
         mTargets = targets;
+        mFormat = format;
         mStreamSpec = streamSpec;
         mSensorToBufferTransform = sensorToBufferTransform;
         mHasCameraTransform = hasCameraTransform;
         mCropRect = cropRect;
         mRotationDegrees = rotationDegrees;
         mMirroring = mirroring;
-        mSettableSurface = new SettableSurface(streamSpec.getResolution());
+        mSettableSurface = new SettableSurface(streamSpec.getResolution(), mFormat);
     }
 
     /**
@@ -311,7 +314,8 @@
     @MainThread
     @NonNull
     public ListenableFuture<SurfaceOutput> createSurfaceOutputFuture(@NonNull Size inputSize,
-            @NonNull Rect cropRect, int rotationDegrees, boolean mirroring) {
+            @CameraEffect.Formats int format, @NonNull Rect cropRect, int rotationDegrees,
+            boolean mirroring) {
         checkMainThread();
         checkNotClosed();
         checkAndSetHasConsumer();
@@ -325,7 +329,7 @@
                         return immediateFailedFuture(e);
                     }
                     SurfaceOutputImpl surfaceOutputImpl = new SurfaceOutputImpl(surface,
-                            getTargets(), mStreamSpec.getResolution(), inputSize, cropRect,
+                            getTargets(), format, mStreamSpec.getResolution(), inputSize, cropRect,
                             rotationDegrees, mirroring);
                     surfaceOutputImpl.getCloseFuture().addListener(
                             settableSurface::decrementUseCount,
@@ -359,7 +363,7 @@
         }
         disconnectWithoutCheckingClosed();
         mHasConsumer = false;
-        mSettableSurface = new SettableSurface(mStreamSpec.getResolution());
+        mSettableSurface = new SettableSurface(mStreamSpec.getResolution(), mFormat);
         for (Runnable onInvalidated : mOnInvalidatedListeners) {
             onInvalidated.run();
         }
@@ -417,6 +421,14 @@
     }
 
     /**
+     * Gets the buffer format of this edge.
+     */
+    @CameraEffect.Formats
+    public int getFormat() {
+        return mFormat;
+    }
+
+    /**
      * Gets the {@link Matrix} represents the transformation from camera sensor to the current
      * {@link Surface}.
      *
@@ -474,14 +486,16 @@
      * returned SurfaceRequest will receive the rotation update by
      * {@link SurfaceRequest.TransformationInfoListener}.
      */
-    @MainThread
     public void setRotationDegrees(int rotationDegrees) {
-        checkMainThread();
-        if (mRotationDegrees == rotationDegrees) {
-            return;
-        }
-        mRotationDegrees = rotationDegrees;
-        notifyTransformationInfoUpdate();
+        // This method is not limited to the main thread because UseCase#setTargetRotation calls
+        // this method and can be called from a background thread.
+        runOnMain(() -> {
+            if (mRotationDegrees == rotationDegrees) {
+                return;
+            }
+            mRotationDegrees = rotationDegrees;
+            notifyTransformationInfoUpdate();
+        });
     }
 
     @MainThread
@@ -558,8 +572,8 @@
 
         private DeferrableSurface mProvider;
 
-        SettableSurface(@NonNull Size size) {
-            super(size, ImageFormat.PRIVATE);
+        SettableSurface(@NonNull Size size, @CameraEffect.Formats int format) {
+            super(size, format);
         }
 
         @NonNull
@@ -605,6 +619,8 @@
                     + "SurfaceEdge#setProvider");
             checkArgument(getPrescribedSize().equals(provider.getPrescribedSize()),
                     "The provider's size must match the parent");
+            checkArgument(getPrescribedStreamFormat() == provider.getPrescribedStreamFormat(),
+                    "The provider's format must match the parent");
             checkState(!isClosed(), "The parent is closed. Call SurfaceEdge#invalidate() before "
                     + "setting a new provider.");
             mProvider = provider;
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/processing/SurfaceOutputImpl.java b/camera/camera-core/src/main/java/androidx/camera/core/processing/SurfaceOutputImpl.java
index d504cf8..7274e55 100644
--- a/camera/camera-core/src/main/java/androidx/camera/core/processing/SurfaceOutputImpl.java
+++ b/camera/camera-core/src/main/java/androidx/camera/core/processing/SurfaceOutputImpl.java
@@ -35,6 +35,7 @@
 import androidx.annotation.RequiresApi;
 import androidx.annotation.RestrictTo;
 import androidx.annotation.VisibleForTesting;
+import androidx.camera.core.CameraEffect;
 import androidx.camera.core.Logger;
 import androidx.camera.core.SurfaceOutput;
 import androidx.concurrent.futures.CallbackToFutureAdapter;
@@ -59,7 +60,10 @@
 
     @NonNull
     private final Surface mSurface;
+    @CameraEffect.Targets
     private final int mTargets;
+    @CameraEffect.Formats
+    private final int mFormat;
     @NonNull
     private final Size mSize;
     private final Size mInputSize;
@@ -86,8 +90,8 @@
 
     SurfaceOutputImpl(
             @NonNull Surface surface,
-            // TODO(b/238222270): annotate targets with IntDef.
-            int targets,
+            @CameraEffect.Targets int targets,
+            @CameraEffect.Formats int format,
             @NonNull Size size,
             @NonNull Size inputSize,
             @NonNull Rect inputCropRect,
@@ -95,6 +99,7 @@
             boolean mirroring) {
         mSurface = surface;
         mTargets = targets;
+        mFormat = format;
         mSize = size;
         mInputSize = inputSize;
         mInputCropRect = new Rect(inputCropRect);
@@ -166,6 +171,12 @@
         return mTargets;
     }
 
+    @CameraEffect.Formats
+    @Override
+    public int getFormat() {
+        return mFormat;
+    }
+
     /**
      * @inheritDoc
      */
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/processing/SurfaceProcessorNode.java b/camera/camera-core/src/main/java/androidx/camera/core/processing/SurfaceProcessorNode.java
index 3f4139a..8ce75c6 100644
--- a/camera/camera-core/src/main/java/androidx/camera/core/processing/SurfaceProcessorNode.java
+++ b/camera/camera-core/src/main/java/androidx/camera/core/processing/SurfaceProcessorNode.java
@@ -150,6 +150,7 @@
 
         outputSurface = new SurfaceEdge(
                 outConfig.getTargets(),
+                outConfig.getFormat(),
                 streamSpec,
                 sensorToBufferTransform,
                 // The Surface transform cannot be carried over during buffer copy.
@@ -199,6 +200,7 @@
             Map.Entry<OutConfig, SurfaceEdge> output) {
         ListenableFuture<SurfaceOutput> future = output.getValue().createSurfaceOutputFuture(
                 input.getStreamSpec().getResolution(),
+                output.getKey().getFormat(),
                 output.getKey().getCropRect(),
                 input.getRotationDegrees(),
                 output.getKey().getMirroring());
@@ -346,6 +348,12 @@
         abstract int getTargets();
 
         /**
+         * The format of the output stream.
+         */
+        @CameraEffect.Formats
+        abstract int getFormat();
+
+        /**
          * How the input should be cropped.
          */
         @NonNull
@@ -370,21 +378,24 @@
          * <p>The result is an output edge with the input's transformation applied.
          */
         @NonNull
-        public static OutConfig of(@NonNull SurfaceEdge surface) {
-            return of(surface.getTargets(),
-                    surface.getCropRect(),
-                    getRotatedSize(surface.getCropRect(), surface.getRotationDegrees()),
-                    surface.getMirroring());
+        public static OutConfig of(@NonNull SurfaceEdge inputEdge) {
+            return of(inputEdge.getTargets(),
+                    inputEdge.getFormat(),
+                    inputEdge.getCropRect(),
+                    getRotatedSize(inputEdge.getCropRect(), inputEdge.getRotationDegrees()),
+                    inputEdge.getMirroring());
         }
 
         /**
          * Creates an {@link OutConfig} instance with custom transformations.
          */
         @NonNull
-        public static OutConfig of(int targets, @NonNull Rect cropRect, @NonNull Size size,
-                boolean mirroring) {
-            return new AutoValue_SurfaceProcessorNode_OutConfig(randomUUID(), targets, cropRect,
-                    size, mirroring);
+        public static OutConfig of(@CameraEffect.Targets int targets,
+                @CameraEffect.Formats int format,
+                @NonNull Rect cropRect,
+                @NonNull Size size, boolean mirroring) {
+            return new AutoValue_SurfaceProcessorNode_OutConfig(randomUUID(), targets, format,
+                    cropRect, size, mirroring);
         }
     }
 }
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/streamsharing/StreamSharing.java b/camera/camera-core/src/main/java/androidx/camera/core/streamsharing/StreamSharing.java
index 54f0b6a..05cfcf5 100644
--- a/camera/camera-core/src/main/java/androidx/camera/core/streamsharing/StreamSharing.java
+++ b/camera/camera-core/src/main/java/androidx/camera/core/streamsharing/StreamSharing.java
@@ -18,6 +18,7 @@
 
 import static androidx.camera.core.CameraEffect.PREVIEW;
 import static androidx.camera.core.CameraEffect.VIDEO_CAPTURE;
+import static androidx.camera.core.impl.ImageFormatConstants.INTERNAL_DEFINED_IMAGE_FORMAT_PRIVATE;
 import static androidx.camera.core.impl.ImageInputConfig.OPTION_INPUT_FORMAT;
 import static androidx.camera.core.impl.utils.Threads.checkMainThread;
 import static androidx.core.util.Preconditions.checkNotNull;
@@ -176,9 +177,10 @@
         // Create input edge and the node.
         mCameraEdge = new SurfaceEdge(
                 /*targets=*/PREVIEW | VIDEO_CAPTURE,
+                INTERNAL_DEFINED_IMAGE_FORMAT_PRIVATE,
                 streamSpec,
                 getSensorToBufferTransformMatrix(),
-                getHasCameraTransform(),
+                camera.getHasTransform(),
                 requireNonNull(getCropRect(streamSpec.getResolution())),
                 /*rotationDegrees=*/0, // Rotation are handled by each child.
                 /*mirroring=*/false); // Mirroring will be decided by each child.
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/streamsharing/VirtualCamera.java b/camera/camera-core/src/main/java/androidx/camera/core/streamsharing/VirtualCamera.java
index 667b674..b6cd753 100644
--- a/camera/camera-core/src/main/java/androidx/camera/core/streamsharing/VirtualCamera.java
+++ b/camera/camera-core/src/main/java/androidx/camera/core/streamsharing/VirtualCamera.java
@@ -125,7 +125,6 @@
 
     void bindChildren() {
         for (UseCase useCase : mChildren) {
-            useCase.setHasCameraTransform(false);
             useCase.bindToCamera(this, null,
                     useCase.getDefaultConfig(true, mUseCaseConfigFactory));
         }
@@ -168,6 +167,7 @@
             boolean mirroring = useCase instanceof Preview && isFrontFacing();
             outConfigs.put(useCase, OutConfig.of(
                     target,
+                    INTERNAL_DEFINED_IMAGE_FORMAT_PRIVATE, // TODO: use JPEG for ImageCapture
                     cameraEdge.getCropRect(),
                     rectToSize(cameraEdge.getCropRect()),
                     mirroring));
@@ -262,6 +262,12 @@
     }
 
     // --- Forward parent camera properties and events ---
+
+    @Override
+    public boolean getHasTransform() {
+        return false;
+    }
+
     @NonNull
     @Override
     public CameraControlInternal getCameraControlInternal() {
diff --git a/camera/camera-core/src/test/java/androidx/camera/core/PreviewTest.kt b/camera/camera-core/src/test/java/androidx/camera/core/PreviewTest.kt
index e1271ec..4e3451f 100644
--- a/camera/camera-core/src/test/java/androidx/camera/core/PreviewTest.kt
+++ b/camera/camera-core/src/test/java/androidx/camera/core/PreviewTest.kt
@@ -20,6 +20,8 @@
 import android.graphics.Rect
 import android.graphics.SurfaceTexture
 import android.os.Build
+import android.os.Handler
+import android.os.HandlerThread
 import android.os.Looper.getMainLooper
 import android.util.Rational
 import android.util.Size
@@ -87,6 +89,8 @@
     private lateinit var processor: FakeSurfaceProcessorInternal
     private lateinit var effect: CameraEffect
 
+    private val handlersToRelease = mutableListOf<Handler>()
+
     @Before
     @Throws(ExecutionException::class, InterruptedException::class)
     fun setUp() {
@@ -129,6 +133,9 @@
         }
         processor.release()
         CameraXUtil.shutdown().get()
+        for (handler in handlersToRelease) {
+            handler.looper.quitSafely()
+        }
     }
 
     @Test
@@ -301,6 +308,25 @@
     }
 
     @Test
+    fun setTargetRotationWithProcessorOnBackground_rotationChangesOnSurfaceEdge() {
+        // Act: create pipeline
+        val preview = createPreview(effect)
+        // Act: update target rotation
+        preview.targetRotation = Surface.ROTATION_0
+        shadowOf(getMainLooper()).idle()
+        // Assert that the rotation of the SettableFuture is updated based on ROTATION_0.
+        assertThat(preview.cameraEdge.rotationDegrees).isEqualTo(0)
+
+        // Act: update target rotation again.
+        val backgroundHandler = createBackgroundHandler()
+        backgroundHandler.post { preview.targetRotation = Surface.ROTATION_180 }
+        shadowOf(backgroundHandler.looper).idle()
+        shadowOf(getMainLooper()).idle()
+        // Assert: the rotation of the SettableFuture is updated based on ROTATION_90.
+        assertThat(preview.cameraEdge.rotationDegrees).isEqualTo(180)
+    }
+
+    @Test
     fun invalidateAppSurfaceRequestWithProcessing_cameraNotReset() {
         // Arrange: create Preview with processing.
         val surfaceRequest = createPreview(effect).mCurrentSurfaceRequest
@@ -364,10 +390,10 @@
     @Test
     fun noCameraTransform_rotationDegreesFlipped() {
         // Act: create preview with hasCameraTransform == false
+        frontCamera.hasTransform = false
         val preview = createPreview(
             effect,
             frontCamera,
-            hasCameraTransform = false,
             targetRotation = Surface.ROTATION_90
         )
         // Assert: rotationDegrees is flipped
@@ -377,9 +403,10 @@
     @Test
     fun setNoCameraTransform_propagatesToCameraEdge() {
         // Act: create preview with hasCameraTransform == false
+        frontCamera.hasTransform = false
         val preview = createPreview(
             effect,
-            hasCameraTransform = false,
+            frontCamera,
             targetRotation = Surface.ROTATION_90
         )
         // Assert
@@ -390,10 +417,10 @@
     @Test
     fun frontCameraWithoutCameraTransform_noMirroring() {
         // Act: create preview with hasCameraTransform == false
+        frontCamera.hasTransform = false
         val preview = createPreview(
             effect,
             frontCamera,
-            hasCameraTransform = false,
             targetRotation = Surface.ROTATION_90
         )
         // Assert
@@ -644,13 +671,11 @@
     private fun createPreview(
         effect: CameraEffect? = null,
         camera: FakeCamera = backCamera,
-        hasCameraTransform: Boolean = true,
         targetRotation: Int = Surface.ROTATION_0
     ): Preview {
         previewToDetach = Preview.Builder()
             .setTargetRotation(targetRotation)
             .build()
-        previewToDetach.hasCameraTransform = hasCameraTransform
         previewToDetach.effect = effect
         previewToDetach.setSurfaceProvider(CameraXExecutors.directExecutor()) {}
         val previewConfig = PreviewConfig(
@@ -665,4 +690,13 @@
         previewToDetach.onSuggestedStreamSpecUpdated(streamSpec)
         return previewToDetach
     }
+
+    private fun createBackgroundHandler(): Handler {
+        val handler = Handler(HandlerThread("PreviewTest").run {
+            start()
+            looper
+        })
+        handlersToRelease.add(handler)
+        return handler
+    }
 }
\ No newline at end of file
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 fc30c6d..5d519520 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
@@ -162,11 +162,6 @@
         // Assert: StreamSharing children are bound
         assertThat(preview.camera).isNotNull()
         assertThat(video.camera).isNotNull()
-        // Assert: has camera transform bit.
-        assertThat(preview.hasCameraTransform).isFalse()
-        assertThat(video.hasCameraTransform).isFalse()
-        assertThat(image.hasCameraTransform).isTrue()
-        assertThat(adapter.getStreamSharing().hasCameraTransform).isTrue()
     }
 
     @Test
@@ -178,8 +173,6 @@
             Preview::class.java,
             FakeUseCase::class.java
         )
-        assertThat(preview.hasCameraTransform).isTrue()
-        assertThat(video.hasCameraTransform).isTrue()
     }
 
     @Test(expected = CameraException::class)
@@ -199,8 +192,6 @@
             FakeUseCase::class.java,
             ImageCapture::class.java
         )
-        assertThat(image.hasCameraTransform).isTrue()
-        assertThat(video.hasCameraTransform).isTrue()
         // Act: add a new UseCase that needs StreamSharing
         adapter.addUseCases(setOf(preview))
         // Assert: StreamSharing is created.
@@ -212,11 +203,6 @@
         assertThat(preview.camera).isNotNull()
         assertThat(video.camera).isNotNull()
         assertThat(image.camera).isNotNull()
-        // Assert: hasCameraTransform bit
-        assertThat(preview.hasCameraTransform).isFalse()
-        assertThat(video.hasCameraTransform).isFalse()
-        assertThat(image.hasCameraTransform).isTrue()
-        assertThat(adapter.getStreamSharing().hasCameraTransform).isTrue()
     }
 
     @Test
@@ -231,11 +217,6 @@
         val streamSharing =
             adapter.cameraUseCases.filterIsInstance(StreamSharing::class.java).single()
         assertThat(streamSharing.camera).isNotNull()
-        // Assert: hasCameraTransform bit
-        assertThat(preview.hasCameraTransform).isFalse()
-        assertThat(video.hasCameraTransform).isFalse()
-        assertThat(image.hasCameraTransform).isTrue()
-        assertThat(adapter.getStreamSharing().hasCameraTransform).isTrue()
         // Act: remove UseCase so that StreamSharing is no longer needed
         adapter.removeUseCases(setOf(video))
         // Assert: StreamSharing removed and unbound.
@@ -244,9 +225,6 @@
             ImageCapture::class.java
         )
         assertThat(streamSharing.camera).isNull()
-        // Assert: hasCameraTransform bit
-        assertThat(image.hasCameraTransform).isTrue()
-        assertThat(preview.hasCameraTransform).isTrue()
     }
 
     @Test(expected = CameraException::class)
diff --git a/camera/camera-core/src/test/java/androidx/camera/core/processing/SurfaceEdgeTest.kt b/camera/camera-core/src/test/java/androidx/camera/core/processing/SurfaceEdgeTest.kt
index 6aca2c6..fe42317 100644
--- a/camera/camera-core/src/test/java/androidx/camera/core/processing/SurfaceEdgeTest.kt
+++ b/camera/camera-core/src/test/java/androidx/camera/core/processing/SurfaceEdgeTest.kt
@@ -25,7 +25,6 @@
 import android.util.Range
 import android.util.Size
 import android.view.Surface
-import androidx.camera.core.CameraEffect
 import androidx.camera.core.CameraEffect.PREVIEW
 import androidx.camera.core.SurfaceOutput
 import androidx.camera.core.SurfaceRequest
@@ -34,6 +33,7 @@
 import androidx.camera.core.impl.DeferrableSurface
 import androidx.camera.core.impl.DeferrableSurface.SurfaceClosedException
 import androidx.camera.core.impl.DeferrableSurface.SurfaceUnavailableException
+import androidx.camera.core.impl.ImageFormatConstants.INTERNAL_DEFINED_IMAGE_FORMAT_PRIVATE
 import androidx.camera.core.impl.StreamSpec
 import androidx.camera.core.impl.utils.TransformUtils.sizeToRect
 import androidx.camera.core.impl.utils.executor.CameraXExecutors.mainThreadExecutor
@@ -76,8 +76,8 @@
     @Before
     fun setUp() {
         surfaceEdge = SurfaceEdge(
-            CameraEffect.PREVIEW, StreamSpec.builder(INPUT_SIZE).build(),
-            Matrix(), true, Rect(), 0, false
+            PREVIEW, INTERNAL_DEFINED_IMAGE_FORMAT_PRIVATE,
+            StreamSpec.builder(INPUT_SIZE).build(), Matrix(), true, Rect(), 0, false
         )
         fakeSurfaceTexture = SurfaceTexture(0)
         fakeSurface = Surface(fakeSurfaceTexture)
@@ -138,7 +138,14 @@
     @Test
     fun createWithStreamSpec_canGetStreamSpec() {
         val edge = SurfaceEdge(
-            PREVIEW, FRAME_SPEC, Matrix(), true, Rect(), 0, false
+            PREVIEW,
+            INTERNAL_DEFINED_IMAGE_FORMAT_PRIVATE,
+            FRAME_SPEC,
+            Matrix(),
+            true,
+            Rect(),
+            0,
+            false
         )
         assertThat(edge.streamSpec).isEqualTo(FRAME_SPEC)
     }
@@ -328,7 +335,8 @@
     private fun getSurfaceRequestHasTransform(hasCameraTransform: Boolean): Boolean {
         // Arrange.
         val surface = SurfaceEdge(
-            CameraEffect.PREVIEW,
+            PREVIEW,
+            INTERNAL_DEFINED_IMAGE_FORMAT_PRIVATE,
             StreamSpec.builder(Size(640, 480)).build(),
             Matrix(),
             hasCameraTransform,
@@ -518,6 +526,7 @@
     private fun createSurfaceOutputFuture(surfaceEdge: SurfaceEdge) =
         surfaceEdge.createSurfaceOutputFuture(
             INPUT_SIZE,
+            INTERNAL_DEFINED_IMAGE_FORMAT_PRIVATE,
             sizeToRect(INPUT_SIZE),
             /*rotationDegrees=*/0,
             /*mirroring=*/false
diff --git a/camera/camera-core/src/test/java/androidx/camera/core/processing/SurfaceOutputImplTest.kt b/camera/camera-core/src/test/java/androidx/camera/core/processing/SurfaceOutputImplTest.kt
index 641e279..9b9d618 100644
--- a/camera/camera-core/src/test/java/androidx/camera/core/processing/SurfaceOutputImplTest.kt
+++ b/camera/camera-core/src/test/java/androidx/camera/core/processing/SurfaceOutputImplTest.kt
@@ -22,6 +22,7 @@
 import android.util.Size
 import android.view.Surface
 import androidx.camera.core.CameraEffect
+import androidx.camera.core.impl.ImageFormatConstants.INTERNAL_DEFINED_IMAGE_FORMAT_PRIVATE
 import androidx.camera.core.impl.utils.TransformUtils.sizeToRect
 import androidx.camera.core.impl.utils.executor.CameraXExecutors.mainThreadExecutor
 import com.google.common.truth.Truth.assertThat
@@ -139,6 +140,7 @@
     private fun createFakeSurfaceOutputImpl() = SurfaceOutputImpl(
         fakeSurface,
         TARGET,
+        INTERNAL_DEFINED_IMAGE_FORMAT_PRIVATE,
         OUTPUT_SIZE,
         INPUT_SIZE,
         sizeToRect(INPUT_SIZE),
diff --git a/camera/camera-core/src/test/java/androidx/camera/core/processing/SurfaceProcessorNodeTest.kt b/camera/camera-core/src/test/java/androidx/camera/core/processing/SurfaceProcessorNodeTest.kt
index c949451..560070e 100644
--- a/camera/camera-core/src/test/java/androidx/camera/core/processing/SurfaceProcessorNodeTest.kt
+++ b/camera/camera-core/src/test/java/androidx/camera/core/processing/SurfaceProcessorNodeTest.kt
@@ -16,6 +16,7 @@
 
 package androidx.camera.core.processing
 
+import android.graphics.ImageFormat
 import android.graphics.Matrix
 import android.graphics.Rect
 import android.graphics.SurfaceTexture
@@ -24,11 +25,15 @@
 import android.util.Range
 import android.util.Size
 import android.view.Surface
+import androidx.camera.core.CameraEffect.IMAGE_CAPTURE
 import androidx.camera.core.CameraEffect.PREVIEW
 import androidx.camera.core.CameraEffect.VIDEO_CAPTURE
 import androidx.camera.core.SurfaceRequest
 import androidx.camera.core.SurfaceRequest.TransformationInfo
+import androidx.camera.core.impl.ImageFormatConstants.INTERNAL_DEFINED_IMAGE_FORMAT_PRIVATE
+import androidx.camera.core.impl.ImmediateSurface
 import androidx.camera.core.impl.StreamSpec
+import androidx.camera.core.impl.utils.TransformUtils
 import androidx.camera.core.impl.utils.TransformUtils.is90or270
 import androidx.camera.core.impl.utils.TransformUtils.rectToSize
 import androidx.camera.core.impl.utils.TransformUtils.rotateSize
@@ -36,6 +41,7 @@
 import androidx.camera.core.impl.utils.executor.CameraXExecutors.mainThreadExecutor
 import androidx.camera.core.processing.SurfaceProcessorNode.OutConfig
 import androidx.camera.testing.fakes.FakeCamera
+import androidx.camera.testing.fakes.FakeImageReaderProxy
 import androidx.camera.testing.fakes.FakeSurfaceProcessorInternal
 import com.google.common.truth.Truth.assertThat
 import org.junit.After
@@ -110,11 +116,61 @@
     }
 
     @Test
+    fun configureJpegOutput_returnsJpegFormat() {
+        // Arrange: configure node to produce JPEG output.
+        createSurfaceProcessorNode()
+        val inputEdge = SurfaceEdge(
+            PREVIEW,
+            INTERNAL_DEFINED_IMAGE_FORMAT_PRIVATE,
+            StreamSpec.builder(INPUT_SIZE).build(),
+            Matrix.IDENTITY_MATRIX,
+            true,
+            PREVIEW_CROP_RECT,
+            0,
+            false
+        )
+        val outConfig = OutConfig.of(
+            IMAGE_CAPTURE,
+            ImageFormat.JPEG,
+            inputEdge.cropRect,
+            TransformUtils.getRotatedSize(inputEdge.cropRect, inputEdge.rotationDegrees),
+            inputEdge.mirroring
+        )
+        nodeInput = SurfaceProcessorNode.In.of(inputEdge, listOf(outConfig))
+        // Act.
+        val out = node.transform(nodeInput)
+        shadowOf(getMainLooper()).idle()
+        // Assert: the output is JPEG format.
+        val outEdge = out[outConfig]!!
+        assertThat(outEdge.format).isEqualTo(ImageFormat.JPEG)
+        // Act: provides a JPEG Surface.
+        val imageReader = FakeImageReaderProxy.newInstance(
+            INPUT_SIZE.width,
+            INPUT_SIZE.height,
+            ImageFormat.JPEG,
+            1,
+            0
+        )
+        val outputDeferrableSurface = ImmediateSurface(
+            imageReader.surface!!,
+            Size(PREVIEW_CROP_RECT.width(), PREVIEW_CROP_RECT.height()),
+            ImageFormat.JPEG
+        )
+        outEdge.setProvider(outputDeferrableSurface)
+        shadowOf(getMainLooper()).idle()
+        // Assert: SurfaceProcessor receives a JPEG SurfaceOutput.
+        val imageCaptureOutput = surfaceProcessorInternal.surfaceOutputs[IMAGE_CAPTURE]!!
+        assertThat(imageCaptureOutput.format).isEqualTo(ImageFormat.JPEG)
+        imageReader.close()
+    }
+
+    @Test
     fun identicalOutConfigs_returnDifferentEdges() {
         // Arrange: create 2 OutConfig with identical values
         createSurfaceProcessorNode()
         val inputEdge = SurfaceEdge(
             PREVIEW,
+            INTERNAL_DEFINED_IMAGE_FORMAT_PRIVATE,
             StreamSpec.builder(INPUT_SIZE).build(),
             Matrix.IDENTITY_MATRIX,
             true,
@@ -124,9 +180,9 @@
         )
         val outConfig1 = OutConfig.of(inputEdge)
         val outConfig2 = OutConfig.of(inputEdge)
-        val input = SurfaceProcessorNode.In.of(inputEdge, listOf(outConfig1, outConfig2))
+        nodeInput = SurfaceProcessorNode.In.of(inputEdge, listOf(outConfig1, outConfig2))
         // Act.
-        val output = node.transform(input)
+        val output = node.transform(nodeInput)
         // Assert: there are two outputs
         assertThat(output).hasSize(2)
         // Cleanup
@@ -333,6 +389,7 @@
     ) {
         val inputEdge = SurfaceEdge(
             previewTarget,
+            INTERNAL_DEFINED_IMAGE_FORMAT_PRIVATE,
             StreamSpec.builder(previewSize).setExpectedFrameRateRange(frameRateRange).build(),
             sensorToBufferTransform,
             hasCameraTransform,
@@ -342,6 +399,7 @@
         )
         videoOutConfig = OutConfig.of(
             VIDEO_CAPTURE,
+            INTERNAL_DEFINED_IMAGE_FORMAT_PRIVATE,
             VIDEO_CROP_RECT,
             videoOutputSize,
             true
diff --git a/camera/camera-core/src/test/java/androidx/camera/core/streamsharing/StreamSharingTest.kt b/camera/camera-core/src/test/java/androidx/camera/core/streamsharing/StreamSharingTest.kt
index ec9354c..909d11d 100644
--- a/camera/camera-core/src/test/java/androidx/camera/core/streamsharing/StreamSharingTest.kt
+++ b/camera/camera-core/src/test/java/androidx/camera/core/streamsharing/StreamSharingTest.kt
@@ -171,6 +171,15 @@
     }
 
     @Test
+    fun bindChildToCamera_virtualCameraHasNoTransform() {
+        // Act.
+        streamSharing.bindToCamera(camera, null, null)
+        // Assert.
+        assertThat(child1.camera!!.hasTransform).isFalse()
+        assertThat(child2.camera!!.hasTransform).isFalse()
+    }
+
+    @Test
     fun bindAndUnbindParent_propagatesToChildren() {
         // Assert: children not bound to camera by default.
         assertThat(child1.camera).isNull()
diff --git a/camera/camera-core/src/test/java/androidx/camera/core/streamsharing/VirtualCameraTest.kt b/camera/camera-core/src/test/java/androidx/camera/core/streamsharing/VirtualCameraTest.kt
index 456858f..3a16e56 100644
--- a/camera/camera-core/src/test/java/androidx/camera/core/streamsharing/VirtualCameraTest.kt
+++ b/camera/camera-core/src/test/java/androidx/camera/core/streamsharing/VirtualCameraTest.kt
@@ -23,6 +23,7 @@
 import android.util.Size
 import androidx.camera.core.CameraEffect.PREVIEW
 import androidx.camera.core.UseCase
+import androidx.camera.core.impl.ImageFormatConstants.INTERNAL_DEFINED_IMAGE_FORMAT_PRIVATE
 import androidx.camera.core.impl.SessionConfig
 import androidx.camera.core.impl.SessionConfig.defaultEmptySessionConfig
 import androidx.camera.core.impl.StreamSpec
@@ -151,7 +152,14 @@
 
     private fun createSurfaceEdge(): SurfaceEdge {
         return SurfaceEdge(
-            PREVIEW, StreamSpec.builder(INPUT_SIZE).build(), Matrix(), true, Rect(), 0, false
+            PREVIEW,
+            INTERNAL_DEFINED_IMAGE_FORMAT_PRIVATE,
+            StreamSpec.builder(INPUT_SIZE).build(),
+            Matrix(),
+            true,
+            Rect(),
+            0,
+            false
         )
     }
 
diff --git a/camera/camera-lifecycle/src/androidTest/java/androidx/camera/lifecycle/LifecycleCameraRepositoryTest.java b/camera/camera-lifecycle/src/androidTest/java/androidx/camera/lifecycle/LifecycleCameraRepositoryTest.java
index 972d36c..2e8ba0a 100644
--- a/camera/camera-lifecycle/src/androidTest/java/androidx/camera/lifecycle/LifecycleCameraRepositoryTest.java
+++ b/camera/camera-lifecycle/src/androidTest/java/androidx/camera/lifecycle/LifecycleCameraRepositoryTest.java
@@ -16,13 +16,17 @@
 
 package androidx.camera.lifecycle;
 
+import static androidx.camera.core.concurrent.CameraCoordinator.CAMERA_OPERATING_MODE_CONCURRENT;
+
 import static com.google.common.truth.Truth.assertThat;
 
 import static java.util.Collections.emptyList;
 
+import androidx.camera.core.concurrent.CameraCoordinator;
 import androidx.camera.core.impl.CameraInternal;
 import androidx.camera.core.internal.CameraUseCaseAdapter;
 import androidx.camera.testing.fakes.FakeCamera;
+import androidx.camera.testing.fakes.FakeCameraCoordinator;
 import androidx.camera.testing.fakes.FakeCameraDeviceSurfaceManager;
 import androidx.camera.testing.fakes.FakeLifecycleOwner;
 import androidx.camera.testing.fakes.FakeUseCase;
@@ -47,6 +51,7 @@
 @SdkSuppress(minSdkVersion = 21)
 public final class LifecycleCameraRepositoryTest {
 
+    private CameraCoordinator mCameraCoordinator;
     private FakeLifecycleOwner mLifecycle;
     private LifecycleCameraRepository mRepository;
     private CameraUseCaseAdapter mCameraUseCaseAdapter;
@@ -55,6 +60,7 @@
 
     @Before
     public void setUp() {
+        mCameraCoordinator = new FakeCameraCoordinator();
         mLifecycle = new FakeLifecycleOwner();
         mRepository = new LifecycleCameraRepository();
         CameraInternal camera = new FakeCamera(String.valueOf(mCameraId));
@@ -119,7 +125,7 @@
         LifecycleCamera lifecycleCamera = mRepository.createLifecycleCamera(mLifecycle,
                 mCameraUseCaseAdapter);
         mRepository.bindToLifecycleCamera(lifecycleCamera, null, emptyList(),
-                Collections.singletonList(new FakeUseCase()));
+                Collections.singletonList(new FakeUseCase()), mCameraCoordinator);
         // LifecycleCamera is inactive before the lifecycle state becomes ON_START.
         assertThat(lifecycleCamera.isActive()).isFalse();
     }
@@ -129,7 +135,7 @@
         LifecycleCamera lifecycleCamera = mRepository.createLifecycleCamera(mLifecycle,
                 mCameraUseCaseAdapter);
         mRepository.bindToLifecycleCamera(lifecycleCamera, null, emptyList(),
-                Collections.singletonList(new FakeUseCase()));
+                Collections.singletonList(new FakeUseCase()), mCameraCoordinator);
         mLifecycle.start();
         // LifecycleCamera is active after the lifecycle state becomes ON_START.
         assertThat(lifecycleCamera.isActive()).isTrue();
@@ -141,7 +147,7 @@
                 mCameraUseCaseAdapter);
         mLifecycle.start();
         mRepository.bindToLifecycleCamera(lifecycleCamera, null, emptyList(),
-                Collections.singletonList(new FakeUseCase()));
+                Collections.singletonList(new FakeUseCase()), mCameraCoordinator);
 
         // LifecycleCamera is active after binding a use case when lifecycle state is ON_START.
         assertThat(lifecycleCamera.isActive()).isTrue();
@@ -153,13 +159,13 @@
         LifecycleCamera lifecycleCamera0 = mRepository.createLifecycleCamera(
                 mLifecycle, mCameraUseCaseAdapter);
         mRepository.bindToLifecycleCamera(lifecycleCamera0, null, emptyList(),
-                Collections.singletonList(new FakeUseCase()));
+                Collections.singletonList(new FakeUseCase()), mCameraCoordinator);
 
         // Creates second LifecycleCamera with use case bound to the same Lifecycle.
         LifecycleCamera lifecycleCamera1 = mRepository.createLifecycleCamera(mLifecycle,
                 createNewCameraUseCaseAdapter());
         mRepository.bindToLifecycleCamera(lifecycleCamera1, null, emptyList(),
-                Collections.singletonList(new FakeUseCase()));
+                Collections.singletonList(new FakeUseCase()), mCameraCoordinator);
     }
 
     @Test
@@ -170,7 +176,7 @@
         mLifecycle.start();
         FakeUseCase useCase = new FakeUseCase();
         mRepository.bindToLifecycleCamera(lifecycleCamera, null, emptyList(),
-                Collections.singletonList(useCase));
+                Collections.singletonList(useCase), mCameraCoordinator);
 
         // Unbinds the use case that was bound previously.
         mRepository.unbind(Collections.singletonList(useCase));
@@ -189,7 +195,7 @@
         FakeUseCase useCase0 = new FakeUseCase();
         FakeUseCase useCase1 = new FakeUseCase();
         mRepository.bindToLifecycleCamera(lifecycleCamera, null, emptyList(),
-                Arrays.asList(useCase0, useCase1));
+                Arrays.asList(useCase0, useCase1), mCameraCoordinator);
 
         // Only unbinds one use case but another one is kept in the LifecycleCamera.
         mRepository.unbind(Collections.singletonList(useCase0));
@@ -206,7 +212,7 @@
                 mCameraUseCaseAdapter);
         mLifecycle.start();
         mRepository.bindToLifecycleCamera(lifecycleCamera, null, emptyList(),
-                Collections.singletonList(new FakeUseCase()));
+                Collections.singletonList(new FakeUseCase()), mCameraCoordinator);
 
         // Unbinds all use cases from all LifecycleCamera by the unbindAll() API.
         mRepository.unbindAll();
@@ -222,7 +228,7 @@
                 mCameraUseCaseAdapter);
         mLifecycle.start();
         mRepository.bindToLifecycleCamera(lifecycleCamera0, null, emptyList(),
-                Collections.singletonList(new FakeUseCase()));
+                Collections.singletonList(new FakeUseCase()), mCameraCoordinator);
 
         // Starts second lifecycle with use case bound.
         FakeLifecycleOwner lifecycle1 = new FakeLifecycleOwner();
@@ -230,7 +236,7 @@
                 createNewCameraUseCaseAdapter());
         lifecycle1.start();
         mRepository.bindToLifecycleCamera(lifecycleCamera1, null, emptyList(),
-                Collections.singletonList(new FakeUseCase()));
+                Collections.singletonList(new FakeUseCase()), mCameraCoordinator);
 
         // The previous LifecycleCamera becomes inactive after new LifecycleCamera becomes active.
         assertThat(lifecycleCamera0.isActive()).isFalse();
@@ -245,7 +251,7 @@
                 mCameraUseCaseAdapter);
         mLifecycle.start();
         mRepository.bindToLifecycleCamera(lifecycleCamera0, null, emptyList(),
-                Collections.singletonList(new FakeUseCase()));
+                Collections.singletonList(new FakeUseCase()), mCameraCoordinator);
 
         // Starts second lifecycle with use case bound.
         FakeLifecycleOwner lifecycle1 = new FakeLifecycleOwner();
@@ -253,11 +259,11 @@
                 createNewCameraUseCaseAdapter());
         lifecycle1.start();
         mRepository.bindToLifecycleCamera(lifecycleCamera1, null, emptyList(),
-                Collections.singletonList(new FakeUseCase()));
+                Collections.singletonList(new FakeUseCase()), mCameraCoordinator);
 
         // Binds new use case to the next most recent active LifecycleCamera.
         mRepository.bindToLifecycleCamera(lifecycleCamera0, null, emptyList(),
-                Collections.singletonList(new FakeUseCase()));
+                Collections.singletonList(new FakeUseCase()), mCameraCoordinator);
 
         // The next most recent active LifecycleCamera becomes active after binding new use case.
         assertThat(lifecycleCamera0.isActive()).isTrue();
@@ -273,7 +279,7 @@
                 mCameraUseCaseAdapter);
         mLifecycle.start();
         mRepository.bindToLifecycleCamera(lifecycleCamera0, null, emptyList(),
-                Collections.singletonList(new FakeUseCase()));
+                Collections.singletonList(new FakeUseCase()), mCameraCoordinator);
 
         // Starts second lifecycle with use case bound.
         FakeLifecycleOwner lifecycle1 = new FakeLifecycleOwner();
@@ -282,7 +288,7 @@
         lifecycle1.start();
         FakeUseCase useCase = new FakeUseCase();
         mRepository.bindToLifecycleCamera(lifecycleCamera1, null, emptyList(),
-                Collections.singletonList(useCase));
+                Collections.singletonList(useCase), mCameraCoordinator);
 
         // Unbinds use case from the most recent active LifecycleCamera.
         mRepository.unbind(Collections.singletonList(useCase));
@@ -301,7 +307,7 @@
                 mLifecycle, mCameraUseCaseAdapter);
         FakeUseCase useCase = new FakeUseCase();
         mRepository.bindToLifecycleCamera(lifecycleCamera, null, emptyList(),
-                Collections.singletonList(useCase));
+                Collections.singletonList(useCase), mCameraCoordinator);
 
         assertThat(useCase.isDetached()).isFalse();
 
@@ -324,7 +330,7 @@
         LifecycleCamera firstLifecycleCamera = mRepository.createLifecycleCamera(
                 mLifecycle, mCameraUseCaseAdapter);
         mRepository.bindToLifecycleCamera(firstLifecycleCamera, null, emptyList(),
-                Collections.singletonList(new FakeUseCase()));
+                Collections.singletonList(new FakeUseCase()), mCameraCoordinator);
         mLifecycle.start();
         assertThat(firstLifecycleCamera.isActive()).isTrue();
 
@@ -333,7 +339,7 @@
         LifecycleCamera secondLifecycleCamera = mRepository.createLifecycleCamera(secondLifecycle,
                 createNewCameraUseCaseAdapter());
         mRepository.bindToLifecycleCamera(secondLifecycleCamera, null, emptyList(),
-                Collections.singletonList(new FakeUseCase()));
+                Collections.singletonList(new FakeUseCase()), mCameraCoordinator);
         secondLifecycle.start();
         assertThat(secondLifecycleCamera.isActive()).isTrue();
         assertThat(firstLifecycleCamera.isActive()).isFalse();
@@ -345,7 +351,7 @@
         LifecycleCamera firstLifecycleCamera = mRepository.createLifecycleCamera(
                 mLifecycle, mCameraUseCaseAdapter);
         mRepository.bindToLifecycleCamera(firstLifecycleCamera, null, emptyList(),
-                Collections.singletonList(new FakeUseCase()));
+                Collections.singletonList(new FakeUseCase()), mCameraCoordinator);
         mLifecycle.start();
         assertThat(firstLifecycleCamera.isActive()).isTrue();
 
@@ -354,7 +360,7 @@
         LifecycleCamera secondLifecycleCamera = mRepository.createLifecycleCamera(secondLifecycle,
                 createNewCameraUseCaseAdapter());
         mRepository.bindToLifecycleCamera(secondLifecycleCamera, null, emptyList(),
-                Collections.singletonList(new FakeUseCase()));
+                Collections.singletonList(new FakeUseCase()), mCameraCoordinator);
         secondLifecycle.start();
         assertThat(secondLifecycleCamera.isActive()).isTrue();
         assertThat(firstLifecycleCamera.isActive()).isFalse();
@@ -371,7 +377,7 @@
         LifecycleCamera firstLifecycleCamera = mRepository.createLifecycleCamera(
                 mLifecycle, mCameraUseCaseAdapter);
         mRepository.bindToLifecycleCamera(firstLifecycleCamera, null, emptyList(),
-                Collections.singletonList(new FakeUseCase()));
+                Collections.singletonList(new FakeUseCase()), mCameraCoordinator);
         mLifecycle.start();
         assertThat(firstLifecycleCamera.isActive()).isTrue();
 
@@ -398,7 +404,7 @@
         LifecycleCamera lifecycleCamera1 = mRepository.createLifecycleCamera(
                 mLifecycle, createNewCameraUseCaseAdapter());
         mRepository.bindToLifecycleCamera(lifecycleCamera1, null, emptyList(),
-                Collections.singletonList(new FakeUseCase()));
+                Collections.singletonList(new FakeUseCase()), mCameraCoordinator);
 
         // Starts third LifecycleCamera with no use case bound to the same Lifecycle.
         LifecycleCamera lifecycleCamera2 = mRepository.createLifecycleCamera(
@@ -453,7 +459,7 @@
         LifecycleCamera lifecycleCamera = mRepository.createLifecycleCamera(
                 mLifecycle, mCameraUseCaseAdapter);
         mRepository.bindToLifecycleCamera(lifecycleCamera, null, emptyList(),
-                Collections.singletonList(new FakeUseCase()));
+                Collections.singletonList(new FakeUseCase()), mCameraCoordinator);
         mLifecycle.start();
         assertThat(lifecycleCamera.isActive()).isTrue();
 
@@ -464,6 +470,113 @@
         mRepository.setInactive(mLifecycle);
     }
 
+    @Test
+    public void concurrentModeOn_twoLifecycleCamerasControlledByOneLifecycle_start() {
+        mCameraCoordinator.setCameraOperatingMode(CAMERA_OPERATING_MODE_CONCURRENT);
+
+        // Starts first lifecycle camera
+        LifecycleCamera lifecycleCamera0 = mRepository.createLifecycleCamera(mLifecycle,
+                mCameraUseCaseAdapter);
+        mRepository.bindToLifecycleCamera(lifecycleCamera0, null, emptyList(),
+                Collections.singletonList(new FakeUseCase()), mCameraCoordinator);
+
+        // Starts second lifecycle camera
+        LifecycleCamera lifecycleCamera1 = mRepository.createLifecycleCamera(mLifecycle,
+                createNewCameraUseCaseAdapter());
+        mRepository.bindToLifecycleCamera(lifecycleCamera1, null, emptyList(),
+                Collections.singletonList(new FakeUseCase()), mCameraCoordinator);
+
+        // Starts lifecycle
+        mLifecycle.start();
+
+        // Both cameras are active in concurrent mode
+        assertThat(lifecycleCamera0.isActive()).isTrue();
+        assertThat(lifecycleCamera1.isActive()).isTrue();
+    }
+
+    @Test
+    public void concurrentModeOn_twoLifecycleCamerasControlledByTwoLifecycles_start() {
+        mCameraCoordinator.setCameraOperatingMode(CAMERA_OPERATING_MODE_CONCURRENT);
+
+        // Starts first lifecycle camera
+        LifecycleCamera lifecycleCamera0 = mRepository.createLifecycleCamera(mLifecycle,
+                mCameraUseCaseAdapter);
+        mRepository.bindToLifecycleCamera(lifecycleCamera0, null, emptyList(),
+                Collections.singletonList(new FakeUseCase()), mCameraCoordinator);
+
+        // Starts lifecycle
+        mLifecycle.start();
+
+        // Starts second lifecycle camera
+        FakeLifecycleOwner lifecycle1 = new FakeLifecycleOwner();
+        LifecycleCamera lifecycleCamera1 = mRepository.createLifecycleCamera(lifecycle1,
+                createNewCameraUseCaseAdapter());
+        mRepository.bindToLifecycleCamera(lifecycleCamera1, null, emptyList(),
+                Collections.singletonList(new FakeUseCase()), mCameraCoordinator);
+
+        // Starts lifecycle1
+        lifecycle1.start();
+
+        // Both cameras are active in concurrent mode
+        assertThat(lifecycleCamera0.isActive()).isTrue();
+        assertThat(lifecycleCamera1.isActive()).isTrue();
+    }
+
+    @Test
+    public void concurrentModeOn_twoLifecycleCamerasControlledByOneLifecycle_stop() {
+        mCameraCoordinator.setCameraOperatingMode(CAMERA_OPERATING_MODE_CONCURRENT);
+
+        // Starts first lifecycle camera
+        LifecycleCamera firstLifecycleCamera = mRepository.createLifecycleCamera(
+                mLifecycle, mCameraUseCaseAdapter);
+        mRepository.bindToLifecycleCamera(firstLifecycleCamera, null, emptyList(),
+                Collections.singletonList(new FakeUseCase()), mCameraCoordinator);
+
+        // Starts second lifecycle camera
+        LifecycleCamera secondLifecycleCamera = mRepository.createLifecycleCamera(mLifecycle,
+                createNewCameraUseCaseAdapter());
+        mRepository.bindToLifecycleCamera(secondLifecycleCamera, null, emptyList(),
+                Collections.singletonList(new FakeUseCase()), mCameraCoordinator);
+
+        // Starts lifecycle
+        mLifecycle.start();
+        assertThat(secondLifecycleCamera.isActive()).isTrue();
+        assertThat(firstLifecycleCamera.isActive()).isTrue();
+
+        // Stops lifecycle
+        mLifecycle.stop();
+        assertThat(secondLifecycleCamera.isActive()).isFalse();
+        assertThat(firstLifecycleCamera.isActive()).isFalse();
+    }
+
+    @Test
+    public void concurrentModeOn_twoLifecycleCamerasControlledByTwoLifecycles_stop() {
+        mCameraCoordinator.setCameraOperatingMode(CAMERA_OPERATING_MODE_CONCURRENT);
+
+        // Starts first lifecycle camera
+        LifecycleCamera firstLifecycleCamera = mRepository.createLifecycleCamera(
+                mLifecycle, mCameraUseCaseAdapter);
+        mRepository.bindToLifecycleCamera(firstLifecycleCamera, null, emptyList(),
+                Collections.singletonList(new FakeUseCase()), mCameraCoordinator);
+        mLifecycle.start();
+        assertThat(firstLifecycleCamera.isActive()).isTrue();
+
+        // Starts second lifecycle camera
+        FakeLifecycleOwner secondLifecycle = new FakeLifecycleOwner();
+        LifecycleCamera secondLifecycleCamera = mRepository.createLifecycleCamera(secondLifecycle,
+                createNewCameraUseCaseAdapter());
+        mRepository.bindToLifecycleCamera(secondLifecycleCamera, null, emptyList(),
+                Collections.singletonList(new FakeUseCase()), mCameraCoordinator);
+        secondLifecycle.start();
+        assertThat(secondLifecycleCamera.isActive()).isTrue();
+        assertThat(firstLifecycleCamera.isActive()).isTrue();
+
+        // Stops lifecycle
+        secondLifecycle.stop();
+        assertThat(secondLifecycleCamera.isActive()).isFalse();
+        assertThat(firstLifecycleCamera.isActive()).isTrue();
+    }
+
     private CameraUseCaseAdapter createNewCameraUseCaseAdapter() {
         String cameraId = String.valueOf(++mCameraId);
         CameraInternal fakeCamera = new FakeCamera(cameraId);
diff --git a/camera/camera-lifecycle/src/androidTest/java/androidx/camera/lifecycle/ProcessCameraProviderTest.kt b/camera/camera-lifecycle/src/androidTest/java/androidx/camera/lifecycle/ProcessCameraProviderTest.kt
index 0c38d7a..1bf7e70 100644
--- a/camera/camera-lifecycle/src/androidTest/java/androidx/camera/lifecycle/ProcessCameraProviderTest.kt
+++ b/camera/camera-lifecycle/src/androidTest/java/androidx/camera/lifecycle/ProcessCameraProviderTest.kt
@@ -23,6 +23,8 @@
 import androidx.annotation.OptIn
 import androidx.annotation.RequiresApi
 import androidx.camera.core.CameraSelector
+import androidx.camera.core.CameraSelector.LENS_FACING_BACK
+import androidx.camera.core.CameraSelector.LENS_FACING_FRONT
 import androidx.camera.core.CameraXConfig
 import androidx.camera.core.Preview
 import androidx.camera.core.UseCaseGroup
@@ -32,6 +34,7 @@
 import androidx.camera.core.impl.utils.executor.CameraXExecutors.mainThreadExecutor
 import androidx.camera.testing.fakes.FakeAppConfig
 import androidx.camera.testing.fakes.FakeCamera
+import androidx.camera.testing.fakes.FakeCameraCoordinator
 import androidx.camera.testing.fakes.FakeCameraDeviceSurfaceManager
 import androidx.camera.testing.fakes.FakeCameraFactory
 import androidx.camera.testing.fakes.FakeCameraInfoInternal
@@ -97,6 +100,7 @@
 
             // Assert.
             assertThat(preview.effect).isEqualTo(effect)
+            assertThat(provider.isConcurrentCameraModeOn).isFalse()
         }
     }
 
@@ -201,6 +205,7 @@
             val camera =
                 provider.bindToLifecycle(lifecycleOwner0, CameraSelector.DEFAULT_BACK_CAMERA)
             assertThat(camera).isNotNull()
+            assertThat(provider.isConcurrentCameraModeOn).isFalse()
         }
     }
 
@@ -218,6 +223,7 @@
             )
 
             assertThat(provider.isBound(useCase)).isTrue()
+            assertThat(provider.isConcurrentCameraModeOn).isFalse()
         }
     }
 
@@ -246,6 +252,7 @@
             assertThat(provider.isBound(useCase0)).isTrue()
             assertThat(useCase1.camera).isNotNull()
             assertThat(provider.isBound(useCase1)).isTrue()
+            assertThat(provider.isConcurrentCameraModeOn).isFalse()
         }
     }
 
@@ -267,6 +274,7 @@
             provider.unbind(useCase)
 
             assertThat(provider.isBound(useCase)).isFalse()
+            assertThat(provider.isConcurrentCameraModeOn).isFalse()
         }
     }
 
@@ -291,6 +299,7 @@
             assertThat(provider.isBound(useCase0)).isFalse()
             assertThat(useCase1.camera).isNotNull()
             assertThat(provider.isBound(useCase1)).isTrue()
+            assertThat(provider.isConcurrentCameraModeOn).isFalse()
         }
     }
 
@@ -311,6 +320,7 @@
 
             assertThat(useCase.camera).isNull()
             assertThat(provider.isBound(useCase)).isFalse()
+            assertThat(provider.isConcurrentCameraModeOn).isFalse()
         }
     }
 
@@ -330,6 +340,7 @@
 
             assertThat(provider.isBound(useCase0)).isTrue()
             assertThat(provider.isBound(useCase1)).isTrue()
+            assertThat(provider.isConcurrentCameraModeOn).isFalse()
         }
     }
 
@@ -353,6 +364,7 @@
             )
 
             assertThat(camera0).isNotEqualTo(camera1)
+            assertThat(provider.isConcurrentCameraModeOn).isFalse()
         }
     }
 
@@ -368,6 +380,7 @@
             assertThrows<IllegalArgumentException> {
                 provider.bindToLifecycle(lifecycleOwner0, CameraSelector.DEFAULT_BACK_CAMERA)
             }
+            assertThat(provider.isConcurrentCameraModeOn).isFalse()
         }
     }
 
@@ -394,6 +407,7 @@
             )
 
             assertThat(camera0).isSameInstanceAs(camera1)
+            assertThat(provider.isConcurrentCameraModeOn).isFalse()
         }
     }
 
@@ -416,6 +430,7 @@
                     useCase1
                 )
             }
+            assertThat(provider.isConcurrentCameraModeOn).isFalse()
         }
     }
 
@@ -444,6 +459,7 @@
             )
 
             assertThat(camera0).isNotEqualTo(camera1)
+            assertThat(provider.isConcurrentCameraModeOn).isFalse()
         }
     }
 
@@ -464,6 +480,7 @@
                         )
                     )
                 }
+                cameraFactory.cameraCoordinator = FakeCameraCoordinator()
                 cameraFactory
             }
 
@@ -488,6 +505,7 @@
                     useCase
                 )
             }
+            assertThat(provider.isConcurrentCameraModeOn).isFalse()
         }
     }
 
@@ -501,6 +519,7 @@
                     LifecycleCamera
             lifecycleOwner0.startAndResume()
             assertThat(camera.isActive).isFalse()
+            assertThat(provider.isConcurrentCameraModeOn).isFalse()
         }
     }
 
@@ -514,6 +533,7 @@
                 provider.bindToLifecycle(lifecycleOwner0, CameraSelector.DEFAULT_BACK_CAMERA) as
                     LifecycleCamera
             assertThat(camera.isActive).isFalse()
+            assertThat(provider.isConcurrentCameraModeOn).isFalse()
         }
     }
 
@@ -530,6 +550,7 @@
                 ) as LifecycleCamera
             lifecycleOwner0.startAndResume()
             assertThat(camera.isActive).isTrue()
+            assertThat(provider.isConcurrentCameraModeOn).isFalse()
         }
     }
 
@@ -546,6 +567,7 @@
                     useCase
                 ) as LifecycleCamera
             assertThat(camera.isActive).isTrue()
+            assertThat(provider.isConcurrentCameraModeOn).isFalse()
         }
     }
 
@@ -564,6 +586,7 @@
             assertThat(camera.isActive).isTrue()
             provider.unbind(useCase)
             assertThat(camera.isActive).isFalse()
+            assertThat(provider.isConcurrentCameraModeOn).isFalse()
         }
     }
 
@@ -582,6 +605,7 @@
             assertThat(camera.isActive).isTrue()
             provider.unbindAll()
             assertThat(camera.isActive).isFalse()
+            assertThat(provider.isConcurrentCameraModeOn).isFalse()
         }
     }
 
@@ -611,6 +635,17 @@
     }
 
     @Test
+    fun getAvailableConcurrentCameraInfos() {
+        ProcessCameraProvider.configureInstance(createConcurrentCameraAppConfig())
+        runBlocking {
+            provider = ProcessCameraProvider.getInstance(context).await()
+            assertThat(provider.availableConcurrentCameraInfos.size).isEqualTo(2)
+            assertThat(provider.availableConcurrentCameraInfos[0].size).isEqualTo(2)
+            assertThat(provider.availableConcurrentCameraInfos[1].size).isEqualTo(2)
+        }
+    }
+
+    @Test
     fun cannotConfigureTwice() {
         ProcessCameraProvider.configureInstance(FakeAppConfig.create())
         assertThrows<IllegalStateException> {
@@ -634,7 +669,7 @@
 
     @Test
     fun bindConcurrentCamera_isBound() {
-        ProcessCameraProvider.configureInstance(FakeAppConfig.create())
+        ProcessCameraProvider.configureInstance(createConcurrentCameraAppConfig())
 
         runBlocking(MainScope().coroutineContext) {
             provider = ProcessCameraProvider.getInstance(context).await()
@@ -666,12 +701,72 @@
             assertThat(concurrentCamera.cameras.size).isEqualTo(2)
             assertThat(provider.isBound(useCase0)).isTrue()
             assertThat(provider.isBound(useCase1)).isTrue()
+            assertThat(provider.isConcurrentCameraModeOn).isTrue()
+        }
+    }
+
+    @Test
+    fun bindConcurrentCameraTwice_isBound() {
+        ProcessCameraProvider.configureInstance(createConcurrentCameraAppConfig())
+
+        runBlocking(MainScope().coroutineContext) {
+            provider = ProcessCameraProvider.getInstance(context).await()
+            val useCase0 = Preview.Builder().setSessionOptionUnpacker { _, _ -> }.build()
+            val useCase1 = Preview.Builder().setSessionOptionUnpacker { _, _ -> }.build()
+            val useCase2 = Preview.Builder().setSessionOptionUnpacker { _, _ -> }.build()
+
+            val singleCameraConfig0 = SingleCameraConfig.Builder()
+                .setCameraSelector(CameraSelector.DEFAULT_BACK_CAMERA)
+                .setUseCaseGroup(UseCaseGroup.Builder()
+                    .addUseCase(useCase0)
+                    .build())
+                .setLifecycleOwner(lifecycleOwner0)
+                .build()
+            val singleCameraConfig1 = SingleCameraConfig.Builder()
+                .setCameraSelector(CameraSelector.DEFAULT_FRONT_CAMERA)
+                .setUseCaseGroup(UseCaseGroup.Builder()
+                    .addUseCase(useCase1)
+                    .build())
+                .setLifecycleOwner(lifecycleOwner1)
+                .build()
+
+            val singleCameraConfig2 = SingleCameraConfig.Builder()
+                .setCameraSelector(CameraSelector.DEFAULT_FRONT_CAMERA)
+                .setUseCaseGroup(UseCaseGroup.Builder()
+                    .addUseCase(useCase2)
+                    .build())
+                .setLifecycleOwner(lifecycleOwner1)
+                .build()
+
+            val concurrentCameraConfig0 = ConcurrentCameraConfig.Builder()
+                .setCameraConfigs(listOf(singleCameraConfig0, singleCameraConfig1))
+                .build()
+
+            val concurrentCamera0 = provider.bindToLifecycle(concurrentCameraConfig0)
+
+            assertThat(concurrentCamera0).isNotNull()
+            assertThat(concurrentCamera0.cameras.size).isEqualTo(2)
+            assertThat(provider.isBound(useCase0)).isTrue()
+            assertThat(provider.isBound(useCase1)).isTrue()
+            assertThat(provider.isConcurrentCameraModeOn).isTrue()
+
+            val concurrentCameraConfig1 = ConcurrentCameraConfig.Builder()
+                .setCameraConfigs(listOf(singleCameraConfig0, singleCameraConfig2))
+                .build()
+
+            val concurrentCamera1 = provider.bindToLifecycle(concurrentCameraConfig1)
+
+            assertThat(concurrentCamera1).isNotNull()
+            assertThat(concurrentCamera1.cameras.size).isEqualTo(2)
+            assertThat(provider.isBound(useCase0)).isTrue()
+            assertThat(provider.isBound(useCase2)).isTrue()
+            assertThat(provider.isConcurrentCameraModeOn).isTrue()
         }
     }
 
     @Test
     fun bindConcurrentCamera_lessThanTwoSingleCameraConfigs() {
-        ProcessCameraProvider.configureInstance(FakeAppConfig.create())
+        ProcessCameraProvider.configureInstance(createConcurrentCameraAppConfig())
 
         runBlocking(MainScope().coroutineContext) {
             provider = ProcessCameraProvider.getInstance(context).await()
@@ -692,12 +787,13 @@
             assertThrows<IllegalArgumentException> {
                 provider.bindToLifecycle(concurrentCameraConfig)
             }
+            assertThat(provider.isConcurrentCameraModeOn).isFalse()
         }
     }
 
     @Test
     fun bindConcurrentCamera_moreThanTwoSingleCameraConfigs() {
-        ProcessCameraProvider.configureInstance(FakeAppConfig.create())
+        ProcessCameraProvider.configureInstance(createConcurrentCameraAppConfig())
 
         runBlocking(MainScope().coroutineContext) {
             provider = ProcessCameraProvider.getInstance(context).await()
@@ -737,8 +833,70 @@
             assertThrows<UnsupportedOperationException> {
                 provider.bindToLifecycle(concurrentCameraConfig)
             }
+            assertThat(provider.isConcurrentCameraModeOn).isFalse()
         }
     }
+
+    private fun createConcurrentCameraAppConfig(): CameraXConfig {
+        val cameraCoordinator = FakeCameraCoordinator()
+        val combination0 = mapOf(
+            "0" to CameraSelector.Builder().requireLensFacing(LENS_FACING_BACK).build(),
+            "1" to CameraSelector.Builder().requireLensFacing(LENS_FACING_FRONT).build())
+        val combination1 = mapOf(
+            "0" to CameraSelector.Builder().requireLensFacing(LENS_FACING_BACK).build(),
+            "2" to CameraSelector.Builder().requireLensFacing(LENS_FACING_FRONT).build())
+
+        cameraCoordinator.addConcurrentCameraIdsAndCameraSelectors(combination0)
+        cameraCoordinator.addConcurrentCameraIdsAndCameraSelectors(combination1)
+        val cameraFactoryProvider =
+            CameraFactory.Provider { _, _, _ ->
+                val cameraFactory = FakeCameraFactory()
+                cameraFactory.insertCamera(
+                    CameraSelector.LENS_FACING_BACK,
+                    "0"
+                ) {
+                    FakeCamera(
+                        "0", null,
+                        FakeCameraInfoInternal(
+                            "0", 0,
+                            CameraSelector.LENS_FACING_BACK
+                        )
+                    )
+                }
+                cameraFactory.insertCamera(
+                    CameraSelector.LENS_FACING_FRONT,
+                    "1"
+                ) {
+                    FakeCamera(
+                        "1", null,
+                        FakeCameraInfoInternal(
+                            "1", 0,
+                            CameraSelector.LENS_FACING_FRONT
+                        )
+                    )
+                }
+                cameraFactory.insertCamera(
+                    CameraSelector.LENS_FACING_FRONT,
+                    "2"
+                ) {
+                    FakeCamera(
+                        "2", null,
+                        FakeCameraInfoInternal(
+                            "2", 0,
+                            CameraSelector.LENS_FACING_FRONT
+                        )
+                    )
+                }
+                cameraFactory.cameraCoordinator = cameraCoordinator
+                cameraFactory
+            }
+        val appConfigBuilder = CameraXConfig.Builder()
+            .setCameraFactoryProvider(cameraFactoryProvider)
+            .setDeviceSurfaceManagerProvider { _, _, _ -> FakeCameraDeviceSurfaceManager() }
+            .setUseCaseConfigFactoryProvider { FakeUseCaseConfigFactory() }
+
+        return appConfigBuilder.build()
+    }
 }
 
 private class TestAppContextWrapper(base: Context, val app: Application? = null) :
diff --git a/camera/camera-lifecycle/src/main/java/androidx/camera/lifecycle/LifecycleCameraRepository.java b/camera/camera-lifecycle/src/main/java/androidx/camera/lifecycle/LifecycleCameraRepository.java
index 706c842..3b8fae9 100644
--- a/camera/camera-lifecycle/src/main/java/androidx/camera/lifecycle/LifecycleCameraRepository.java
+++ b/camera/camera-lifecycle/src/main/java/androidx/camera/lifecycle/LifecycleCameraRepository.java
@@ -23,6 +23,7 @@
 import androidx.camera.core.CameraEffect;
 import androidx.camera.core.UseCase;
 import androidx.camera.core.ViewPort;
+import androidx.camera.core.concurrent.CameraCoordinator;
 import androidx.camera.core.impl.CameraInternal;
 import androidx.camera.core.internal.CameraUseCaseAdapter;
 import androidx.core.util.Preconditions;
@@ -81,6 +82,9 @@
     @GuardedBy("mLock")
     private final ArrayDeque<LifecycleOwner> mActiveLifecycleOwners = new ArrayDeque<>();
 
+    @GuardedBy("mLock")
+    @Nullable CameraCoordinator mCameraCoordinator;
+
     /**
      * Create a new {@link LifecycleCamera} associated with the given {@link LifecycleOwner}.
      *
@@ -253,15 +257,21 @@
      * @param viewPort The viewport which represents the visible camera sensor rect.
      * @param effects The effects applied to the camera outputs.
      * @param useCases The use cases to bind to a lifecycle.
+     * @param cameraCoordinator The {@link CameraCoordinator} for concurrent camera mode.
+     *
      * @throws IllegalArgumentException If multiple LifecycleCameras with use cases are
      * registered to the same LifecycleOwner. Or all use cases will exceed the capability of the
      * camera after binding them to the LifecycleCamera.
      */
-    void bindToLifecycleCamera(@NonNull LifecycleCamera lifecycleCamera,
-            @Nullable ViewPort viewPort, @NonNull List<CameraEffect> effects,
-            @NonNull Collection<UseCase> useCases) {
+    void bindToLifecycleCamera(
+            @NonNull LifecycleCamera lifecycleCamera,
+            @Nullable ViewPort viewPort,
+            @NonNull List<CameraEffect> effects,
+            @NonNull Collection<UseCase> useCases,
+            @Nullable CameraCoordinator cameraCoordinator) {
         synchronized (mLock) {
             Preconditions.checkArgument(!useCases.isEmpty());
+            mCameraCoordinator = cameraCoordinator;
             LifecycleOwner lifecycleOwner = lifecycleCamera.getLifecycleOwner();
             // Disallow multiple LifecycleCameras with use cases to be registered to the same
             // LifecycleOwner.
@@ -269,11 +279,18 @@
                     getLifecycleCameraRepositoryObserver(lifecycleOwner);
             Set<Key> lifecycleCameraKeySet = mLifecycleObserverMap.get(observer);
 
-            for (Key key : lifecycleCameraKeySet) {
-                LifecycleCamera camera = Preconditions.checkNotNull(mCameraMap.get(key));
-                if (!camera.equals(lifecycleCamera) && !camera.getUseCases().isEmpty()) {
-                    throw new IllegalArgumentException("Multiple LifecycleCameras with use cases "
-                            + "are registered to the same LifecycleOwner.");
+            // Bypass the use cases lifecycle owner validation when concurrent camera mode is on.
+            // In concurrent camera mode, we allow multiple cameras registered to the same
+            // lifecycle owner.
+            if (mCameraCoordinator == null || (mCameraCoordinator.getCameraOperatingMode()
+                    != CameraCoordinator.CAMERA_OPERATING_MODE_CONCURRENT)) {
+                for (Key key : lifecycleCameraKeySet) {
+                    LifecycleCamera camera = Preconditions.checkNotNull(mCameraMap.get(key));
+                    if (!camera.equals(lifecycleCamera) && !camera.getUseCases().isEmpty()) {
+                        throw new IllegalArgumentException(
+                                "Multiple LifecycleCameras with use cases "
+                                        + "are registered to the same LifecycleOwner.");
+                    }
                 }
             }
 
@@ -366,12 +383,18 @@
             if (mActiveLifecycleOwners.isEmpty()) {
                 mActiveLifecycleOwners.push(lifecycleOwner);
             } else {
-                LifecycleOwner currentActiveLifecycleOwner = mActiveLifecycleOwners.peek();
-                if (!lifecycleOwner.equals(currentActiveLifecycleOwner)) {
-                    suspendUseCases(currentActiveLifecycleOwner);
+                // Bypass the use cases suspending when concurrent camera mode is on.
+                // In concurrent camera mode, we allow multiple cameras registered to the same
+                // lifecycle owner.
+                if (mCameraCoordinator == null || (mCameraCoordinator.getCameraOperatingMode()
+                        != CameraCoordinator.CAMERA_OPERATING_MODE_CONCURRENT)) {
+                    LifecycleOwner currentActiveLifecycleOwner = mActiveLifecycleOwners.peek();
+                    if (!lifecycleOwner.equals(currentActiveLifecycleOwner)) {
+                        suspendUseCases(currentActiveLifecycleOwner);
 
-                    mActiveLifecycleOwners.remove(lifecycleOwner);
-                    mActiveLifecycleOwners.push(lifecycleOwner);
+                        mActiveLifecycleOwners.remove(lifecycleOwner);
+                        mActiveLifecycleOwners.push(lifecycleOwner);
+                    }
                 }
             }
 
diff --git a/camera/camera-lifecycle/src/main/java/androidx/camera/lifecycle/ProcessCameraProvider.java b/camera/camera-lifecycle/src/main/java/androidx/camera/lifecycle/ProcessCameraProvider.java
index 7290be0..782dfca 100644
--- a/camera/camera-lifecycle/src/main/java/androidx/camera/lifecycle/ProcessCameraProvider.java
+++ b/camera/camera-lifecycle/src/main/java/androidx/camera/lifecycle/ProcessCameraProvider.java
@@ -16,9 +16,13 @@
 
 package androidx.camera.lifecycle;
 
+import static androidx.camera.core.concurrent.CameraCoordinator.CAMERA_OPERATING_MODE_CONCURRENT;
+import static androidx.camera.core.concurrent.CameraCoordinator.CAMERA_OPERATING_MODE_SINGLE;
+import static androidx.camera.core.concurrent.CameraCoordinator.CAMERA_OPERATING_MODE_UNSPECIFIED;
 import static androidx.camera.core.impl.utils.Threads.runOnMainSync;
 
 import static java.util.Collections.emptyList;
+import static java.util.Objects.requireNonNull;
 
 import android.app.Application;
 import android.content.Context;
@@ -46,6 +50,7 @@
 import androidx.camera.core.UseCase;
 import androidx.camera.core.UseCaseGroup;
 import androidx.camera.core.ViewPort;
+import androidx.camera.core.concurrent.CameraCoordinator.CameraOperatingMode;
 import androidx.camera.core.concurrent.ConcurrentCamera;
 import androidx.camera.core.concurrent.ConcurrentCameraConfig;
 import androidx.camera.core.concurrent.SingleCameraConfig;
@@ -366,7 +371,15 @@
     public Camera bindToLifecycle(@NonNull LifecycleOwner lifecycleOwner,
             @NonNull CameraSelector cameraSelector,
             @NonNull UseCase... useCases) {
-        return bindToLifecycle(lifecycleOwner, cameraSelector, null, emptyList(), useCases);
+        if (getCameraOperatingMode() == CAMERA_OPERATING_MODE_CONCURRENT) {
+            throw new UnsupportedOperationException("bindToLifecycle for single camera is not "
+                    + "supported in concurrent camera mode, call unbindAll() first");
+        }
+
+        Camera camera = bindToLifecycle(lifecycleOwner, cameraSelector, null, emptyList(),
+                useCases);
+        setCameraOperatingMode(CAMERA_OPERATING_MODE_SINGLE);
+        return camera;
     }
 
     /**
@@ -387,9 +400,16 @@
     public Camera bindToLifecycle(@NonNull LifecycleOwner lifecycleOwner,
             @NonNull CameraSelector cameraSelector,
             @NonNull UseCaseGroup useCaseGroup) {
-        return bindToLifecycle(lifecycleOwner, cameraSelector,
+        if (getCameraOperatingMode() == CAMERA_OPERATING_MODE_CONCURRENT) {
+            throw new UnsupportedOperationException("bindToLifecycle for single camera is not "
+                    + "supported in concurrent camera mode, call unbindAll() first");
+        }
+
+        Camera camera = bindToLifecycle(lifecycleOwner, cameraSelector,
                 useCaseGroup.getViewPort(), useCaseGroup.getEffects(),
                 useCaseGroup.getUseCases().toArray(new UseCase[0]));
+        setCameraOperatingMode(CAMERA_OPERATING_MODE_SINGLE);
+        return camera;
     }
 
     /**
@@ -402,6 +422,10 @@
      * @param concurrentCameraConfig input configuration for concurrent camera.
      * @return output concurrent camera instance.
      *
+     * @throws IllegalArgumentException If less than two camera configs are provided.
+     * @throws UnsupportedOperationException If more than two camera configs are provides or
+     * there is single camera already running.
+     *
      * @hide
      */
     @RestrictTo(Scope.LIBRARY_GROUP)
@@ -410,7 +434,6 @@
     @NonNull
     public ConcurrentCamera bindToLifecycle(
             @NonNull ConcurrentCameraConfig concurrentCameraConfig) {
-        // TODO(b/268347532): enable concurrent mode in camera coordinator
         if (concurrentCameraConfig.getSingleCameraConfigs().size() < 2) {
             throw new IllegalArgumentException("Concurrent camera needs two camera configs");
         }
@@ -420,6 +443,20 @@
                     + "cameras at maximum.");
         }
 
+        if (getCameraOperatingMode() == CAMERA_OPERATING_MODE_SINGLE) {
+            throw new UnsupportedOperationException("Camera is already running, call "
+                    + "unbindAll() before binding more cameras");
+        }
+
+        List<CameraSelector> cameraSelectorsToBind = Arrays.asList(
+                concurrentCameraConfig.getSingleCameraConfigs().get(0).getCameraSelector(),
+                concurrentCameraConfig.getSingleCameraConfigs().get(1).getCameraSelector());
+        if (!getActiveConcurrentCameraSelectors().isEmpty()
+                && !cameraSelectorsToBind.equals(getActiveConcurrentCameraSelectors())) {
+            throw new UnsupportedOperationException("Cameras are already running, call "
+                    + "unbindAll() before binding more cameras");
+        }
+
         List<Camera> cameras = new ArrayList<>();
         for (SingleCameraConfig config : concurrentCameraConfig.getSingleCameraConfigs()) {
             Camera camera = bindToLifecycle(
@@ -428,10 +465,12 @@
                     config.getUseCaseGroup().getViewPort(),
                     config.getUseCaseGroup().getEffects(),
                     config.getUseCaseGroup().getUseCases().toArray(new UseCase[0]));
-
             cameras.add(camera);
         }
 
+        setActiveConcurrentCameraSelectors(cameraSelectorsToBind);
+        setCameraOperatingMode(CAMERA_OPERATING_MODE_CONCURRENT);
+
         return new ConcurrentCamera.Builder()
                 .setCameras(cameras)
                 .builder();
@@ -586,8 +625,12 @@
             return lifecycleCameraToBind;
         }
 
-        mLifecycleCameraRepository.bindToLifecycleCamera(lifecycleCameraToBind, viewPort,
-                effects, Arrays.asList(useCases));
+        mLifecycleCameraRepository.bindToLifecycleCamera(
+                lifecycleCameraToBind,
+                viewPort,
+                effects,
+                Arrays.asList(useCases),
+                mCameraX.getCameraFactory().getCameraCoordinator());
 
         return lifecycleCameraToBind;
     }
@@ -624,11 +667,18 @@
      *
      * @param useCases The collection of use cases to remove.
      * @throws IllegalStateException If not called on main thread.
+     * @throws UnsupportedOperationException If called in concurrent mode.
      */
     @MainThread
     @Override
     public void unbind(@NonNull UseCase... useCases) {
         Threads.checkMainThread();
+
+        if (getCameraOperatingMode() == CAMERA_OPERATING_MODE_CONCURRENT) {
+            throw new UnsupportedOperationException("unbind usecase is not "
+                    + "supported in concurrent camera mode, call unbindAll() first");
+        }
+
         mLifecycleCameraRepository.unbind(Arrays.asList(useCases));
     }
 
@@ -644,6 +694,9 @@
     public void unbindAll() {
         Threads.checkMainThread();
         mLifecycleCameraRepository.unbindAll();
+
+        // Reset camera operating mode.
+        setCameraOperatingMode(CAMERA_OPERATING_MODE_UNSPECIFIED);
     }
 
     /** {@inheritDoc} */
@@ -684,6 +737,82 @@
         return availableCameraInfos;
     }
 
+    /**
+     * {@inheritDoc}
+     *
+     * @hide
+     */
+    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+    @NonNull
+    @Override
+    public List<List<CameraInfo>> getAvailableConcurrentCameraInfos() {
+        requireNonNull(mCameraX);
+        requireNonNull(mCameraX.getCameraFactory().getCameraCoordinator());
+        List<List<CameraSelector>> concurrentCameraSelectorLists =
+                mCameraX.getCameraFactory().getCameraCoordinator().getConcurrentCameraSelectors();
+        List<CameraInfo> availableCameraInfos = getAvailableCameraInfos();
+
+        List<List<CameraInfo>> availableConcurrentCameraInfos = new ArrayList<>();
+        for (final List<CameraSelector> cameraSelectors : concurrentCameraSelectorLists) {
+            List<CameraInfo> cameraInfos = new ArrayList<>();
+            for (CameraSelector cameraSelector : cameraSelectors) {
+                for (CameraInfo cameraInfo : availableCameraInfos) {
+                    if (cameraSelector.getLensFacing()
+                            == cameraInfo.getLensFacing()) {
+                        cameraInfos.add(cameraInfo);
+                        break;
+                    }
+                }
+            }
+            availableConcurrentCameraInfos.add(cameraInfos);
+        }
+        return availableConcurrentCameraInfos;
+    }
+
+    /**
+     * {@inheritDoc}
+     *
+     * @hide
+     */
+    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+    @Override
+    public boolean isConcurrentCameraModeOn() {
+        return getCameraOperatingMode() == CAMERA_OPERATING_MODE_CONCURRENT;
+    }
+
+    @CameraOperatingMode
+    private int getCameraOperatingMode() {
+        if (mCameraX == null || mCameraX.getCameraFactory().getCameraCoordinator() == null) {
+            return CAMERA_OPERATING_MODE_UNSPECIFIED;
+        }
+        return mCameraX.getCameraFactory().getCameraCoordinator().getCameraOperatingMode();
+    }
+
+    private void setCameraOperatingMode(@CameraOperatingMode int cameraOperatingMode) {
+        if (mCameraX == null || mCameraX.getCameraFactory().getCameraCoordinator() == null) {
+            return;
+        }
+        mCameraX.getCameraFactory().getCameraCoordinator()
+                .setCameraOperatingMode(cameraOperatingMode);
+    }
+
+    @NonNull
+    private List<CameraSelector> getActiveConcurrentCameraSelectors() {
+        if (mCameraX == null || mCameraX.getCameraFactory().getCameraCoordinator() == null) {
+            return new ArrayList<>();
+        }
+        return mCameraX.getCameraFactory().getCameraCoordinator()
+                .getActiveConcurrentCameraSelectors();
+    }
+
+    private void setActiveConcurrentCameraSelectors(@NonNull List<CameraSelector> cameraSelectors) {
+        if (mCameraX == null || mCameraX.getCameraFactory().getCameraCoordinator() == null) {
+            return;
+        }
+        mCameraX.getCameraFactory().getCameraCoordinator()
+                .setActiveConcurrentCameraSelectors(cameraSelectors);
+    }
+
     private ProcessCameraProvider() {
     }
 }
diff --git a/camera/camera-testing/src/main/java/androidx/camera/testing/fakes/FakeAppConfig.java b/camera/camera-testing/src/main/java/androidx/camera/testing/fakes/FakeAppConfig.java
index a6004bc..e608dcd 100644
--- a/camera/camera-testing/src/main/java/androidx/camera/testing/fakes/FakeAppConfig.java
+++ b/camera/camera-testing/src/main/java/androidx/camera/testing/fakes/FakeAppConfig.java
@@ -22,6 +22,7 @@
 import androidx.annotation.RestrictTo;
 import androidx.camera.core.CameraSelector;
 import androidx.camera.core.CameraXConfig;
+import androidx.camera.core.concurrent.CameraCoordinator;
 import androidx.camera.core.impl.CameraDeviceSurfaceManager;
 import androidx.camera.core.impl.CameraFactory;
 
@@ -60,6 +61,8 @@
                     () -> new FakeCamera(CAMERA_ID_1, null,
                             new FakeCameraInfoInternal(CAMERA_ID_1, 0,
                                     CameraSelector.LENS_FACING_FRONT)));
+            final CameraCoordinator cameraCoordinator = new FakeCameraCoordinator();
+            cameraFactory.setCameraCoordinator(cameraCoordinator);
             return cameraFactory;
         };
 
diff --git a/camera/camera-testing/src/main/java/androidx/camera/testing/fakes/FakeCamera.java b/camera/camera-testing/src/main/java/androidx/camera/testing/fakes/FakeCamera.java
index 7f8a579..1ff9ad6 100644
--- a/camera/camera-testing/src/main/java/androidx/camera/testing/fakes/FakeCamera.java
+++ b/camera/camera-testing/src/main/java/androidx/camera/testing/fakes/FakeCamera.java
@@ -66,6 +66,7 @@
     private State mState = State.CLOSED;
     private int mAvailableCameraCount = 1;
     private List<UseCase> mUseCaseResetHistory = new ArrayList<>();
+    private boolean mHasTransform = true;
 
     @Nullable
     private SessionConfig mSessionConfig;
@@ -310,6 +311,18 @@
         return mUseCaseResetHistory;
     }
 
+    @Override
+    public boolean getHasTransform() {
+        return mHasTransform;
+    }
+
+    /**
+     * Sets whether the camera has a transform.
+     */
+    public void setHasTransform(boolean hasCameraTransform) {
+        mHasTransform = hasCameraTransform;
+    }
+
     private void checkNotReleased() {
         if (mState == State.RELEASED) {
             throw new IllegalStateException("Camera has been released.");
diff --git a/camera/camera-testing/src/main/java/androidx/camera/testing/fakes/FakeCameraCoordinator.java b/camera/camera-testing/src/main/java/androidx/camera/testing/fakes/FakeCameraCoordinator.java
new file mode 100644
index 0000000..b4ce627
--- /dev/null
+++ b/camera/camera-testing/src/main/java/androidx/camera/testing/fakes/FakeCameraCoordinator.java
@@ -0,0 +1,126 @@
+/*
+ * 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.camera.testing.fakes;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.RequiresApi;
+import androidx.annotation.RestrictTo;
+import androidx.camera.core.CameraSelector;
+import androidx.camera.core.concurrent.CameraCoordinator;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * A {@link CameraCoordinator} implementation that contains concurrent camera mode and camera id
+ * information.
+ *
+ * @hide
+ */
+@RequiresApi(21) // TODO(b/200306659): Remove and replace with annotation on package-info.java
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+public class FakeCameraCoordinator implements CameraCoordinator {
+
+    @NonNull private Map<String, String> mConcurrentCameraIdMap;
+    @NonNull private List<List<String>> mConcurrentCameraIds;
+    @NonNull private List<List<CameraSelector>> mConcurrentCameraSelectors;
+    @NonNull private List<CameraSelector> mActiveConcurrentCameraSelectors;
+    @NonNull private final List<ConcurrentCameraModeListener> mConcurrentCameraModeListeners;
+
+    @CameraOperatingMode private int mCameraOperatingMode;
+
+    public FakeCameraCoordinator() {
+        mConcurrentCameraIdMap = new HashMap<>();
+        mConcurrentCameraIds = new ArrayList<>();
+        mConcurrentCameraSelectors = new ArrayList<>();
+        mActiveConcurrentCameraSelectors = new ArrayList<>();
+        mConcurrentCameraModeListeners = new ArrayList<>();
+    }
+
+    /**
+     * Adds concurrent camera id and camera selectors.
+     *
+     * @param cameraIdAndSelectors combinations of camera id and selector.
+     */
+    public void addConcurrentCameraIdsAndCameraSelectors(
+            @NonNull Map<String, CameraSelector> cameraIdAndSelectors) {
+        mConcurrentCameraIds.add(new ArrayList<>(cameraIdAndSelectors.keySet()));
+        mConcurrentCameraSelectors.add(new ArrayList<>(cameraIdAndSelectors.values()));
+
+        for (List<String> concurrentCameraIdList: mConcurrentCameraIds) {
+            List<String> cameraIdList = new ArrayList<>(concurrentCameraIdList);
+            mConcurrentCameraIdMap.put(cameraIdList.get(0), cameraIdList.get(1));
+            mConcurrentCameraIdMap.put(cameraIdList.get(1), cameraIdList.get(0));
+        }
+    }
+
+    @NonNull
+    @Override
+    public List<List<CameraSelector>> getConcurrentCameraSelectors() {
+        return mConcurrentCameraSelectors;
+    }
+
+    @Override
+    public void setActiveConcurrentCameraSelectors(@NonNull List<CameraSelector> cameraSelectors) {
+        mActiveConcurrentCameraSelectors = cameraSelectors;
+    }
+
+    @NonNull
+    @Override
+    public List<CameraSelector> getActiveConcurrentCameraSelectors() {
+        return mActiveConcurrentCameraSelectors;
+    }
+
+    @Nullable
+    @Override
+    public String getPairedConcurrentCameraId(@NonNull String cameraId) {
+        if (mConcurrentCameraIdMap.containsKey(cameraId)) {
+            return mConcurrentCameraIdMap.get(cameraId);
+        }
+        return null;
+    }
+
+    @CameraOperatingMode
+    @Override
+    public int getCameraOperatingMode() {
+        return mCameraOperatingMode;
+    }
+
+    @Override
+    public void setCameraOperatingMode(@CameraOperatingMode int cameraOperatingMode) {
+        if (cameraOperatingMode != mCameraOperatingMode) {
+            for (ConcurrentCameraModeListener listener : mConcurrentCameraModeListeners) {
+                listener.notifyConcurrentCameraModeUpdated(cameraOperatingMode);
+            }
+        }
+
+        mCameraOperatingMode = cameraOperatingMode;
+    }
+
+    @Override
+    public void addListener(@NonNull ConcurrentCameraModeListener listener) {
+        mConcurrentCameraModeListeners.add(listener);
+    }
+
+    @Override
+    public void removeListener(@NonNull ConcurrentCameraModeListener listener) {
+        mConcurrentCameraModeListeners.remove(listener);
+    }
+}
diff --git a/camera/camera-video/src/androidTest/java/androidx/camera/video/VideoRecordingTest.kt b/camera/camera-video/src/androidTest/java/androidx/camera/video/VideoRecordingTest.kt
index f4f9d38..a7a8ed9 100644
--- a/camera/camera-video/src/androidTest/java/androidx/camera/video/VideoRecordingTest.kt
+++ b/camera/camera-video/src/androidTest/java/androidx/camera/video/VideoRecordingTest.kt
@@ -219,9 +219,11 @@
     @Test
     fun getMetadataRotation_when_setTargetRotation() {
         // Arrange.
-        // Just set one Surface.ROTATION_90 to verify the function work or not.
-        val targetRotation = Surface.ROTATION_90
-        videoCapture.targetRotation = targetRotation
+        // Set Surface.ROTATION_90 for the 1st recording and update to Surface.ROTATION_180
+        // for the 2nd recording.
+        val targetRotation1 = Surface.ROTATION_90
+        val targetRotation2 = Surface.ROTATION_180
+        videoCapture.targetRotation = targetRotation1
 
         val file = File.createTempFile("CameraX", ".tmp").apply { deleteOnExit() }
         latchForVideoSaved = CountDownLatch(1)
@@ -235,10 +237,32 @@
         completeVideoRecording(videoCapture, file)
 
         // Verify.
-        verifyMetadataRotation(getExpectedRotation(videoCapture).metadataRotation, file)
+        val (videoContentRotation, metadataRotation) = getExpectedRotation(videoCapture)
+        verifyMetadataRotation(metadataRotation, file)
 
         // Cleanup.
         file.delete()
+
+        // Arrange: Prepare for 2nd recording
+        val file2 = File.createTempFile("CameraX", ".tmp").apply { deleteOnExit() }
+        latchForVideoSaved = CountDownLatch(1)
+        latchForVideoRecording = CountDownLatch(5)
+
+        // Act: Update targetRotation.
+        videoCapture.targetRotation = targetRotation2
+        completeVideoRecording(videoCapture, file2)
+
+        // Verify.
+        val metadataRotation2 = cameraInfo.getSensorRotationDegrees(targetRotation2).let {
+            if (videoCapture.node != null) {
+                // If effect is enabled, the rotation should eliminate the video content rotation.
+                it - videoContentRotation
+            } else it
+        }
+        verifyMetadataRotation(metadataRotation2, file2)
+
+        // Cleanup.
+        file2.delete()
     }
 
     @Test
diff --git a/camera/camera-video/src/main/java/androidx/camera/video/VideoCapture.java b/camera/camera-video/src/main/java/androidx/camera/video/VideoCapture.java
index ba7c991..ba5ac48 100644
--- a/camera/camera-video/src/main/java/androidx/camera/video/VideoCapture.java
+++ b/camera/camera-video/src/main/java/androidx/camera/video/VideoCapture.java
@@ -17,6 +17,7 @@
 package androidx.camera.video;
 
 import static androidx.camera.core.CameraEffect.VIDEO_CAPTURE;
+import static androidx.camera.core.impl.ImageFormatConstants.INTERNAL_DEFINED_IMAGE_FORMAT_PRIVATE;
 import static androidx.camera.core.impl.ImageOutputConfig.OPTION_CUSTOM_ORDERED_RESOLUTIONS;
 import static androidx.camera.core.impl.ImageOutputConfig.OPTION_DEFAULT_RESOLUTION;
 import static androidx.camera.core.impl.ImageOutputConfig.OPTION_MAX_RESOLUTION;
@@ -397,7 +398,7 @@
             } else {
                 surfaceRequest.updateTransformationInfo(
                         SurfaceRequest.TransformationInfo.of(cropRect, relativeRotation,
-                                targetRotation, getHasCameraTransform()));
+                                targetRotation, cameraInternal.getHasTransform()));
             }
         }
     }
@@ -456,16 +457,27 @@
                 videoCapabilities, mediaSpec, resolution, targetFpsRange);
         mCropRect = calculateCropRect(resolution, videoEncoderInfo);
         mNode = createNodeIfNeeded(isCropNeeded(mCropRect, resolution));
+        // Choose Timebase based on the whether the buffer is copied.
         Timebase timebase;
+        if (mNode != null || !camera.getHasTransform()) {
+            timebase = camera.getCameraInfoInternal().getTimebase();
+        } else {
+            // When camera buffers from a REALTIME device are passed directly to a video encoder
+            // from the camera, automatic compensation is done to account for differing timebases
+            // of the audio and camera subsystems. See the document of
+            // CameraMetadata#SENSOR_INFO_TIMESTAMP_SOURCE_REALTIME. So the timebase is always
+            // UPTIME when encoder surface is directly sent to camera.
+            timebase = Timebase.UPTIME;
+        }
         if (mNode != null) {
             // Make sure the previously created camera edge is cleared before creating a new one.
             checkState(mCameraEdge == null);
-            timebase = camera.getCameraInfoInternal().getTimebase();
             SurfaceEdge cameraEdge = new SurfaceEdge(
                     VIDEO_CAPTURE,
+                    INTERNAL_DEFINED_IMAGE_FORMAT_PRIVATE,
                     streamSpec,
                     getSensorToBufferTransformMatrix(),
-                    getHasCameraTransform(),
+                    camera.getHasTransform(),
                     mCropRect,
                     getRelativeRotation(camera),
                     /*mirroring=*/false);
@@ -494,12 +506,6 @@
             mSurfaceRequest = new SurfaceRequest(resolution, camera, targetFpsRange,
                     onSurfaceInvalidated);
             mDeferrableSurface = mSurfaceRequest.getDeferrableSurface();
-            // When camera buffers from a REALTIME device are passed directly to a video encoder
-            // from the camera, automatic compensation is done to account for differing timebases
-            // of the audio and camera subsystems. See the document of
-            // CameraMetadata#SENSOR_INFO_TIMESTAMP_SOURCE_REALTIME. So the timebase is always
-            // UPTIME when encoder surface is directly sent to camera.
-            timebase = Timebase.UPTIME;
         }
 
         config.getVideoOutput().onSurfaceRequested(mSurfaceRequest, timebase);
@@ -597,14 +603,7 @@
         private static final VideoCaptureConfig<?> DEFAULT_CONFIG;
 
         private static final Function<VideoEncoderConfig, VideoEncoderInfo>
-                DEFAULT_VIDEO_ENCODER_INFO_FINDER = encoderConfig -> {
-                    try {
-                        return VideoEncoderInfoImpl.from(encoderConfig);
-                    } catch (InvalidConfigException e) {
-                        Logger.w(TAG, "Unable to find VideoEncoderInfo", e);
-                        return null;
-                    }
-                };
+                DEFAULT_VIDEO_ENCODER_INFO_FINDER = createFinder();
 
         static final Range<Integer> DEFAULT_FPS_RANGE = new Range<>(30, 30);
 
@@ -617,6 +616,18 @@
         }
 
         @NonNull
+        private static Function<VideoEncoderConfig, VideoEncoderInfo> createFinder() {
+            return encoderConfig -> {
+                try {
+                    return VideoEncoderInfoImpl.from(encoderConfig);
+                } catch (InvalidConfigException e) {
+                    Logger.w(TAG, "Unable to find VideoEncoderInfo", e);
+                    return null;
+                }
+            };
+        }
+
+        @NonNull
         @Override
         public VideoCaptureConfig<?> getConfig() {
             return DEFAULT_CONFIG;
diff --git a/camera/camera-video/src/test/java/androidx/camera/video/VideoCaptureTest.kt b/camera/camera-video/src/test/java/androidx/camera/video/VideoCaptureTest.kt
index 2768b4c..c640507 100644
--- a/camera/camera-video/src/test/java/androidx/camera/video/VideoCaptureTest.kt
+++ b/camera/camera-video/src/test/java/androidx/camera/video/VideoCaptureTest.kt
@@ -25,6 +25,8 @@
 import android.media.CamcorderProfile.QUALITY_HIGH
 import android.media.CamcorderProfile.QUALITY_LOW
 import android.os.Build
+import android.os.Handler
+import android.os.HandlerThread
 import android.os.Looper
 import android.util.Range
 import android.util.Size
@@ -34,8 +36,10 @@
 import androidx.camera.core.AspectRatio.RATIO_4_3
 import androidx.camera.core.CameraEffect
 import androidx.camera.core.CameraEffect.VIDEO_CAPTURE
-import androidx.camera.core.CameraSelector
+import androidx.camera.core.CameraSelector.DEFAULT_BACK_CAMERA
+import androidx.camera.core.CameraSelector.DEFAULT_FRONT_CAMERA
 import androidx.camera.core.CameraSelector.LENS_FACING_BACK
+import androidx.camera.core.CameraSelector.LENS_FACING_FRONT
 import androidx.camera.core.CameraXConfig
 import androidx.camera.core.SurfaceRequest
 import androidx.camera.core.UseCase
@@ -48,12 +52,16 @@
 import androidx.camera.core.impl.Observable
 import androidx.camera.core.impl.StreamSpec
 import androidx.camera.core.impl.Timebase
+import androidx.camera.core.impl.utils.CameraOrientationUtil.surfaceRotationToDegrees
 import androidx.camera.core.impl.utils.CompareSizesByArea
 import androidx.camera.core.impl.utils.TransformUtils.rectToSize
 import androidx.camera.core.impl.utils.TransformUtils.rotateSize
+import androidx.camera.core.impl.utils.TransformUtils.within360
 import androidx.camera.core.impl.utils.executor.CameraXExecutors.directExecutor
 import androidx.camera.core.impl.utils.executor.CameraXExecutors.mainThreadExecutor
 import androidx.camera.core.internal.CameraUseCaseAdapter
+import androidx.camera.testing.CameraUtil
+import androidx.camera.testing.CameraXUtil
 import androidx.camera.testing.EncoderProfilesUtil.PROFILES_1080P
 import androidx.camera.testing.EncoderProfilesUtil.PROFILES_2160P
 import androidx.camera.testing.EncoderProfilesUtil.PROFILES_480P
@@ -65,8 +73,6 @@
 import androidx.camera.testing.EncoderProfilesUtil.RESOLUTION_QHD
 import androidx.camera.testing.EncoderProfilesUtil.RESOLUTION_QVGA
 import androidx.camera.testing.EncoderProfilesUtil.RESOLUTION_VGA
-import androidx.camera.testing.CameraUtil
-import androidx.camera.testing.CameraXUtil
 import androidx.camera.testing.fakes.FakeAppConfig
 import androidx.camera.testing.fakes.FakeCamera
 import androidx.camera.testing.fakes.FakeCameraDeviceSurfaceManager
@@ -88,6 +94,7 @@
 import androidx.camera.video.internal.encoder.VideoEncoderInfo
 import androidx.test.core.app.ApplicationProvider
 import com.google.common.truth.Truth.assertThat
+import com.google.common.truth.Truth.assertWithMessage
 import java.util.Collections
 import java.util.concurrent.TimeUnit
 import org.junit.After
@@ -125,6 +132,7 @@
     private lateinit var surfaceManager: FakeCameraDeviceSurfaceManager
     private lateinit var camera: FakeCamera
     private var surfaceRequestsToRelease = mutableListOf<SurfaceRequest>()
+    private val handlersToRelease = mutableListOf<Handler>()
 
     @Before
     fun setup() {
@@ -145,6 +153,9 @@
             it.willNotProvideSurface()
         }
         CameraXUtil.shutdown().get(10, TimeUnit.SECONDS)
+        for (handler in handlersToRelease) {
+            handler.looper.quitSafely()
+        }
     }
 
     @Test
@@ -153,8 +164,8 @@
         setupCamera()
         val videoCapture = createVideoCapture(createVideoOutput())
         videoCapture.effect = createFakeEffect()
+        camera.hasTransform = false
         // Act: set no transform and create pipeline.
-        videoCapture.hasCameraTransform = false
         videoCapture.bindToCamera(camera, null, null)
         videoCapture.updateSuggestedStreamSpec(StreamSpec.builder(Size(640, 480)).build())
         videoCapture.onStateAttached()
@@ -354,6 +365,15 @@
     }
 
     @Test
+    fun addUseCasesWithoutCameraTransform_cameraIsRealtime_requestIsRealtime() {
+        testTimebase(
+            cameraTimebase = Timebase.REALTIME,
+            expectedTimebase = Timebase.REALTIME,
+            hasTransform = false
+        )
+    }
+
+    @Test
     fun addUseCasesWithSurfaceProcessor_cameraIsUptime_requestIsUptime() {
         testTimebase(
             effect = createFakeEffect(),
@@ -374,10 +394,11 @@
     private fun testTimebase(
         effect: CameraEffect? = null,
         cameraTimebase: Timebase,
-        expectedTimebase: Timebase
+        expectedTimebase: Timebase,
+        hasTransform: Boolean = true,
     ) {
         // Arrange.
-        setupCamera(timebase = cameraTimebase)
+        setupCamera(timebase = cameraTimebase, hasTransform = hasTransform)
         createCameraUseCaseAdapter()
 
         var timebase: Timebase? = null
@@ -691,6 +712,15 @@
     }
 
     @Test
+    fun setTargetRotationInBuilder_rotationIsChanged() {
+        // Act.
+        val videoCapture = createVideoCapture(targetRotation = Surface.ROTATION_180)
+
+        // Assert.
+        assertThat(videoCapture.targetRotation).isEqualTo(Surface.ROTATION_180)
+    }
+
+    @Test
     fun setTargetRotation_rotationIsChanged() {
         // Arrange.
         val videoCapture = createVideoCapture()
@@ -703,6 +733,53 @@
     }
 
     @Test
+    fun setTargetRotationWithEffect_rotationChangesOnSurfaceEdge() {
+        // Arrange.
+        setupCamera()
+        createCameraUseCaseAdapter()
+        val videoCapture = createVideoCapture()
+        cameraUseCaseAdapter.setEffects(listOf(createFakeEffect()))
+        addAndAttachUseCases(videoCapture)
+
+        // Act: update target rotation
+        videoCapture.targetRotation = Surface.ROTATION_0
+        shadowOf(Looper.getMainLooper()).idle()
+        // Assert that the rotation of the SettableFuture is updated based on ROTATION_0.
+        assertThat(videoCapture.cameraEdge!!.rotationDegrees).isEqualTo(0)
+
+        // Act: update target rotation again.
+        videoCapture.targetRotation = Surface.ROTATION_180
+        shadowOf(Looper.getMainLooper()).idle()
+        // Assert: the rotation of the SettableFuture is updated based on ROTATION_90.
+        assertThat(videoCapture.cameraEdge!!.rotationDegrees).isEqualTo(180)
+    }
+
+    @Test
+    fun setTargetRotationWithEffectOnBackground_rotationChangesOnSurfaceEdge() {
+        // Arrange.
+        setupCamera()
+        createCameraUseCaseAdapter()
+        val videoCapture = createVideoCapture()
+        cameraUseCaseAdapter.setEffects(listOf(createFakeEffect()))
+        addAndAttachUseCases(videoCapture)
+        val backgroundHandler = createBackgroundHandler()
+
+        // Act: update target rotation
+        backgroundHandler.post { videoCapture.targetRotation = Surface.ROTATION_0 }
+        shadowOf(backgroundHandler.looper).idle()
+        shadowOf(Looper.getMainLooper()).idle()
+        // Assert that the rotation of the SettableFuture is updated based on ROTATION_0.
+        assertThat(videoCapture.cameraEdge!!.rotationDegrees).isEqualTo(0)
+
+        // Act: update target rotation again.
+        backgroundHandler.post { videoCapture.targetRotation = Surface.ROTATION_180 }
+        shadowOf(backgroundHandler.looper).idle()
+        shadowOf(Looper.getMainLooper()).idle()
+        // Assert: the rotation of the SettableFuture is updated based on ROTATION_90.
+        assertThat(videoCapture.cameraEdge!!.rotationDegrees).isEqualTo(180)
+    }
+
+    @Test
     fun addUseCases_transformationInfoUpdated() {
         // Arrange.
         setupCamera()
@@ -726,11 +803,92 @@
         verify(listener).onTransformationInfoUpdate(any())
     }
 
+    // Test setTargetRotation with common back and front camera properties and various conditions.
     @Test
-    fun setTargetRotation_transformationInfoUpdated() {
+    fun setTargetRotation_backCameraInitial0_transformationInfoUpdated() {
+        testSetTargetRotation_transformationInfoUpdated(
+            lensFacing = LENS_FACING_BACK,
+            sensorRotationDegrees = 90,
+            initialTargetRotation = Surface.ROTATION_0,
+        )
+    }
+
+    @Test
+    fun setTargetRotation_backCameraInitial90_transformationInfoUpdated() {
+        testSetTargetRotation_transformationInfoUpdated(
+            lensFacing = LENS_FACING_BACK,
+            sensorRotationDegrees = 90,
+            initialTargetRotation = Surface.ROTATION_90,
+        )
+    }
+
+    @Test
+    fun setTargetRotation_frontCameraInitial0_transformationInfoUpdated() {
+        testSetTargetRotation_transformationInfoUpdated(
+            lensFacing = LENS_FACING_FRONT,
+            sensorRotationDegrees = 270,
+            initialTargetRotation = Surface.ROTATION_0,
+        )
+    }
+
+    @Test
+    fun setTargetRotation_frontCameraInitial90_transformationInfoUpdated() {
+        testSetTargetRotation_transformationInfoUpdated(
+            lensFacing = LENS_FACING_FRONT,
+            sensorRotationDegrees = 270,
+            initialTargetRotation = Surface.ROTATION_90,
+        )
+    }
+
+    @Test
+    fun setTargetRotation_withEffectBackCameraInitial0_transformationInfoUpdated() {
+        testSetTargetRotation_transformationInfoUpdated(
+            lensFacing = LENS_FACING_BACK,
+            sensorRotationDegrees = 90,
+            effect = createFakeEffect(),
+            initialTargetRotation = Surface.ROTATION_0,
+        )
+    }
+
+    @Test
+    fun setTargetRotation_withEffectBackCameraInitial90_transformationInfoUpdated() {
+        testSetTargetRotation_transformationInfoUpdated(
+            lensFacing = LENS_FACING_BACK,
+            sensorRotationDegrees = 90,
+            effect = createFakeEffect(),
+            initialTargetRotation = Surface.ROTATION_90,
+        )
+    }
+
+    @Test
+    fun setTargetRotation_withEffectFrontCameraInitial0_transformationInfoUpdated() {
+        testSetTargetRotation_transformationInfoUpdated(
+            lensFacing = LENS_FACING_FRONT,
+            sensorRotationDegrees = 270,
+            effect = createFakeEffect(),
+            initialTargetRotation = Surface.ROTATION_90,
+        )
+    }
+
+    @Test
+    fun setTargetRotation_withEffectFrontCameraInitial90_transformationInfoUpdated() {
+        testSetTargetRotation_transformationInfoUpdated(
+            lensFacing = LENS_FACING_FRONT,
+            sensorRotationDegrees = 270,
+            effect = createFakeEffect(),
+            initialTargetRotation = Surface.ROTATION_90,
+        )
+    }
+
+    private fun testSetTargetRotation_transformationInfoUpdated(
+        lensFacing: Int = LENS_FACING_BACK,
+        sensorRotationDegrees: Int = 0,
+        effect: CameraEffect? = null,
+        initialTargetRotation: Int = Surface.ROTATION_0,
+    ) {
         // Arrange.
-        setupCamera()
-        createCameraUseCaseAdapter()
+        setupCamera(lensFacing = lensFacing, sensorRotation = sensorRotationDegrees)
+        createCameraUseCaseAdapter(lensFacing = lensFacing)
         var transformationInfo: SurfaceRequest.TransformationInfo? = null
         val videoOutput = createVideoOutput(
             surfaceRequestListener = { surfaceRequest, _ ->
@@ -741,19 +899,59 @@
                 }
             }
         )
-        val videoCapture = createVideoCapture(videoOutput, targetRotation = Surface.ROTATION_90)
+        val videoCapture = createVideoCapture(videoOutput, targetRotation = initialTargetRotation)
 
         // Act.
+        effect?.apply { cameraUseCaseAdapter.setEffects(listOf(this)) }
         addAndAttachUseCases(videoCapture)
+        shadowOf(Looper.getMainLooper()).idle()
 
         // Assert.
-        assertThat(transformationInfo!!.rotationDegrees).isEqualTo(270)
+        var videoContentDegrees: Int
+        var metadataDegrees: Int
+        cameraInfo.getSensorRotationDegrees(initialTargetRotation).let {
+            if (effect != null) {
+                // If effect is enabled, the rotation is applied on video content but not metadata.
+                videoContentDegrees = it
+                metadataDegrees = 0
+            } else {
+                videoContentDegrees = 0
+                metadataDegrees = it
+            }
+        }
+        assertThat(transformationInfo!!.rotationDegrees).isEqualTo(metadataDegrees)
 
-        // Act.
-        videoCapture.targetRotation = Surface.ROTATION_180
+        // Act: Test all 4 rotation degrees.
+        for (targetRotation in listOf(
+            Surface.ROTATION_0,
+            Surface.ROTATION_90,
+            Surface.ROTATION_180,
+            Surface.ROTATION_270
+        )) {
+            videoCapture.targetRotation = targetRotation
+            shadowOf(Looper.getMainLooper()).idle()
 
-        // Assert.
-        assertThat(transformationInfo!!.rotationDegrees).isEqualTo(180)
+            // Assert.
+            val requiredDegrees = cameraInfo.getSensorRotationDegrees(targetRotation)
+            val expectedDegrees = if (effect != null) {
+                // If effect is enabled, the rotation should eliminate the video content rotation.
+                within360(requiredDegrees - videoContentDegrees)
+            } else {
+                requiredDegrees
+            }
+            val message = "lensFacing = $lensFacing" +
+                ", sensorRotationDegrees = $sensorRotationDegrees" +
+                ", initialTargetRotation = $initialTargetRotation" +
+                ", targetRotation = ${surfaceRotationToDegrees(targetRotation)}" +
+                ", effect = ${effect != null}" +
+                ", videoContentDegrees = $videoContentDegrees" +
+                ", metadataDegrees = $metadataDegrees" +
+                ", requiredDegrees = $requiredDegrees" +
+                ", expectedDegrees = $expectedDegrees" +
+                ", transformationInfo.rotationDegrees = " + transformationInfo!!.rotationDegrees
+            assertWithMessage(message).that(transformationInfo!!.rotationDegrees)
+                .isEqualTo(expectedDegrees)
+        }
     }
 
     @Test
@@ -1000,14 +1198,15 @@
         shadowOf(Looper.getMainLooper()).idle()
     }
 
-    private fun createCameraUseCaseAdapter() {
+    private fun createCameraUseCaseAdapter(lensFacing: Int = LENS_FACING_BACK) {
+        val cameraSelector = if (lensFacing == LENS_FACING_FRONT) DEFAULT_FRONT_CAMERA
+        else DEFAULT_BACK_CAMERA
         cameraUseCaseAdapter =
-            CameraUtil.createCameraUseCaseAdapter(context, CameraSelector.DEFAULT_BACK_CAMERA)
+            CameraUtil.createCameraUseCaseAdapter(context, cameraSelector)
     }
 
     private fun createVideoCapture(
         videoOutput: VideoOutput = createVideoOutput(),
-        hasCameraTransform: Boolean = true,
         targetRotation: Int? = null,
         targetResolution: Size? = null,
         videoEncoderInfoFinder: Function<VideoEncoderConfig, VideoEncoderInfo> =
@@ -1018,9 +1217,7 @@
             targetRotation?.let { setTargetRotation(it) }
             targetResolution?.let { setTargetResolution(it) }
             setVideoEncoderInfoFinder(videoEncoderInfoFinder)
-        }.build().apply {
-            setHasCameraTransform(hasCameraTransform)
-        }
+        }.build()
 
     private fun createFakeEffect(
         processor: FakeSurfaceProcessorInternal = FakeSurfaceProcessorInternal(
@@ -1032,6 +1229,15 @@
             processor
         )
 
+    private fun createBackgroundHandler(): Handler {
+        val handler = Handler(HandlerThread("VideoCaptureTest").run {
+            start()
+            looper
+        })
+        handlersToRelease.add(handler)
+        return handler
+    }
+
     private fun setSuggestedStreamSpec(quality: Quality) {
         setSuggestedStreamSpec(StreamSpec.builder(CAMERA_0_QUALITY_SIZE[quality]!!).build())
     }
@@ -1046,12 +1252,14 @@
 
     private fun setupCamera(
         cameraId: String = CAMERA_ID_0,
+        lensFacing: Int = LENS_FACING_BACK,
         sensorRotation: Int = 0,
+        hasTransform: Boolean = true,
         supportedResolutions: Map<Int, List<Size>> = CAMERA_0_SUPPORTED_RESOLUTION_MAP,
         profiles: Map<Int, EncoderProfilesProxy> = CAMERA_0_PROFILES,
         timebase: Timebase = Timebase.UPTIME,
     ) {
-        cameraInfo = FakeCameraInfoInternal(cameraId, sensorRotation, LENS_FACING_BACK).apply {
+        cameraInfo = FakeCameraInfoInternal(cameraId, sensorRotation, lensFacing).apply {
             supportedResolutions.forEach { (format, resolutions) ->
                 setSupportedResolutions(format, resolutions)
             }
@@ -1059,6 +1267,7 @@
             setTimebase(timebase)
         }
         camera = FakeCamera(cameraId, null, cameraInfo)
+        camera.hasTransform = hasTransform
 
         cameraFactory = FakeCameraFactory().apply {
             insertDefaultBackCamera(cameraId) { camera }
diff --git a/camera/camera-video/src/test/java/androidx/camera/video/internal/FakeBufferProvider.kt b/camera/camera-video/src/test/java/androidx/camera/video/internal/FakeBufferProvider.kt
index e5695b9..0b1f22f 100644
--- a/camera/camera-video/src/test/java/androidx/camera/video/internal/FakeBufferProvider.kt
+++ b/camera/camera-video/src/test/java/androidx/camera/video/internal/FakeBufferProvider.kt
@@ -32,7 +32,7 @@
     private var state: BufferProvider.State = BufferProvider.State.ACTIVE,
     private val bufferFactory: (Int) -> ListenableFuture<FakeInputBuffer>,
 ) : BufferProvider<FakeInputBuffer> {
-    private val completeBufferCalls = MockConsumer<FakeInputBuffer>()
+    private val submittedBufferCalls = MockConsumer<FakeInputBuffer>()
     private var acquiredBufferNum = 0
     private val observers = mutableMapOf<Observable.Observer<in BufferProvider.State>, Executor>()
 
@@ -41,7 +41,12 @@
             val bufferFuture = bufferFactory.invoke(acquiredBufferNum++)
             bufferFuture.addListener({
                 try {
-                    completeBufferCalls.accept(bufferFuture.get())
+                    val inputBuffer = bufferFuture.get()
+                    inputBuffer.terminationFuture.addListener({
+                        if (inputBuffer.isSubmitted) {
+                            submittedBufferCalls.accept(inputBuffer)
+                        }
+                    }, directExecutor())
                 } catch (e: ExecutionException) {
                     // Ignored.
                 }
@@ -68,12 +73,12 @@
         observers.remove(observer)
     }
 
-    fun verifyCompletedBufferCall(
+    fun verifySubmittedBufferCall(
         callTimes: CallTimes,
         timeoutMs: Long = MockConsumer.NO_TIMEOUT,
         inOder: Boolean = false,
         onCompleteBuffers: ((List<FakeInputBuffer>) -> Unit)? = null,
-    ) = completeBufferCalls.verifyAcceptCallExt(
+    ) = submittedBufferCalls.verifyAcceptCallExt(
         FakeInputBuffer::class.java,
         inOder,
         timeoutMs,
diff --git a/camera/camera-video/src/test/java/androidx/camera/video/internal/audio/AudioSourceTest.kt b/camera/camera-video/src/test/java/androidx/camera/video/internal/audio/AudioSourceTest.kt
index b857fca..72e1075 100644
--- a/camera/camera-video/src/test/java/androidx/camera/video/internal/audio/AudioSourceTest.kt
+++ b/camera/camera-video/src/test/java/androidx/camera/video/internal/audio/AudioSourceTest.kt
@@ -79,13 +79,13 @@
         audioStream.verifyStartCall(CallTimes(1), COMMON_TIMEOUT_MS)
         // Assert: Buffers are continuously written.
         val verifyCount = 3
-        bufferProvider.verifyCompletedBufferCall(
+        bufferProvider.verifySubmittedBufferCall(
             CallTimesAtLeast(verifyCount),
             COMMON_TIMEOUT_MS
-        ) { completedBuffers ->
+        ) { submittedBuffers ->
             // Assert: Ensure buffers are written correctly.
             for (i in 0 until verifyCount) {
-                verifyBufferContentEquals(completedBuffers[i], audioStream.getAudioDataList()[i])
+                verifyBufferContentEquals(submittedBuffers[i], audioStream.getAudioDataList()[i])
             }
         }
 
@@ -119,14 +119,14 @@
             bufferProvider = bufferProvider1,
         )
         audioSource.start()
-        bufferProvider1.verifyCompletedBufferCall(CallTimesAtLeast(3), COMMON_TIMEOUT_MS)
+        bufferProvider1.verifySubmittedBufferCall(CallTimesAtLeast(3), COMMON_TIMEOUT_MS)
 
         // Act.
         val bufferProvider2 = createBufferProvider()
         audioSource.setBufferProvider(bufferProvider2)
 
         // Assert.
-        bufferProvider2.verifyCompletedBufferCall(CallTimesAtLeast(3), COMMON_TIMEOUT_MS)
+        bufferProvider2.verifySubmittedBufferCall(CallTimesAtLeast(3), COMMON_TIMEOUT_MS)
     }
 
     @Test
@@ -286,12 +286,15 @@
             assertThat(it.single()).isTrue()
         }
         // Assert: Ensure the content is silence.
-        bufferProvider.verifyCompletedBufferCall(
-            CallTimesAtLeast(1),
+        val verifyCount = 3
+        bufferProvider.verifySubmittedBufferCall(
+            CallTimesAtLeast(verifyCount),
             COMMON_TIMEOUT_MS
-        ) { completedBuffers ->
+        ) { submittedBuffers ->
             // Assert: Ensure buffers are written correctly.
-            verifyBufferIsSilence(completedBuffers.last().byteBuffer)
+            for (i in 0 until verifyCount) {
+                verifyBufferIsSilence(submittedBuffers[i].byteBuffer)
+            }
         }
     }
 
diff --git a/camera/camera-video/src/test/java/androidx/camera/video/internal/encoder/FakeInputBuffer.kt b/camera/camera-video/src/test/java/androidx/camera/video/internal/encoder/FakeInputBuffer.kt
index 2fb084d..0a2867a 100644
--- a/camera/camera-video/src/test/java/androidx/camera/video/internal/encoder/FakeInputBuffer.kt
+++ b/camera/camera-video/src/test/java/androidx/camera/video/internal/encoder/FakeInputBuffer.kt
@@ -19,10 +19,12 @@
 import androidx.concurrent.futures.ResolvableFuture
 import com.google.common.util.concurrent.ListenableFuture
 import java.nio.ByteBuffer
+import java.util.concurrent.atomic.AtomicBoolean
 
 class FakeInputBuffer(capacity: Int = 16) : InputBuffer {
     private val byteBuffer = ByteBuffer.allocate(capacity)
     private val terminationFuture = ResolvableFuture.create<Void>()
+    private val isTerminated: AtomicBoolean = AtomicBoolean(false)
     private var presentationTimeUs = 0L
     private var isEndOfStream = false
     var isCanceled = false
@@ -49,16 +51,18 @@
     fun isEndOfStream() = isEndOfStream
 
     override fun submit(): Boolean {
-        if (terminationFuture.set(null)) {
+        if (!isTerminated.getAndSet(true)) {
             isSubmitted = true
+            terminationFuture.set(null)
             return true
         }
         return false
     }
 
     override fun cancel(): Boolean {
-        if (terminationFuture.set(null)) {
+        if (!isTerminated.getAndSet(true)) {
             isCanceled = true
+            terminationFuture.set(null)
             return true
         }
         return false
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 84311e9..04bdd7d 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
@@ -89,10 +89,10 @@
 import androidx.camera.core.AspectRatio;
 import androidx.camera.core.Camera;
 import androidx.camera.core.CameraControl;
-import androidx.camera.core.CameraFilter;
 import androidx.camera.core.CameraInfo;
 import androidx.camera.core.CameraSelector;
 import androidx.camera.core.DisplayOrientedMeteringPointFactory;
+import androidx.camera.core.ExperimentalLensFacing;
 import androidx.camera.core.ExposureState;
 import androidx.camera.core.FocusMeteringAction;
 import androidx.camera.core.FocusMeteringResult;
@@ -224,12 +224,13 @@
     @VisibleForTesting
     // Sets this bit to bind ImageAnalysis when using INTENT_EXTRA_USE_CASE_COMBINATION
     public static final int BIND_IMAGE_ANALYSIS = 0x8;
-    private static final int UNKNOWN_LENS_FACING = -1;
+
     static final CameraSelector BACK_SELECTOR =
             new CameraSelector.Builder().requireLensFacing(CameraSelector.LENS_FACING_BACK).build();
     static final CameraSelector FRONT_SELECTOR =
             new CameraSelector.Builder().requireLensFacing(
                     CameraSelector.LENS_FACING_FRONT).build();
+    private CameraSelector mExternalCameraSelector = null;
 
     private final AtomicLong mImageAnalysisFrameCount = new AtomicLong(0);
     private final AtomicLong mPreviewFrameCount = new AtomicLong(0);
@@ -261,7 +262,7 @@
     Camera mCamera;
 
     private CameraSelector mLaunchingCameraIdSelector = null;
-    private int mLaunchingCameraLensFacing = UNKNOWN_LENS_FACING;
+    private int mLaunchingCameraLensFacing = CameraSelector.LENS_FACING_UNKNOWN;
 
     private ToggleButton mVideoToggle;
     private ToggleButton mPhotoToggle;
@@ -292,7 +293,7 @@
     SessionMediaUriSet mSessionVideosUriSet = new SessionMediaUriSet();
 
     // Analyzer to be used with ImageAnalysis.
-    private ImageAnalysis.Analyzer mAnalyzer = new ImageAnalysis.Analyzer() {
+    private final ImageAnalysis.Analyzer mAnalyzer = new ImageAnalysis.Analyzer() {
         @Override
         public void analyze(@NonNull ImageProxy image) {
             // Since we set the callback handler to a main thread handler, we can call
@@ -312,7 +313,7 @@
         }
     };
 
-    private FutureCallback<Integer> mEVFutureCallback = new FutureCallback<Integer>() {
+    private final FutureCallback<Integer> mEVFutureCallback = new FutureCallback<Integer>() {
 
         @Override
         public void onSuccess(@Nullable Integer result) {
@@ -333,10 +334,10 @@
     };
 
     // Listener that handles all ToggleButton events.
-    private CompoundButton.OnCheckedChangeListener mOnCheckedChangeListener =
+    private final CompoundButton.OnCheckedChangeListener mOnCheckedChangeListener =
             (compoundButton, isChecked) -> tryBindUseCases();
 
-    private Consumer<Long> mFrameUpdateListener = timestamp -> {
+    private final Consumer<Long> mFrameUpdateListener = timestamp -> {
         if (mPreviewFrameCount.getAndIncrement() >= FRAMES_UNTIL_VIEW_IS_READY) {
             try {
                 if (!this.mViewIdlingResource.isIdleNow()) {
@@ -483,6 +484,7 @@
         mSessionVideosUriSet.deleteAllUris();
     }
 
+    @SuppressLint("NullAnnotationGroup")
     @OptIn(markerClass = androidx.camera.core.ExperimentalZeroShutterLag.class)
     @ImageCapture.CaptureMode
     int getCaptureMode() {
@@ -889,6 +891,12 @@
         } else {
             if (currentCameraSelector == BACK_SELECTOR) {
                 switchedCameraSelector = FRONT_SELECTOR;
+            } else if (currentCameraSelector == FRONT_SELECTOR) {
+                if (mExternalCameraSelector != null) {
+                    switchedCameraSelector = mExternalCameraSelector;
+                } else {
+                    switchedCameraSelector = BACK_SELECTOR;
+                }
             } else {
                 switchedCameraSelector = BACK_SELECTOR;
             }
@@ -1033,6 +1041,7 @@
         }
     }
 
+    @SuppressLint("NullAnnotationGroup")
     @OptIn(markerClass = androidx.camera.core.ExperimentalZeroShutterLag.class)
     private void updateButtonsUi() {
         mRecordUi.setEnabled(mVideoToggle.isChecked());
@@ -1132,6 +1141,8 @@
         }
     }
 
+    @SuppressLint("NullAnnotationGroup")
+    @OptIn(markerClass = ExperimentalLensFacing.class)
     @Override
     protected void onCreate(@Nullable Bundle savedInstanceState) {
         super.onCreate(savedInstanceState);
@@ -1245,7 +1256,7 @@
             }
 
             String cameraImplementation = bundle.getString(INTENT_EXTRA_CAMERA_IMPLEMENTATION);
-            Boolean cameraImplementationNoHistory =
+            boolean cameraImplementationNoHistory =
                     bundle.getBoolean(INTENT_EXTRA_CAMERA_IMPLEMENTATION_NO_HISTORY, false);
             if (cameraImplementationNoHistory) {
                 Intent newIntent = new Intent(getIntent());
@@ -1277,6 +1288,16 @@
             mInitializationIdlingResource.decrement();
             if (cameraProviderResult.hasProvider()) {
                 mCameraProvider = cameraProviderResult.getProvider();
+
+                //initialize mExternalCameraSelector
+                CameraSelector externalCameraSelectorLocal = new CameraSelector.Builder()
+                        .requireLensFacing(CameraSelector.LENS_FACING_EXTERNAL).build();
+                List<CameraInfo> cameraInfos = externalCameraSelectorLocal.filter(
+                        mCameraProvider.getAvailableCameraInfos());
+                if (cameraInfos.size() > 0) {
+                    mExternalCameraSelector = externalCameraSelectorLocal;
+                }
+
                 updateVideoQualityByIntent(getIntent());
                 tryBindUseCases();
             } else {
@@ -1297,7 +1318,7 @@
      */
     private void closeAppIfCameraProviderMismatch(Intent mIntent) {
         String cameraImplementation = null;
-        Boolean cameraImplementationNoHistory = false;
+        boolean cameraImplementationNoHistory = false;
         Bundle bundle = mIntent.getExtras();
         if (bundle != null) {
             cameraImplementation = bundle.getString(INTENT_EXTRA_CAMERA_IMPLEMENTATION);
@@ -1371,7 +1392,7 @@
             // Retrieves the lens facing info when the activity is launched with a specified
             // camera id.
             if (mCurrentCameraSelector == mLaunchingCameraIdSelector
-                    && mLaunchingCameraLensFacing == UNKNOWN_LENS_FACING) {
+                    && mLaunchingCameraLensFacing == CameraSelector.LENS_FACING_UNKNOWN) {
                 mLaunchingCameraLensFacing = getLensFacing(mCamera.getCameraInfo());
             }
             List<UseCase> useCases = buildUseCases();
@@ -1419,7 +1440,7 @@
     /**
      * Unchecks use case to find a supported use cases combination.
      *
-     * Only VideoCapture or ImageAnalysis will be tried to uncheck. If only Preview and
+     * <p>Only VideoCapture or ImageAnalysis will be tried to uncheck. If only Preview and
      * ImageCapture are remained, the combination should always be supported.
      */
     private void reduceUseCaseToFindSupportedCombination() {
@@ -1710,13 +1731,9 @@
     }
 
     private void setUpZoomButton() {
-        mZoomIn2XToggle.setOnClickListener(v -> {
-            setZoomRatio(2.0f);
-        });
+        mZoomIn2XToggle.setOnClickListener(v -> setZoomRatio(2.0f));
 
-        mZoomResetToggle.setOnClickListener(v -> {
-            setZoomRatio(1.0f);
-        });
+        mZoomResetToggle.setOnClickListener(v -> setZoomRatio(1.0f));
     }
 
     void setZoomRatio(float newZoom) {
@@ -1760,7 +1777,6 @@
 
     /** Gets the absolute path from a Uri. */
     @Nullable
-    @SuppressWarnings("deprecation")
     public String getAbsolutePathFromUri(@NonNull ContentResolver resolver,
             @NonNull Uri contentUri) {
         Cursor cursor = null;
@@ -1778,7 +1794,7 @@
         } catch (RuntimeException e) {
             Log.e(TAG, String.format(
                     "Failed in getting absolute path for Uri %s with Exception %s",
-                    contentUri.toString(), e.toString()));
+                    contentUri, e));
             return "";
         } finally {
             if (cursor != null) {
@@ -2054,18 +2070,14 @@
     }
 
     private static CameraSelector createCameraSelectorById(@Nullable String cameraId) {
-        return new CameraSelector.Builder().addCameraFilter(new CameraFilter() {
-            @NonNull
-            @Override
-            public List<CameraInfo> filter(@NonNull List<CameraInfo> cameraInfos) {
-                for (CameraInfo cameraInfo : cameraInfos) {
-                    if (Objects.equals(cameraId, getCameraId(cameraInfo))) {
-                        return Collections.singletonList(cameraInfo);
-                    }
+        return new CameraSelector.Builder().addCameraFilter(cameraInfos -> {
+            for (CameraInfo cameraInfo : cameraInfos) {
+                if (Objects.equals(cameraId, getCameraId(cameraInfo))) {
+                    return Collections.singletonList(cameraInfo);
                 }
-
-                throw new IllegalArgumentException("No camera can be find for id: " + cameraId);
             }
+
+            throw new IllegalArgumentException("No camera can be find for id: " + cameraId);
         }).build();
     }
 
@@ -2077,6 +2089,7 @@
         }
     }
 
+    @SuppressLint("NullAnnotationGroup")
     @OptIn(markerClass = ExperimentalCamera2Interop.class)
     private static int getCamera2LensFacing(@NonNull CameraInfo cameraInfo) {
         Integer lensFacing = Camera2CameraInfo.from(cameraInfo).getCameraCharacteristic(
@@ -2085,6 +2098,7 @@
         return lensFacing == null ? CameraCharacteristics.LENS_FACING_BACK : lensFacing;
     }
 
+    @SuppressLint("NullAnnotationGroup")
     @OptIn(markerClass =
             androidx.camera.camera2.pipe.integration.interop.ExperimentalCamera2Interop.class)
     private static int getCamera2PipeLensFacing(@NonNull CameraInfo cameraInfo) {
@@ -2104,12 +2118,14 @@
         }
     }
 
+    @SuppressLint("NullAnnotationGroup")
     @OptIn(markerClass = ExperimentalCamera2Interop.class)
     @NonNull
     private static String getCamera2CameraId(@NonNull CameraInfo cameraInfo) {
         return Camera2CameraInfo.from(cameraInfo).getCameraId();
     }
 
+    @SuppressLint("NullAnnotationGroup")
     @OptIn(markerClass =
             androidx.camera.camera2.pipe.integration.interop.ExperimentalCamera2Interop.class)
     @NonNull
diff --git a/car/app/app-samples/showcase/common/src/main/java/androidx/car/app/sample/showcase/common/screens/navigationdemos/MapTemplateWithListDemoScreen.java b/car/app/app-samples/showcase/common/src/main/java/androidx/car/app/sample/showcase/common/screens/navigationdemos/MapTemplateWithListDemoScreen.java
index 54df637..83a4c93 100644
--- a/car/app/app-samples/showcase/common/src/main/java/androidx/car/app/sample/showcase/common/screens/navigationdemos/MapTemplateWithListDemoScreen.java
+++ b/car/app/app-samples/showcase/common/src/main/java/androidx/car/app/sample/showcase/common/screens/navigationdemos/MapTemplateWithListDemoScreen.java
@@ -30,6 +30,7 @@
 import androidx.car.app.model.CarText;
 import androidx.car.app.model.Header;
 import androidx.car.app.model.ItemList;
+import androidx.car.app.model.OnClickListener;
 import androidx.car.app.model.ParkedOnlyOnClickListener;
 import androidx.car.app.model.Row;
 import androidx.car.app.model.Template;
@@ -53,14 +54,8 @@
     @Override
     public Template onGetTemplate() {
         ItemList.Builder listBuilder = new ItemList.Builder();
-        listBuilder.addItem(
-                new Row.Builder()
-                        .setOnClickListener(
-                                ParkedOnlyOnClickListener.create(() -> onClick(
-                                        getCarContext().getString(R.string.parked_toast_msg))))
-                        .setTitle(getCarContext().getString(R.string.parked_only_title))
-                        .addText(getCarContext().getString(R.string.parked_only_text))
-                        .build());
+        listBuilder.addItem(createRowWithParkedOnlyContent());
+        listBuilder.addItem(createRowWithSecondaryAction(2));
         // Some hosts may allow more items in the list than others, so create more.
         if (getCarContext().getCarAppApiLevel() > CarAppApiLevels.LEVEL_1) {
             int listLimit =
@@ -68,26 +63,8 @@
                             getCarContext().getCarService(ConstraintManager.class).getContentLimit(
                                     ConstraintManager.CONTENT_LIMIT_TYPE_LIST));
 
-            for (int i = 2; i <= listLimit; ++i) {
-                // For row text, set text variants that fit best in different screen sizes.
-                String secondTextStr = getCarContext().getString(R.string.second_line_text);
-                CarText secondText =
-                        new CarText.Builder(
-                                "================= " + secondTextStr + " ================")
-                                .addVariant("--------------------- " + secondTextStr
-                                        + " ----------------------")
-                                .addVariant(secondTextStr)
-                                .build();
-                final String onClickText = getCarContext().getString(R.string.clicked_row_prefix)
-                        + ": " + i;
-                listBuilder.addItem(
-                        new Row.Builder()
-                                .setOnClickListener(() -> onClick(onClickText))
-                                .setTitle(
-                                        getCarContext().getString(R.string.title_prefix) + " " + i)
-                                .addText(getCarContext().getString(R.string.first_line_text))
-                                .addText(secondText)
-                                .build());
+            for (int i = 3; i <= listLimit; ++i) {
+                listBuilder.addItem(createRow(i));
             }
         }
 
@@ -162,6 +139,68 @@
         return builder.build();
     }
 
+    private Row createRowWithParkedOnlyContent() {
+        return new Row.Builder()
+                .setOnClickListener(
+                        ParkedOnlyOnClickListener.create(() -> onClick(
+                                getCarContext().getString(R.string.parked_toast_msg))))
+                .setTitle(getCarContext().getString(R.string.parked_only_title))
+                .addText(getCarContext().getString(R.string.parked_only_text))
+                .build();
+    }
+
+    private Row createRowWithSecondaryAction(int index) {
+        Action action = new Action.Builder()
+                .setIcon(buildCarIconWithResources(R.drawable.baseline_question_mark_24))
+                .setOnClickListener(createRowOnClickListener(index))
+                .build();
+
+        Row.Builder rowBuilder = new Row.Builder()
+                .setTitle(createRowTitle(index))
+                .addText(getCarContext().getString(R.string.other_row_text));
+
+        if (getCarContext().getCarAppApiLevel() >= CarAppApiLevels.LEVEL_6) {
+            rowBuilder.addAction(action);
+        }
+
+        return rowBuilder.build();
+    }
+
+    private Row createRow(int index) {
+        // For row text, set text variants that fit best in different screen sizes.
+        String secondTextStr = getCarContext().getString(R.string.second_line_text);
+        CarText secondText =
+                new CarText.Builder(
+                        "================= " + secondTextStr + " ================")
+                        .addVariant("--------------------- " + secondTextStr
+                                + " ----------------------")
+                        .addVariant(secondTextStr)
+                        .build();
+
+        return new Row.Builder()
+                .setOnClickListener(createRowOnClickListener(index))
+                .setTitle(createRowTitle(index))
+                .addText(getCarContext().getString(R.string.first_line_text))
+                .addText(secondText)
+                .build();
+    }
+
+    private String createRowTitle(int index) {
+        return getCarContext().getString(R.string.title_prefix) + " " + index;
+    }
+
+    private OnClickListener createRowOnClickListener(int index) {
+        return () -> onClick(getCarContext().getString(R.string.clicked_row_prefix) + ": " + index);
+    }
+
+    private CarIcon buildCarIconWithResources(int imageId) {
+        return new CarIcon.Builder(
+                IconCompat.createWithResource(
+                        getCarContext(),
+                        imageId))
+                .build();
+    }
+
     private void onClick(String text) {
         CarToast.makeText(getCarContext(), text, LENGTH_LONG).show();
     }
diff --git a/car/app/app-samples/showcase/common/src/main/java/androidx/car/app/sample/showcase/common/screens/navigationdemos/MapTemplateWithPaneDemoScreen.java b/car/app/app-samples/showcase/common/src/main/java/androidx/car/app/sample/showcase/common/screens/navigationdemos/MapTemplateWithPaneDemoScreen.java
index 8049bf3..da1987c 100644
--- a/car/app/app-samples/showcase/common/src/main/java/androidx/car/app/sample/showcase/common/screens/navigationdemos/MapTemplateWithPaneDemoScreen.java
+++ b/car/app/app-samples/showcase/common/src/main/java/androidx/car/app/sample/showcase/common/screens/navigationdemos/MapTemplateWithPaneDemoScreen.java
@@ -16,6 +16,7 @@
 
 package androidx.car.app.sample.showcase.common.screens.navigationdemos;
 
+import static androidx.car.app.CarToast.LENGTH_LONG;
 import static androidx.car.app.CarToast.LENGTH_SHORT;
 import static androidx.car.app.model.Action.FLAG_PRIMARY;
 
@@ -68,7 +69,10 @@
     @Override
     public Template onGetTemplate() {
         Pane.Builder paneBuilder = new Pane.Builder();
-        for (int i = 0; i < LIST_LIMIT; i++) {
+
+        paneBuilder.addRow(createRowWithExcessivelyLargeContent());
+        paneBuilder.addRow(createRowWithSecondaryAction(1));
+        for (int i = 2; i < LIST_LIMIT; i++) {
             paneBuilder.addRow(createRow(i));
         }
 
@@ -174,23 +178,48 @@
     }
 
     private Row createRow(int index) {
-        switch (index) {
-            case 0:
-                // Row with a large image.
-                return new Row.Builder()
-                        .setTitle(getCarContext().getString(R.string.first_row_title))
-                        .addText(getCarContext().getString(R.string.long_line_text))
-                        .setImage(new CarIcon.Builder(mRowLargeIcon).build())
-                        .build();
-            default:
-                return new Row.Builder()
-                        .setTitle(
-                                getCarContext().getString(R.string.other_row_title_prefix) + (index
-                                        + 1))
-                        .addText(getCarContext().getString(R.string.other_row_text))
-                        .addText(getCarContext().getString(R.string.other_row_text))
-                        .build();
+        return new Row.Builder()
+                .setTitle(createRowTitle(index))
+                .addText(getCarContext().getString(R.string.other_row_text))
+                .addText(getCarContext().getString(R.string.other_row_text))
+                .build();
+    }
 
+    private Row createRowWithExcessivelyLargeContent() {
+        return new Row.Builder()
+            .setTitle(getCarContext().getString(R.string.first_row_title))
+            .addText(getCarContext().getString(R.string.long_line_text))
+            .setImage(new CarIcon.Builder(mRowLargeIcon).build())
+            .build();
+    }
+
+    private Row createRowWithSecondaryAction(int index) {
+        Action action = new Action.Builder()
+                .setIcon(buildCarIconWithResources(R.drawable.baseline_question_mark_24))
+                .setOnClickListener(() -> CarToast.makeText(getCarContext(),
+                        R.string.secondary_action_toast, LENGTH_LONG).show())
+                .build();
+
+        Row.Builder rowBuilder = new Row.Builder()
+                .setTitle(createRowTitle(index))
+                .addText(getCarContext().getString(R.string.other_row_text));
+
+        if (getCarContext().getCarAppApiLevel() >= CarAppApiLevels.LEVEL_6) {
+            rowBuilder.addAction(action);
         }
+
+        return rowBuilder.build();
+    }
+
+    private CharSequence createRowTitle(int index) {
+        return getCarContext().getString(R.string.other_row_title_prefix) + (index + 1);
+    }
+
+    private CarIcon buildCarIconWithResources(int imageId) {
+        return new CarIcon.Builder(
+                IconCompat.createWithResource(
+                        getCarContext(),
+                        imageId))
+                .build();
     }
 }
diff --git a/compose/foundation/foundation/integration-tests/foundation-demos/build.gradle b/compose/foundation/foundation/integration-tests/foundation-demos/build.gradle
index 4382106..2692883 100644
--- a/compose/foundation/foundation/integration-tests/foundation-demos/build.gradle
+++ b/compose/foundation/foundation/integration-tests/foundation-demos/build.gradle
@@ -40,6 +40,24 @@
     implementation(project(":compose:ui:ui-tooling-preview"))
     debugImplementation(project(":compose:ui:ui-tooling"))
     implementation(project(":internal-testutils-fonts"))
+
+    testImplementation(project(":compose:test-utils"))
+    testImplementation(libs.testRules)
+    testImplementation(libs.testRunner)
+    testImplementation(libs.junit)
+    testImplementation(libs.truth)
+    testImplementation(libs.kotlinTest)
+    testImplementation(libs.mockitoCore4)
+    testImplementation(libs.kotlinReflect)
+    testImplementation(libs.mockitoKotlin4)
+
+    androidTestImplementation(project(":compose:test-utils"))
+    androidTestImplementation(libs.testRules)
+    androidTestImplementation(libs.testRunner)
+    androidTestImplementation(libs.junit)
+    androidTestImplementation(libs.kotlinTest)
+    androidTestImplementation(libs.truth)
+    androidTestImplementation(libs.espressoCore)
 }
 
 android {
diff --git a/compose/foundation/foundation/integration-tests/foundation-demos/src/androidTest/kotlin/androidx/compose/foundation/text2/input/BackspaceCommandTest.kt b/compose/foundation/foundation/integration-tests/foundation-demos/src/androidTest/kotlin/androidx/compose/foundation/text2/input/BackspaceCommandTest.kt
new file mode 100644
index 0000000..d7ce687
--- /dev/null
+++ b/compose/foundation/foundation/integration-tests/foundation-demos/src/androidTest/kotlin/androidx/compose/foundation/text2/input/BackspaceCommandTest.kt
@@ -0,0 +1,133 @@
+/*
+ * 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.compose.foundation.text2.input
+
+import androidx.compose.ui.text.TextRange
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SdkSuppress
+import androidx.test.filters.SmallTest
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@SmallTest
+@RunWith(AndroidJUnit4::class)
+class BackspaceCommandTest {
+
+    // Test sample surrogate pair characters.
+    private val SP1 = "\uD83D\uDE00" // U+1F600: GRINNING FACE
+    private val SP2 = "\uD83D\uDE01" // U+1F601: GRINNING FACE WITH SMILING EYES
+    private val SP3 = "\uD83D\uDE02" // U+1F602: FACE WITH TEARS OF JOY
+    private val SP4 = "\uD83D\uDE03" // U+1F603: SMILING FACE WITH OPEN MOUTH
+    private val SP5 = "\uD83D\uDE04" // U+1F604: SMILING FACE WITH OPEN MOUTH AND SMILING EYES
+
+    // Family ZWJ Emoji: U+1F468 U+200D U+1F469 U+200D U+1F467 U+200D U+1F466
+    private val ZWJ_EMOJI = "\uD83D\uDC68\u200D\uD83D\uDC69\u200D\uD83D\uDC67\u200D\uD83D\uDC66"
+
+    @Test
+    fun test_delete() {
+        val eb = EditingBuffer("ABCDE", TextRange(1))
+
+        eb.update(BackspaceCommand)
+
+        assertThat(eb.toString()).isEqualTo("BCDE")
+        assertThat(eb.cursor).isEqualTo(0)
+        assertThat(eb.hasComposition()).isFalse()
+    }
+
+    @Test
+    fun test_delete_from_offset0() {
+        val eb = EditingBuffer("ABCDE", TextRange.Zero)
+
+        eb.update(BackspaceCommand)
+
+        assertThat(eb.toString()).isEqualTo("ABCDE")
+        assertThat(eb.cursor).isEqualTo(0)
+        assertThat(eb.hasComposition()).isFalse()
+    }
+
+    @Test
+    fun test_delete_with_selection() {
+        val eb = EditingBuffer("ABCDE", TextRange(2, 3))
+
+        eb.update(BackspaceCommand)
+
+        assertThat(eb.toString()).isEqualTo("ABDE")
+        assertThat(eb.cursor).isEqualTo(2)
+        assertThat(eb.hasComposition()).isFalse()
+    }
+
+    @Test
+    fun test_delete_with_composition() {
+        val eb = EditingBuffer("ABCDE", TextRange(1))
+        eb.setComposition(2, 3)
+
+        eb.update(BackspaceCommand)
+
+        assertThat(eb.toString()).isEqualTo("ABDE")
+        assertThat(eb.cursor).isEqualTo(1)
+        assertThat(eb.hasComposition()).isFalse()
+    }
+
+    @Test
+    fun test_delete_surrogate_pair() {
+        val eb = EditingBuffer("$SP1$SP2$SP3$SP4$SP5", TextRange(2))
+
+        eb.update(BackspaceCommand)
+
+        assertThat(eb.toString()).isEqualTo("$SP2$SP3$SP4$SP5")
+        assertThat(eb.cursor).isEqualTo(0)
+        assertThat(eb.hasComposition()).isFalse()
+    }
+
+    @Test
+    fun test_delete_with_selection_surrogate_pair() {
+        val eb = EditingBuffer("$SP1$SP2$SP3$SP4$SP5", TextRange(4, 6))
+
+        eb.update(BackspaceCommand)
+
+        assertThat(eb.toString()).isEqualTo("$SP1$SP2$SP4$SP5")
+        assertThat(eb.cursor).isEqualTo(4)
+        assertThat(eb.hasComposition()).isFalse()
+    }
+
+    @Test
+    fun test_delete_with_composition_surrogate_pair() {
+        val eb = EditingBuffer("$SP1$SP2$SP3$SP4$SP5", TextRange(2))
+        eb.setComposition(4, 6)
+
+        eb.update(BackspaceCommand)
+
+        assertThat(eb.toString()).isEqualTo("$SP1$SP2$SP4$SP5")
+        assertThat(eb.cursor).isEqualTo(2)
+        assertThat(eb.hasComposition()).isFalse()
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 26)
+    fun test_delete_with_composition_zwj_emoji() {
+        val eb = EditingBuffer(
+            "$ZWJ_EMOJI$ZWJ_EMOJI",
+            TextRange(ZWJ_EMOJI.length)
+        )
+
+        eb.update(BackspaceCommand)
+
+        assertThat(eb.toString()).isEqualTo(ZWJ_EMOJI)
+        assertThat(eb.cursor).isEqualTo(0)
+        assertThat(eb.hasComposition()).isFalse()
+    }
+}
\ No newline at end of file
diff --git a/compose/foundation/foundation/integration-tests/foundation-demos/src/androidTest/kotlin/androidx/compose/foundation/text2/input/MoveCursorCommandTest.kt b/compose/foundation/foundation/integration-tests/foundation-demos/src/androidTest/kotlin/androidx/compose/foundation/text2/input/MoveCursorCommandTest.kt
new file mode 100644
index 0000000..6a0c00d
--- /dev/null
+++ b/compose/foundation/foundation/integration-tests/foundation-demos/src/androidTest/kotlin/androidx/compose/foundation/text2/input/MoveCursorCommandTest.kt
@@ -0,0 +1,172 @@
+/*
+ * 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.compose.foundation.text2.input
+
+import androidx.compose.ui.text.TextRange
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SdkSuppress
+import androidx.test.filters.SmallTest
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@SmallTest
+@RunWith(AndroidJUnit4::class)
+class MoveCursorCommandTest {
+    private val CH1 = "\uD83D\uDE00" // U+1F600
+    private val CH2 = "\uD83D\uDE01" // U+1F601
+    private val CH3 = "\uD83D\uDE02" // U+1F602
+    private val CH4 = "\uD83D\uDE03" // U+1F603
+    private val CH5 = "\uD83D\uDE04" // U+1F604
+
+    // U+1F468 U+200D U+1F469 U+200D U+1F467 U+200D U+1F466
+    private val FAMILY = "\uD83D\uDC68\u200D\uD83D\uDC69\u200D\uD83D\uDC67\u200D\uD83D\uDC66"
+
+    @Test
+    fun test_left() {
+        val eb = EditingBuffer("ABCDE", TextRange(3))
+
+        eb.update(MoveCursorCommand(-1))
+
+        assertThat(eb.toString()).isEqualTo("ABCDE")
+        assertThat(eb.cursor).isEqualTo(2)
+        assertThat(eb.hasComposition()).isFalse()
+    }
+
+    @Test
+    fun test_left_multiple() {
+        val eb = EditingBuffer("ABCDE", TextRange(3))
+
+        eb.update(MoveCursorCommand(-2))
+
+        assertThat(eb.toString()).isEqualTo("ABCDE")
+        assertThat(eb.cursor).isEqualTo(1)
+        assertThat(eb.hasComposition()).isFalse()
+    }
+
+    @Test
+    fun test_left_from_offset0() {
+        val eb = EditingBuffer("ABCDE", TextRange.Zero)
+
+        eb.update(MoveCursorCommand(-1))
+
+        assertThat(eb.toString()).isEqualTo("ABCDE")
+        assertThat(eb.cursor).isEqualTo(0)
+        assertThat(eb.hasComposition()).isFalse()
+    }
+
+    @Test
+    fun test_right() {
+        val eb = EditingBuffer("ABCDE", TextRange(3))
+
+        eb.update(MoveCursorCommand(1))
+
+        assertThat(eb.toString()).isEqualTo("ABCDE")
+        assertThat(eb.cursor).isEqualTo(4)
+        assertThat(eb.hasComposition()).isFalse()
+    }
+
+    @Test
+    fun test_right_multiple() {
+        val eb = EditingBuffer("ABCDE", TextRange(3))
+
+        eb.update(MoveCursorCommand(2))
+
+        assertThat(eb.toString()).isEqualTo("ABCDE")
+        assertThat(eb.cursor).isEqualTo(5)
+        assertThat(eb.hasComposition()).isFalse()
+    }
+
+    @Test
+    fun test_right_from_offset_length() {
+        val eb = EditingBuffer("ABCDE", TextRange(5))
+
+        eb.update(MoveCursorCommand(1))
+
+        assertThat(eb.toString()).isEqualTo("ABCDE")
+        assertThat(eb.cursor).isEqualTo(5)
+        assertThat(eb.hasComposition()).isFalse()
+    }
+
+    @Test
+    fun test_left_surrogate_pair() {
+        val eb = EditingBuffer("$CH1$CH2$CH3$CH4$CH5", TextRange(6))
+
+        eb.update(MoveCursorCommand(-1))
+
+        assertThat(eb.toString()).isEqualTo("$CH1$CH2$CH3$CH4$CH5")
+        assertThat(eb.cursor).isEqualTo(4)
+        assertThat(eb.hasComposition()).isFalse()
+    }
+
+    @Test
+    fun test_left_multiple_surrogate_pair() {
+        val eb = EditingBuffer("$CH1$CH2$CH3$CH4$CH5", TextRange(6))
+
+        eb.update(MoveCursorCommand(-2))
+
+        assertThat(eb.toString()).isEqualTo("$CH1$CH2$CH3$CH4$CH5")
+        assertThat(eb.cursor).isEqualTo(2)
+        assertThat(eb.hasComposition()).isFalse()
+    }
+
+    @Test
+    fun test_right_surrogate_pair() {
+        val eb = EditingBuffer("$CH1$CH2$CH3$CH4$CH5", TextRange(6))
+
+        eb.update(MoveCursorCommand(1))
+
+        assertThat(eb.toString()).isEqualTo("$CH1$CH2$CH3$CH4$CH5")
+        assertThat(eb.cursor).isEqualTo(8)
+        assertThat(eb.hasComposition()).isFalse()
+    }
+
+    @Test
+    fun test_right_multiple_surrogate_pair() {
+        val eb = EditingBuffer("$CH1$CH2$CH3$CH4$CH5", TextRange(6))
+
+        eb.update(MoveCursorCommand(2))
+
+        assertThat(eb.toString()).isEqualTo("$CH1$CH2$CH3$CH4$CH5")
+        assertThat(eb.cursor).isEqualTo(10)
+        assertThat(eb.hasComposition()).isFalse()
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 26)
+    fun test_left_emoji() {
+        val eb = EditingBuffer("$FAMILY$FAMILY", TextRange(FAMILY.length))
+
+        eb.update(MoveCursorCommand(-1))
+
+        assertThat(eb.toString()).isEqualTo("$FAMILY$FAMILY")
+        assertThat(eb.cursor).isEqualTo(0)
+        assertThat(eb.hasComposition()).isFalse()
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = 26)
+    fun test_right_emoji() {
+        val eb = EditingBuffer("$FAMILY$FAMILY", TextRange(FAMILY.length))
+
+        eb.update(MoveCursorCommand(1))
+
+        assertThat(eb.toString()).isEqualTo("$FAMILY$FAMILY")
+        assertThat(eb.cursor).isEqualTo(2 * FAMILY.length)
+        assertThat(eb.hasComposition()).isFalse()
+    }
+}
\ No newline at end of file
diff --git a/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/text/PlatformTextInputAdapterDemo.kt b/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/text/PlatformTextInputAdapterDemo.kt
new file mode 100644
index 0000000..ac09efc
--- /dev/null
+++ b/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/text/PlatformTextInputAdapterDemo.kt
@@ -0,0 +1,355 @@
+/*
+ * 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.
+ */
+
+@file:OptIn(ExperimentalTextApi::class)
+
+package androidx.compose.foundation.demos.text
+
+import android.content.Context.INPUT_METHOD_SERVICE
+import android.text.InputType
+import android.util.Log
+import android.view.View
+import android.view.inputmethod.BaseInputConnection
+import android.view.inputmethod.EditorInfo
+import android.view.inputmethod.InputConnection
+import android.view.inputmethod.InputMethodManager
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.RepeatMode
+import androidx.compose.animation.core.infiniteRepeatable
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.demos.text.WackyTextInputPlugin.createAdapter
+import androidx.compose.foundation.focusable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.text.BasicText
+import androidx.compose.material.Divider
+import androidx.compose.material.Text
+import androidx.compose.material.TextField
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collection.mutableVectorOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.neverEqualPolicy
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalPlatformTextInputPluginRegistry
+import androidx.compose.ui.text.ExperimentalTextApi
+import androidx.compose.ui.text.TextRange
+import androidx.compose.ui.text.input.PlatformTextInput
+import androidx.compose.ui.text.input.PlatformTextInputAdapter
+import androidx.compose.ui.text.input.PlatformTextInputPlugin
+import androidx.compose.ui.text.input.TextInputForTests
+import androidx.compose.ui.unit.dp
+import androidx.core.view.inputmethod.EditorInfoCompat
+import kotlinx.coroutines.launch
+
+private const val TAG = "WackyInput"
+
+@Composable
+fun PlatformTextInputAdapterDemo() {
+    Column {
+        Row {
+            var value by remember { mutableStateOf("") }
+            Text("Standard text field: ")
+            TextField(value = value, onValueChange = { value = it })
+        }
+        Divider()
+        Row {
+            val value = remember { WackyTextState("") }
+            Text("From-scratch text field: ")
+            WackyTextField(value, Modifier.weight(1f))
+        }
+    }
+}
+
+@Composable
+fun WackyTextField(state: WackyTextState, modifier: Modifier) {
+    // rememberAdapter returns an instance of WackyTextInputService as created by
+    // WackyTextInputPlugin. If this is the first call, it will instantiate the service and cache
+    // it, otherwise it will return the cached instance.
+    val service = LocalPlatformTextInputPluginRegistry.current
+        .rememberAdapter(WackyTextInputPlugin)
+    val scope = rememberCoroutineScope()
+    var isFocused by remember { mutableStateOf(false) }
+    val focusRequester = remember { FocusRequester() }
+    val borderWidth = remember { Animatable(0f) }
+
+    BasicText(
+        text = state.toString(),
+        modifier = modifier
+            .border(borderWidth.value.dp, Color.Gray.copy(alpha = 0.5f))
+            .focusRequester(focusRequester)
+            .onFocusChanged { focusState ->
+                if (isFocused == focusState.hasFocus) return@onFocusChanged
+                isFocused = focusState.hasFocus
+                if (focusState.hasFocus) {
+                    scope.launch {
+                        borderWidth.animateTo(
+                            2f, infiniteRepeatable(tween(500), RepeatMode.Reverse)
+                        )
+                    }
+                    service.startInput(state)
+                } else {
+                    scope.launch {
+                        borderWidth.animateTo(0f)
+                    }
+                    service.endInput()
+                }
+            }
+            .clickable { focusRequester.requestFocus() }
+            .focusable()
+    )
+}
+
+class WackyTextState(initialValue: String) {
+    var refresh by mutableStateOf(Unit, neverEqualPolicy())
+    val buffer = StringBuilder(initialValue)
+
+    override fun toString(): String {
+        refresh
+        return buffer.toString()
+    }
+}
+
+/**
+ * This is an object because it is stateless and the plugin instance is used as the key into the
+ * plugin registry to determine whether to reuse an existing adapter or create a new one.
+ *
+ * Even in a real library this object could be private or internal. It's only used by the text field
+ * composable to get instances of [WackyTextInputService]. And if that is also private/internal,
+ * then the internal implementation details of this text field are completely inaccessible to
+ * external code.
+ *
+ * If this code were to support multiplatform, this would be an `expect` object with `actual`
+ * implementations that had a different [createAdapter] call for each platform. They would probably
+ * all return the same type ([WackyTextInputService]), but that type would either be expect/actual
+ * itself or contain an instance of an expect/actual class.
+ */
+private object WackyTextInputPlugin :
+    PlatformTextInputPlugin<WackyTextInputService> {
+
+    override fun createAdapter(
+        platformTextInput: PlatformTextInput,
+        view: View
+    ): WackyTextInputService = WackyTextInputService(platformTextInput, view)
+}
+
+/**
+ * A _very_ incomplete, toy input service that just demonstrates another input service can handoff.
+ *
+ * Even in a real library, this class could be private or internal. It is only used as an
+ * implementation detail inside the text field composable.
+ *
+ * If this code were to support multiplatform, this would be either an `expect` class with `actual`
+ * implementations for each platform, OR contain an instance of an expect/actual class.
+ */
+private class WackyTextInputService(
+    private val platformTextInput: PlatformTextInput,
+    private val view: View,
+) : PlatformTextInputAdapter {
+    private val imm: InputMethodManager =
+        view.context.getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager
+    private var currentSession: WackyTextState? = null
+    private var currentConnection: WackyInputConnection? = null
+
+    override val inputForTests: TextInputForTests
+        get() = currentConnection ?: error("WackyTextInputService is not active")
+
+    fun startInput(state: WackyTextState) {
+        Log.d(TAG, "starting input for $state")
+        platformTextInput.requestInputFocus()
+        currentSession = state
+        view.post {
+            imm.showSoftInput(view, 0)
+        }
+    }
+
+    fun endInput() {
+        Log.d(TAG, "ending input")
+        platformTextInput.releaseInputFocus()
+        imm.restartInput(view)
+    }
+
+    // TODO input is broken when field is focused first
+    override fun createInputConnection(outAttrs: EditorInfo): InputConnection {
+        val state = currentSession
+        Log.d(TAG, "creating input connection for $state")
+        checkNotNull(state)
+
+        outAttrs.initialSelStart = state.buffer.length
+        outAttrs.initialSelEnd = state.buffer.length
+        outAttrs.inputType = InputType.TYPE_CLASS_TEXT
+        EditorInfoCompat.setInitialSurroundingText(outAttrs, state.toString())
+        outAttrs.imeOptions = EditorInfo.IME_ACTION_DONE or EditorInfo.IME_FLAG_NO_FULLSCREEN
+        state.refresh = Unit
+        return WackyInputConnection(state).also {
+            currentConnection = it
+        }
+    }
+
+    /**
+     * This class can mostly be ignored for the sake of this demo.
+     *
+     * This is where most of the actual communication with the Android IME system APIs is. It is
+     * an implementation of the Android interface [InputConnection], which is a very large and
+     * complex interface to implement. Here we use the [BaseInputConnection] class to avoid
+     * implementing the whole thing from scratch, and then only make very weak attempts at handling
+     * all the edge cases a real-world text editor would need to handle.
+     */
+    private inner class WackyInputConnection(
+        private val state: WackyTextState
+    ) : BaseInputConnection(view, false), TextInputForTests {
+        private var selection: TextRange = TextRange(state.buffer.length)
+        private var composition: TextRange? = null
+
+        private val batch = mutableVectorOf<() -> Unit>()
+
+        // region InputConnection
+
+        override fun beginBatchEdit(): Boolean = true
+
+        override fun endBatchEdit(): Boolean {
+            Log.d(TAG, "ending batch edit")
+            batch.forEach { it() }
+            batch.clear()
+            state.refresh = Unit
+            return true
+        }
+
+        override fun commitText(text: CharSequence?, newCursorPosition: Int): Boolean {
+            Log.d(TAG, "committing text: text=\"$text\", newCursorPosition=$newCursorPosition")
+            @Suppress("NAME_SHADOWING")
+            val text = text.toString()
+            batch += {
+                selection = if (composition != null) {
+                    state.buffer.replace(composition!!.start, composition!!.end, text)
+                    TextRange(composition!!.end)
+                } else {
+                    state.buffer.replace(selection.start, selection.end, text)
+                    TextRange(selection.start + text.length)
+                }
+            }
+            return true
+        }
+
+        override fun setComposingRegion(start: Int, end: Int): Boolean {
+            Log.d(TAG, "setting composing region: start=$start, end=$end")
+            batch += {
+                composition =
+                    TextRange(
+                        start.coerceIn(0, state.buffer.length),
+                        end.coerceIn(0, state.buffer.length)
+                    )
+            }
+            return true
+        }
+
+        override fun setComposingText(text: CharSequence?, newCursorPosition: Int): Boolean {
+            Log.d(
+                TAG,
+                "setting composing text: text=\"$text\", newCursorPosition=$newCursorPosition"
+            )
+            @Suppress("NAME_SHADOWING")
+            val text = text.toString()
+            batch += {
+                if (composition != null) {
+                    state.buffer.replace(composition!!.start, composition!!.end, text)
+                    if (text.isNotEmpty()) {
+                        composition =
+                            TextRange(composition!!.start, composition!!.start + text.length)
+                    }
+                    selection = TextRange(composition!!.end)
+                } else {
+                    state.buffer.replace(selection.start, selection.end, text)
+                    if (text.isNotEmpty()) {
+                        composition = TextRange(selection.start, selection.start + text.length)
+                    }
+                    selection = TextRange(selection.start + text.length)
+                }
+            }
+            return true
+        }
+
+        override fun deleteSurroundingText(beforeLength: Int, afterLength: Int): Boolean {
+            Log.d(
+                TAG,
+                "deleting surrounding text: beforeLength=$beforeLength, afterLength=$afterLength"
+            )
+            batch += {
+                state.buffer.delete(
+                    selection.end.coerceIn(0, state.buffer.length),
+                    (selection.end + afterLength).coerceIn(0, state.buffer.length)
+                )
+                state.buffer.delete(
+                    (selection.start - beforeLength).coerceIn(0, state.buffer.length),
+                    selection.start.coerceIn(0, state.buffer.length)
+                )
+            }
+            return false
+        }
+
+        override fun setSelection(start: Int, end: Int): Boolean {
+            Log.d(TAG, "setting selection: start=$start, end=$end")
+            batch += {
+                selection = TextRange(
+                    start.coerceIn(0, state.buffer.length),
+                    end.coerceIn(0, state.buffer.length)
+                )
+            }
+            return true
+        }
+
+        override fun finishComposingText(): Boolean {
+            Log.d(TAG, "finishing composing text")
+            batch += {
+                composition = null
+            }
+            return true
+        }
+
+        override fun closeConnection() {
+            Log.d(TAG, "closing input connection")
+            currentSession = null
+            currentConnection = null
+            // This calls finishComposingText, so don't clear the batch until after.
+            super.closeConnection()
+            batch.clear()
+        }
+
+        // endregion
+        // region TextInputForTests
+
+        override fun inputTextForTest(text: String) {
+            beginBatchEdit()
+            commitText(text, 0)
+            endBatchEdit()
+        }
+
+        override fun submitTextForTest() {
+            throw UnsupportedOperationException("just a test")
+        }
+
+        // endregion
+    }
+}
\ No newline at end of file
diff --git a/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/text/TextDemos.kt b/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/text/TextDemos.kt
index 836cda4..da10e9e 100644
--- a/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/text/TextDemos.kt
+++ b/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/text/TextDemos.kt
@@ -110,6 +110,7 @@
                 ComposableDemo("TextFieldValue") { TextFieldValueDemo() },
                 ComposableDemo("Tail Following Text Field") { TailFollowingTextFieldDemo() },
                 ComposableDemo("Focus immediately") { FocusTextFieldImmediatelyDemo() },
+                ComposableDemo("Secondary input system") { PlatformTextInputAdapterDemo() },
             )
         ),
         DemoCategory(
diff --git a/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/text2/input/ApplyEditCommand.kt b/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/text2/input/ApplyEditCommand.kt
new file mode 100644
index 0000000..0a1412a
--- /dev/null
+++ b/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/text2/input/ApplyEditCommand.kt
@@ -0,0 +1,253 @@
+/*
+ * 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.compose.foundation.text2.input
+
+import java.text.BreakIterator
+
+/**
+ * Applies a given [EditCommand] on this [EditingBuffer].
+ *
+ * Usually calls a dedicated extension function for a given subclass of [EditCommand].
+ *
+ * @throws IllegalArgumentException if EditCommand is not recognized.
+ */
+internal fun EditingBuffer.update(editCommand: EditCommand) {
+    when (editCommand) {
+        is BackspaceCommand -> applyBackspaceCommand()
+        is CommitTextCommand -> applyCommitTextCommand(editCommand)
+        is DeleteAllCommand -> replace(0, length, "")
+        is DeleteSurroundingTextCommand -> applyDeleteSurroundingTextCommand(editCommand)
+        is DeleteSurroundingTextInCodePointsCommand ->
+            applyDeleteSurroundingTextInCodePointsCommand(editCommand)
+        is FinishComposingTextCommand -> commitComposition()
+        is MoveCursorCommand -> applyMoveCursorCommand(editCommand)
+        is SetComposingRegionCommand -> applySetComposingRegionCommand(editCommand)
+        is SetComposingTextCommand -> applySetComposingTextCommand(editCommand)
+        is SetSelectionCommand -> applySetSelectionCommand(editCommand)
+    }
+}
+
+private fun EditingBuffer.applySetSelectionCommand(setSelectionCommand: SetSelectionCommand) {
+    // Sanitize the input: reverse if reversed, clamped into valid range.
+    val clampedStart = setSelectionCommand.start.coerceIn(0, length)
+    val clampedEnd = setSelectionCommand.end.coerceIn(0, length)
+    if (clampedStart < clampedEnd) {
+        setSelection(clampedStart, clampedEnd)
+    } else {
+        setSelection(clampedEnd, clampedStart)
+    }
+}
+
+private fun EditingBuffer.applySetComposingTextCommand(
+    setComposingTextCommand: SetComposingTextCommand
+) {
+    val text = setComposingTextCommand.text
+    val newCursorPosition = setComposingTextCommand.newCursorPosition
+
+    if (hasComposition()) {
+        // API doc says, if there is ongoing composing text, replace it with new text.
+        val compositionStart = compositionStart
+        replace(compositionStart, compositionEnd, text)
+        if (text.isNotEmpty()) {
+            setComposition(compositionStart, compositionStart + text.length)
+        }
+    } else {
+        // If there is no composing text, insert composing text into cursor position with
+        // removing selected text if any.
+        val selectionStart = selectionStart
+        replace(selectionStart, selectionEnd, text)
+        if (text.isNotEmpty()) {
+            setComposition(selectionStart, selectionStart + text.length)
+        }
+    }
+
+    // After replace function is called, the editing buffer places the cursor at the end of the
+    // modified range.
+    val newCursor = cursor
+
+    // See above API description for the meaning of newCursorPosition.
+    val newCursorInBuffer = if (newCursorPosition > 0) {
+        newCursor + newCursorPosition - 1
+    } else {
+        newCursor + newCursorPosition - text.length
+    }
+
+    cursor = newCursorInBuffer.coerceIn(0, length)
+}
+
+private fun EditingBuffer.applySetComposingRegionCommand(
+    setComposingRegionCommand: SetComposingRegionCommand
+) {
+    // The API description says, different from SetComposingText, SetComposingRegion must
+    // preserve the ongoing composition text and set new composition.
+    if (hasComposition()) {
+        commitComposition()
+    }
+
+    // Sanitize the input: reverse if reversed, clamped into valid range, ignore empty range.
+    val clampedStart = setComposingRegionCommand.start.coerceIn(0, length)
+    val clampedEnd = setComposingRegionCommand.end.coerceIn(0, length)
+    if (clampedStart == clampedEnd) {
+        // do nothing. empty composition range is not allowed.
+    } else if (clampedStart < clampedEnd) {
+        setComposition(clampedStart, clampedEnd)
+    } else {
+        setComposition(clampedEnd, clampedStart)
+    }
+}
+
+private fun EditingBuffer.applyMoveCursorCommand(moveCursorCommand: MoveCursorCommand) {
+    if (cursor == -1) {
+        cursor = selectionStart
+    }
+
+    var newCursor = selectionStart
+    val bufferText = toString()
+    if (moveCursorCommand.amount > 0) {
+        for (i in 0 until moveCursorCommand.amount) {
+            val next = bufferText.findFollowingBreak(newCursor)
+            if (next == -1) break
+            newCursor = next
+        }
+    } else {
+        for (i in 0 until -moveCursorCommand.amount) {
+            val prev = bufferText.findPrecedingBreak(newCursor)
+            if (prev == -1) break
+            newCursor = prev
+        }
+    }
+
+    cursor = newCursor
+}
+
+private fun EditingBuffer.applyDeleteSurroundingTextInCodePointsCommand(
+    deleteSurroundingTextInCodePointsCommand: DeleteSurroundingTextInCodePointsCommand
+) {
+    // Convert code point length into character length. Then call the common logic of the
+    // DeleteSurroundingTextEditOp
+    var beforeLenInChars = 0
+    for (i in 0 until deleteSurroundingTextInCodePointsCommand.lengthBeforeCursor) {
+        beforeLenInChars++
+        if (selectionStart > beforeLenInChars) {
+            val lead = this[selectionStart - beforeLenInChars - 1]
+            val trail = this[selectionStart - beforeLenInChars]
+
+            if (isSurrogatePair(lead, trail)) {
+                beforeLenInChars++
+            }
+        }
+        if (beforeLenInChars == selectionStart) break
+    }
+
+    var afterLenInChars = 0
+    for (i in 0 until deleteSurroundingTextInCodePointsCommand.lengthAfterCursor) {
+        afterLenInChars++
+        if (selectionEnd + afterLenInChars < length) {
+            val lead = this[selectionEnd + afterLenInChars - 1]
+            val trail = this[selectionEnd + afterLenInChars]
+
+            if (isSurrogatePair(lead, trail)) {
+                afterLenInChars++
+            }
+        }
+        if (selectionEnd + afterLenInChars == length) break
+    }
+
+    delete(selectionEnd, selectionEnd + afterLenInChars)
+    delete(selectionStart - beforeLenInChars, selectionStart)
+}
+
+private fun EditingBuffer.applyDeleteSurroundingTextCommand(
+    deleteSurroundingTextCommand: DeleteSurroundingTextCommand
+) {
+    delete(
+        selectionEnd,
+        minOf(selectionEnd + deleteSurroundingTextCommand.lengthAfterCursor, length)
+    )
+
+    delete(
+        maxOf(0, selectionStart - deleteSurroundingTextCommand.lengthBeforeCursor),
+        selectionStart
+    )
+}
+
+private fun EditingBuffer.applyBackspaceCommand() {
+    if (hasComposition()) {
+        delete(compositionStart, compositionEnd)
+        return
+    }
+
+    if (cursor == -1) {
+        val delStart = selectionStart
+        val delEnd = selectionEnd
+        cursor = selectionStart
+        delete(delStart, delEnd)
+        return
+    }
+
+    if (cursor == 0) {
+        return
+    }
+
+    val prevCursorPos = toString().findPrecedingBreak(cursor)
+    delete(prevCursorPos, cursor)
+}
+
+private fun EditingBuffer.applyCommitTextCommand(commitTextCommand: CommitTextCommand) {
+    // API description says replace ongoing composition text if there. Then, if there is no
+    // composition text, insert text into cursor position or replace selection.
+    if (hasComposition()) {
+        replace(compositionStart, compositionEnd, commitTextCommand.text)
+    } else {
+        // In this editing buffer, insert into cursor or replace selection are equivalent.
+        replace(selectionStart, selectionEnd, commitTextCommand.text)
+    }
+
+    // After replace function is called, the editing buffer places the cursor at the end of the
+    // modified range.
+    val newCursor = cursor
+
+    // See above API description for the meaning of newCursorPosition.
+    val newCursorInBuffer = if (commitTextCommand.newCursorPosition > 0) {
+        newCursor + commitTextCommand.newCursorPosition - 1
+    } else {
+        newCursor + commitTextCommand.newCursorPosition - commitTextCommand.text.length
+    }
+
+    cursor = newCursorInBuffer.coerceIn(0, length)
+}
+
+/**
+ * Helper function that returns true when [high] is a Unicode high-surrogate code unit and [low]
+ * is a Unicode low-surrogate code unit.
+ */
+private fun isSurrogatePair(high: Char, low: Char): Boolean =
+    high.isHighSurrogate() && low.isLowSurrogate()
+
+// TODO(halilibo): Remove when migrating back to foundation
+private fun String.findPrecedingBreak(index: Int): Int {
+    val it = BreakIterator.getCharacterInstance()
+    it.setText(this)
+    return it.preceding(index)
+}
+
+// TODO(halilibo): Remove when migrating back to foundation
+private fun String.findFollowingBreak(index: Int): Int {
+    val it = BreakIterator.getCharacterInstance()
+    it.setText(this)
+    return it.following(index)
+}
\ No newline at end of file
diff --git a/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/text2/input/EdiProcessor.kt b/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/text2/input/EdiProcessor.kt
new file mode 100644
index 0000000..fe3f3ba
--- /dev/null
+++ b/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/text2/input/EdiProcessor.kt
@@ -0,0 +1,200 @@
+/*
+ * 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.compose.foundation.text2.input
+
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.text.TextRange
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.input.TextFieldValue
+import androidx.compose.ui.text.input.TextInputService
+import androidx.compose.ui.text.input.TextInputSession
+import androidx.compose.ui.util.fastForEach
+
+/**
+ * Helper class to apply [EditCommand]s on an internal buffer. Used by TextField Composable
+ * to combine TextFieldValue lifecycle with the editing operations.
+ *
+ * When a [TextFieldValue] is suggested by the developer, [reset] should be called.
+ * When [TextInputService] provides [EditCommand]s, they should be applied to the internal
+ * buffer using [apply].
+ */
+internal class EditProcessor(
+    initialValue: TextFieldValue
+) {
+
+    constructor() : this(
+        TextFieldValue(
+            EmptyAnnotatedString,
+            TextRange.Zero,
+            null
+        )
+    )
+
+    /**
+     * The current state of the internal editing buffer as a [TextFieldValue] backed by Snapshot
+     * state, so its readers can get updates in composition context.
+     */
+    var value: TextFieldValue by mutableStateOf(initialValue)
+        private set
+
+    // The editing buffer used for applying editor commands from IME.
+    internal var mBuffer: EditingBuffer = EditingBuffer(
+        text = initialValue.annotatedString,
+        selection = initialValue.selection
+    )
+        private set
+
+    /**
+     * Must be called whenever new TextFieldValue arrives.
+     *
+     * This method updates the internal editing buffer with the given TextFieldValue.
+     * This method may tell the IME about the selection offset changes or extracted text changes.
+     *
+     * Retro; this function seems straightforward but it actually does something very specific
+     * for the previous state hoisting design of TextField. In each recomposition, this function
+     * was supposed to be called from BasicTextField with the new value. However, this new value
+     * wouldn't be new to the internal buffer since the changes coming from IME were already applied
+     * in previous composition and sent through onValueChange to the hoisted state.
+     *
+     * Therefore, this function has to care for two scenarios. 1) Developer doesn't interfere with
+     * the value and the editing buffer doesn't have to change because previous composition value
+     * is sent back. 2) Developer interferes and the new value is different than the current buffer
+     * state. The difference could be text, selection, or composition.
+     *
+     * In short, `reset` function used to compare newly arrived value in this composition with the
+     * internal buffer for any differences. This won't be necessary anymore since the internal state
+     * is going to be the only source of truth for the new BasicTextField. However, `reset` would
+     * gain a new responsibility in the cases where developer filters the input or adds a template.
+     * This would again introduce a need for sync between internal buffer and the state value.
+     */
+    fun reset(
+        newValue: TextFieldValue,
+        textInputSession: TextInputSession?,
+    ) {
+        var textChanged = false
+        var selectionChanged = false
+        val compositionChanged = newValue.composition != mBuffer.composition
+
+        // TODO(halilibo): String equality check marker.
+        if (value.annotatedString != newValue.annotatedString) {
+            mBuffer = EditingBuffer(
+                text = newValue.annotatedString,
+                selection = newValue.selection
+            )
+            textChanged = true
+        } else if (value.selection != newValue.selection) {
+            mBuffer.setSelection(newValue.selection.min, newValue.selection.max)
+            selectionChanged = true
+        }
+
+        val composition = newValue.composition
+        if (composition == null) {
+            mBuffer.commitComposition()
+        } else if (!composition.collapsed) {
+            mBuffer.setComposition(composition.min, composition.max)
+        }
+
+        // this is the same code as in TextInputServiceAndroid class where restartInput is decided.
+        // if restartInput is going to be called the composition has to be cleared otherwise it
+        // results in keyboards behaving strangely.
+        val finalValue = if (textChanged || (!selectionChanged && compositionChanged)) {
+            mBuffer.commitComposition()
+            newValue.copy(composition = null)
+        } else {
+            newValue
+        }
+
+        val oldValue = value
+        value = finalValue
+
+        textInputSession?.updateState(oldValue, finalValue)
+    }
+
+    /**
+     * Applies a set of [editCommands] to the internal text editing buffer.
+     *
+     * After applying the changes, returns the final state of the editing buffer as a
+     * [TextFieldValue]
+     *
+     * @param editCommands [EditCommand]s to be applied to the editing buffer.
+     *
+     * @return the [TextFieldValue] representation of the final buffer state.
+     */
+    fun update(editCommands: List<EditCommand>): TextFieldValue {
+        var lastCommand: EditCommand? = null
+        try {
+            editCommands.fastForEach {
+                lastCommand = it
+                mBuffer.update(it)
+            }
+        } catch (e: Exception) {
+            throw RuntimeException(generateBatchErrorMessage(editCommands, lastCommand), e)
+        }
+
+        val newState = TextFieldValue(
+            annotatedString = mBuffer.toAnnotatedString(),
+            selection = mBuffer.selection,
+            composition = mBuffer.composition
+        )
+
+        value = newState
+        return newState
+    }
+
+    private fun generateBatchErrorMessage(
+        editCommands: List<EditCommand>,
+        failedCommand: EditCommand?,
+    ): String = buildString {
+        appendLine(
+            "Error while applying EditCommand batch to buffer (" +
+                "length=${mBuffer.length}, " +
+                "composition=${mBuffer.composition}, " +
+                "selection=${mBuffer.selection}):"
+        )
+        @Suppress("ListIterator")
+        editCommands.joinTo(this, separator = "\n") {
+            val prefix = if (failedCommand === it) " > " else "   "
+            prefix + it.toStringForLog()
+        }
+    }
+}
+
+/**
+ * Generate a description of the command that is suitable for logging – this should not include
+ * any user-entered text, which may be sensitive.
+ */
+internal fun EditCommand.toStringForLog(): String = when (this) {
+    is CommitTextCommand ->
+        "CommitTextCommand(text.length=${text.length}, newCursorPosition=$newCursorPosition)"
+
+    is SetComposingTextCommand ->
+        "SetComposingTextCommand(text.length=${text.length}, " +
+            "newCursorPosition=$newCursorPosition)"
+
+    is SetComposingRegionCommand -> toString()
+    is DeleteSurroundingTextCommand -> toString()
+    is DeleteSurroundingTextInCodePointsCommand -> toString()
+    is SetSelectionCommand -> toString()
+    is FinishComposingTextCommand -> toString()
+    is BackspaceCommand -> toString()
+    is MoveCursorCommand -> toString()
+    is DeleteAllCommand -> toString()
+}
+
+private val EmptyAnnotatedString = buildAnnotatedString { }
\ No newline at end of file
diff --git a/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/text2/input/EditCommand.kt b/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/text2/input/EditCommand.kt
new file mode 100644
index 0000000..c3ecd36
--- /dev/null
+++ b/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/text2/input/EditCommand.kt
@@ -0,0 +1,241 @@
+/*
+ * 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.compose.foundation.text2.input
+
+import androidx.compose.ui.text.AnnotatedString
+
+/**
+ * [EditCommand] is a command representation for the platform IME API function calls. The commands
+ * from the IME as function calls are translated into command pattern. For example, as a result of
+ * commit text function call by IME [CommitTextCommand] is created.
+ */
+// TODO(halilibo): Consider value class or some other alternatives like passing the buffer into
+//  InputConnection, eliminating the need for EditCommand.
+internal sealed interface EditCommand
+
+/**
+ * Commit final [text] to the text box and set the new cursor position.
+ *
+ * See [`commitText`](https://developer.android.com/reference/android/view/inputmethod/InputConnection.html#commitText(java.lang.CharSequence,%20int)).
+ *
+ * @param annotatedString The text to commit.
+ * @param newCursorPosition The cursor position after inserted text.
+ */
+internal data class CommitTextCommand(
+    val annotatedString: AnnotatedString,
+    val newCursorPosition: Int
+) : EditCommand {
+
+    constructor(
+        /**
+         * The text to commit. We ignore any styles in the original API.
+         */
+        text: String,
+        /**
+         * The cursor position after setting composing text.
+         */
+        newCursorPosition: Int
+    ) : this(AnnotatedString(text), newCursorPosition)
+
+    val text: String get() = annotatedString.text
+
+    override fun toString(): String {
+        return "CommitTextCommand(text='$text', newCursorPosition=$newCursorPosition)"
+    }
+}
+
+/**
+ * Mark a certain region of text as composing text.
+ *
+ * See [`setComposingRegion`](https://developer.android.com/reference/android/view/inputmethod/InputConnection.html#setComposingRegion(int,%2520int)).
+ *
+ * @param start The inclusive start offset of the composing region.
+ * @param end The exclusive end offset of the composing region
+ */
+internal data class SetComposingRegionCommand(
+    val start: Int,
+    val end: Int
+) : EditCommand {
+
+    override fun toString(): String {
+        return "SetComposingRegionCommand(start=$start, end=$end)"
+    }
+}
+
+/**
+ * Replace the currently composing text with the given text, and set the new cursor position. Any
+ * composing text set previously will be removed automatically.
+ *
+ * See [`setComposingText`](https://developer.android.com/reference/android/view/inputmethod/InputConnection.html#setComposingText(java.lang.CharSequence,%2520int)).
+ *
+ * @param annotatedString The composing text.
+ * @param newCursorPosition The cursor position after setting composing text.
+ */
+internal data class SetComposingTextCommand(
+    val annotatedString: AnnotatedString,
+    val newCursorPosition: Int
+) : EditCommand {
+
+    constructor(
+        /**
+         * The composing text.
+         */
+        text: String,
+        /**
+         * The cursor position after setting composing text.
+         */
+        newCursorPosition: Int
+    ) : this(AnnotatedString(text), newCursorPosition)
+
+    val text: String get() = annotatedString.text
+
+    override fun toString(): String {
+        return "SetComposingTextCommand(text='$text', newCursorPosition=$newCursorPosition)"
+    }
+}
+
+/**
+ * Delete [lengthBeforeCursor] characters of text before the current cursor position, and delete
+ * [lengthAfterCursor] characters of text after the current cursor position, excluding the selection.
+ *
+ * Before and after refer to the order of the characters in the string, not to their visual
+ * representation.
+ *
+ * See [`deleteSurroundingText`](https://developer.android.com/reference/android/view/inputmethod/InputConnection.html#deleteSurroundingText(int,%2520int)).
+ *
+ * @param lengthBeforeCursor The number of characters in UTF-16 before the cursor to be deleted.
+ * Must be non-negative.
+ * @param lengthAfterCursor The number of characters in UTF-16 after the cursor to be deleted.
+ * Must be non-negative.
+ */
+internal data class DeleteSurroundingTextCommand(
+    val lengthBeforeCursor: Int,
+    val lengthAfterCursor: Int
+) : EditCommand {
+    init {
+        require(lengthBeforeCursor >= 0 && lengthAfterCursor >= 0) {
+            "Expected lengthBeforeCursor and lengthAfterCursor to be non-negative, were " +
+                "$lengthBeforeCursor and $lengthAfterCursor respectively."
+        }
+    }
+
+    override fun toString(): String {
+        return "DeleteSurroundingTextCommand(lengthBeforeCursor=$lengthBeforeCursor, " +
+            "lengthAfterCursor=$lengthAfterCursor)"
+    }
+}
+
+/**
+ * A variant of [DeleteSurroundingTextCommand]. The difference is that
+ * * The lengths are supplied in code points, not in chars.
+ * * This command does nothing if there are one or more invalid surrogate pairs
+ * in the requested range.
+ *
+ * See [`deleteSurroundingTextInCodePoints`](https://developer.android.com/reference/android/view/inputmethod/InputConnection.html#deleteSurroundingTextInCodePoints(int,%2520int)).
+ *
+ * @param lengthBeforeCursor The number of characters in Unicode code points before the cursor to be
+ * deleted. Must be non-negative.
+ * @param lengthAfterCursor The number of characters in Unicode code points after the cursor to be
+ * deleted. Must be non-negative.
+ */
+internal data class DeleteSurroundingTextInCodePointsCommand(
+    val lengthBeforeCursor: Int,
+    val lengthAfterCursor: Int
+) : EditCommand {
+    init {
+        require(lengthBeforeCursor >= 0 && lengthAfterCursor >= 0) {
+            "Expected lengthBeforeCursor and lengthAfterCursor to be non-negative, were " +
+                "$lengthBeforeCursor and $lengthAfterCursor respectively."
+        }
+    }
+
+    override fun toString(): String {
+        return "DeleteSurroundingTextInCodePointsCommand(lengthBeforeCursor=$lengthBeforeCursor, " +
+            "lengthAfterCursor=$lengthAfterCursor)"
+    }
+}
+
+/**
+ * Sets the selection on the text. When [start] and [end] have the same value, it sets the cursor
+ * position.
+ *
+ * See [`setSelection`](https://developer.android.com/reference/android/view/inputmethod/InputConnection.html#setSelection(int,%2520int)).
+ *
+ * @param start The inclusive start offset of the selection region.
+ * @param end The exclusive end offset of the selection region.
+ */
+internal data class SetSelectionCommand(
+    val start: Int,
+    val end: Int
+) : EditCommand {
+
+    override fun toString(): String {
+        return "SetSelectionCommand(start=$start, end=$end)"
+    }
+}
+
+/**
+ * Finishes the composing text that is currently active. This simply leaves the text as-is,
+ * removing any special composing styling or other state that was around it. The cursor position
+ * remains unchanged.
+ *
+ * See [`finishComposingText`](https://developer.android.com/reference/android/view/inputmethod/InputConnection.html#finishComposingText()).
+ */
+internal object FinishComposingTextCommand : EditCommand {
+
+    override fun toString(): String {
+        return "FinishComposingTextCommand()"
+    }
+}
+
+/**
+ * Represents a backspace operation at the cursor position.
+ *
+ * If there is composition, delete the text in the composition range.
+ * If there is no composition but there is selection, delete whole selected range.
+ * If there is no composition and selection, perform backspace key event at the cursor position.
+ */
+internal object BackspaceCommand : EditCommand {
+
+    override fun toString(): String {
+        return "BackspaceCommand()"
+    }
+}
+
+/**
+ * Moves the cursor with [amount] characters.
+ *
+ * If there is selection, cancel the selection first and move the cursor to the selection start
+ * position. Then perform the cursor movement.
+ *
+ * @param amount The amount of cursor movement. If you want to move backward, pass negative value.
+ */
+internal data class MoveCursorCommand(val amount: Int) : EditCommand {
+    override fun toString(): String {
+        return "MoveCursorCommand(amount=$amount)"
+    }
+}
+
+/**
+ * Deletes all the text in the buffer.
+ */
+internal object DeleteAllCommand : EditCommand {
+
+    override fun toString(): String {
+        return "DeleteAllCommand()"
+    }
+}
\ No newline at end of file
diff --git a/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/text2/input/EditingBuffer.kt b/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/text2/input/EditingBuffer.kt
new file mode 100644
index 0000000..a2a1f87
--- /dev/null
+++ b/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/text2/input/EditingBuffer.kt
@@ -0,0 +1,399 @@
+/*
+ * 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.compose.foundation.text2.input
+
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.TextRange
+
+/**
+ * The editing buffer
+ *
+ * This class manages the all editing relate states, editing buffers, selection, styles, etc.
+ */
+internal class EditingBuffer(
+    /**
+     * The initial text of this editing buffer
+     */
+    text: AnnotatedString,
+    /**
+     * The initial selection range of this buffer.
+     * If you provide collapsed selection, it is treated as the cursor position. The cursor and
+     * selection cannot exists at the same time.
+     * The selection must points the valid index of the initialText, otherwise
+     * IndexOutOfBoundsException will be thrown.
+     */
+    selection: TextRange
+) {
+    internal companion object {
+        const val NOWHERE = -1
+    }
+
+    private val gapBuffer = PartialGapBuffer(text.text)
+
+    /**
+     * The inclusive selection start offset
+     */
+    var selectionStart = selection.min
+        private set(value) {
+            require(value >= 0) { "Cannot set selectionStart to a negative value: $value" }
+            field = value
+        }
+
+    /**
+     * The exclusive selection end offset
+     */
+    var selectionEnd = selection.max
+        private set(value) {
+            require(value >= 0) { "Cannot set selectionEnd to a negative value: $value" }
+            field = value
+        }
+
+    /**
+     * The inclusive composition start offset
+     *
+     * If there is no composing text, returns -1
+     */
+    var compositionStart = NOWHERE
+        private set
+
+    /**
+     * The exclusive composition end offset
+     *
+     * If there is no composing text, returns -1
+     */
+    var compositionEnd = NOWHERE
+        private set
+
+    /**
+     * Helper function that returns true if the editing buffer has composition text
+     */
+    fun hasComposition(): Boolean = compositionStart != NOWHERE
+
+    /**
+     * Returns the composition information as TextRange. Returns null if no
+     * composition is set.
+     */
+    val composition: TextRange?
+        get() = if (hasComposition()) {
+            TextRange(compositionStart, compositionEnd)
+        } else null
+
+    /**
+     * Returns the selection information as TextRange
+     */
+    val selection: TextRange
+        get() = TextRange(selectionStart, selectionEnd)
+
+    /**
+     * Helper accessor for cursor offset
+     */
+    /*VisibleForTesting*/
+    var cursor: Int
+        /**
+         * Return the cursor offset.
+         *
+         * Since selection and cursor cannot exist at the same time, return -1 if there is a
+         * selection.
+         */
+        get() = if (selectionStart == selectionEnd) selectionEnd else -1
+        /**
+         * Set the cursor offset.
+         *
+         * Since selection and cursor cannot exist at the same time, cancel selection if there is.
+         */
+        set(cursor) = setSelection(cursor, cursor)
+
+    /**
+     * [] operator for the character at the index.
+     */
+    operator fun get(index: Int): Char = gapBuffer[index]
+
+    /**
+     * Returns the length of the buffer.
+     */
+    val length: Int get() = gapBuffer.length
+
+    constructor(
+        text: String,
+        selection: TextRange
+    ) : this(AnnotatedString(text), selection)
+
+    init {
+        val start = selection.min
+        val end = selection.max
+        if (start < 0 || start > text.length) {
+            throw IndexOutOfBoundsException(
+                "start ($start) offset is outside of text region ${text.length}"
+            )
+        }
+
+        if (end < 0 || end > text.length) {
+            throw IndexOutOfBoundsException(
+                "end ($end) offset is outside of text region ${text.length}"
+            )
+        }
+
+        if (start > end) {
+            throw IllegalArgumentException("Do not set reversed range: $start > $end")
+        }
+    }
+
+    fun replace(start: Int, end: Int, text: AnnotatedString) {
+        replace(start, end, text.text)
+    }
+
+    /**
+     * Replace the text and move the cursor to the end of inserted text.
+     *
+     * This function cancels selection if there.
+     *
+     * @throws IndexOutOfBoundsException if start or end offset is outside of current buffer
+     * @throws IllegalArgumentException if start is larger than end. (reversed range)
+     */
+    fun replace(start: Int, end: Int, text: String) {
+
+        if (start < 0 || start > gapBuffer.length) {
+            throw IndexOutOfBoundsException(
+                "start ($start) offset is outside of text region ${gapBuffer.length}"
+            )
+        }
+
+        if (end < 0 || end > gapBuffer.length) {
+            throw IndexOutOfBoundsException(
+                "end ($end) offset is outside of text region ${gapBuffer.length}"
+            )
+        }
+
+        if (start > end) {
+            throw IllegalArgumentException("Do not set reversed range: $start > $end")
+        }
+
+        gapBuffer.replace(start, end, text)
+
+        // On Android, all text modification APIs also provides explicit cursor location. On the
+        // hand, desktop application usually doesn't. So, here tentatively move the cursor to the
+        // end offset of the editing area for desktop like application. In case of Android,
+        // implementation will call setSelection immediately after replace function to update this
+        // tentative cursor location.
+        selectionStart = start + text.length
+        selectionEnd = start + text.length
+
+        // Similarly, if text modification happens, cancel ongoing composition. If caller want to
+        // change the composition text, it is caller responsibility to call setComposition again
+        // to set composition range after replace function.
+        compositionStart = NOWHERE
+        compositionEnd = NOWHERE
+    }
+
+    /**
+     * Remove the given range of text.
+     *
+     * Different from replace method, this doesn't move cursor location to the end of modified text.
+     * Instead, preserve the selection with adjusting the deleted text.
+     */
+    fun delete(start: Int, end: Int) {
+        val deleteRange = TextRange(start, end)
+
+        gapBuffer.replace(start, end, "")
+
+        val newSelection = updateRangeAfterDelete(
+            TextRange(selectionStart, selectionEnd),
+            deleteRange
+        )
+        selectionStart = newSelection.min
+        selectionEnd = newSelection.max
+
+        if (hasComposition()) {
+            val compositionRange = TextRange(compositionStart, compositionEnd)
+            val newComposition = updateRangeAfterDelete(compositionRange, deleteRange)
+            if (newComposition.collapsed) {
+                commitComposition()
+            } else {
+                compositionStart = newComposition.min
+                compositionEnd = newComposition.max
+            }
+        }
+    }
+
+    /**
+     * Mark the specified area of the text as selected text.
+     *
+     * You can set cursor by specifying the same value to `start` and `end`.
+     * The reversed range is not allowed.
+     * @param start the inclusive start offset of the selection
+     * @param end the exclusive end offset of the selection
+     *
+     * @throws IndexOutOfBoundsException if start or end offset is outside of current buffer.
+     * @throws IllegalArgumentException if start is larger than end. (reversed range)
+     */
+    fun setSelection(start: Int, end: Int) {
+        if (start < 0 || start > gapBuffer.length) {
+            throw IndexOutOfBoundsException(
+                "start ($start) offset is outside of text region ${gapBuffer.length}"
+            )
+        }
+        if (end < 0 || end > gapBuffer.length) {
+            throw IndexOutOfBoundsException(
+                "end ($end) offset is outside of text region ${gapBuffer.length}"
+            )
+        }
+        if (start > end) {
+            throw IllegalArgumentException("Do not set reversed range: $start > $end")
+        }
+
+        selectionStart = start
+        selectionEnd = end
+    }
+
+    /**
+     * Mark the specified area of the text as composition text.
+     *
+     * The empty range or reversed range is not allowed.
+     * Use clearComposition in case of clearing composition.
+     *
+     * @param start the inclusive start offset of the composition
+     * @param end the exclusive end offset of the composition
+     *
+     * @throws IndexOutOfBoundsException if start or end offset is ouside of current buffer
+     * @throws IllegalArgumentException if start is larger than or equal to end. (reversed or
+     *                                  collapsed range)
+     */
+    fun setComposition(start: Int, end: Int) {
+        if (start < 0 || start > gapBuffer.length) {
+            throw IndexOutOfBoundsException(
+                "start ($start) offset is outside of text region ${gapBuffer.length}"
+            )
+        }
+        if (end < 0 || end > gapBuffer.length) {
+            throw IndexOutOfBoundsException(
+                "end ($end) offset is outside of text region ${gapBuffer.length}"
+            )
+        }
+        if (start >= end) {
+            throw IllegalArgumentException("Do not set reversed or empty range: $start > $end")
+        }
+
+        compositionStart = start
+        compositionEnd = end
+    }
+
+    /**
+     * Removes the ongoing composition text and reset the composition range.
+     */
+    fun cancelComposition() {
+        replace(compositionStart, compositionEnd, "")
+        compositionStart = NOWHERE
+        compositionEnd = NOWHERE
+    }
+
+    /**
+     * Commits the ongoing composition text and reset the composition range.
+     */
+    fun commitComposition() {
+        compositionStart = NOWHERE
+        compositionEnd = NOWHERE
+    }
+
+    override fun toString(): String = gapBuffer.toString()
+
+    fun toAnnotatedString(): AnnotatedString = AnnotatedString(toString())
+}
+
+/**
+ * Returns the updated TextRange for [target] after the [deleted] TextRange is deleted as a Pair.
+ *
+ * If the [deleted] Range covers the whole target, Pair(-1,-1) is returned.
+ */
+/*@VisibleForTesting*/
+internal fun updateRangeAfterDelete(target: TextRange, deleted: TextRange): TextRange {
+    var targetMin = target.min
+    var targetMax = target.max
+
+    // Following figure shows the deletion range and composition range.
+    // |---| represents deleted range.
+    // |===| represents target range.
+    if (deleted.intersects(target)) {
+        if (deleted.contains(target)) {
+            // Input:
+            //   Buffer     : ABCDEFGHIJKLMNOPQRSTUVWXYZ
+            //   Deleted    :      |-------------|
+            //   Target     :          |======|
+            //
+            // Result:
+            //   Buffer     : ABCDETUVWXYZ
+            //   Target     :
+            targetMin = deleted.min
+            targetMax = targetMin
+        } else if (target.contains(deleted)) {
+            // Input:
+            //   Buffer     : ABCDEFGHIJKLMNOPQRSTUVWXYZ
+            //   Deleted    :          |------|
+            //   Target     :        |==========|
+            //
+            // Result:
+            //   Buffer     : ABCDEFGHIQRSTUVWXYZ
+            //   Target     :        |===|
+            targetMax -= deleted.length
+        } else if (deleted.contains(targetMin)) {
+            // Input:
+            //   Buffer     : ABCDEFGHIJKLMNOPQRSTUVWXYZ
+            //   Deleted    :      |---------|
+            //   Target     :            |========|
+            //
+            // Result:
+            //   Buffer     : ABCDEFPQRSTUVWXYZ
+            //   Target     :       |=====|
+            targetMin = deleted.min
+            targetMax -= deleted.length
+        } else { // deleteRange contains myMax
+            // Input:
+            //   Buffer     : ABCDEFGHIJKLMNOPQRSTUVWXYZ
+            //   Deleted    :         |---------|
+            //   Target     :    |=======|
+            //
+            // Result:
+            //   Buffer     : ABCDEFGHSTUVWXYZ
+            //   Target     :    |====|
+            targetMax = deleted.min
+        }
+    } else {
+        if (targetMax <= deleted.min) {
+            // Input:
+            //   Buffer     : ABCDEFGHIJKLMNOPQRSTUVWXYZ
+            //   Deleted    :            |-------|
+            //   Target     :  |=======|
+            //
+            // Result:
+            //   Buffer     : ABCDEFGHIJKLTUVWXYZ
+            //   Target     :  |=======|
+            // do nothing
+        } else {
+            // Input:
+            //   Buffer     : ABCDEFGHIJKLMNOPQRSTUVWXYZ
+            //   Deleted    :  |-------|
+            //   Target     :            |=======|
+            //
+            // Result:
+            //   Buffer     : AJKLMNOPQRSTUVWXYZ
+            //   Target     :    |=======|
+            targetMin -= deleted.length
+            targetMax -= deleted.length
+        }
+    }
+
+    return TextRange(targetMin, targetMax)
+}
diff --git a/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/text2/input/GapBuffer.kt b/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/text2/input/GapBuffer.kt
new file mode 100644
index 0000000..c567a8b
--- /dev/null
+++ b/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/text2/input/GapBuffer.kt
@@ -0,0 +1,338 @@
+/*
+ * 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.compose.foundation.text2.input
+
+/**
+ * Like [toCharArray] but copies the entire source string.
+ * Workaround for compiler error when giving [toCharArray] above default parameters.
+ */
+private fun String.toCharArray(
+    destination: CharArray,
+    destinationOffset: Int
+) = toCharArray(destination, destinationOffset, startIndex = 0, endIndex = this.length)
+
+/**
+ * Copies characters from this [String] into [destination].
+ *
+ * Platform-specific implementations should use native functions for performing this operation if
+ * they exist, since they will likely be more efficient than copying each character individually.
+ *
+ * @param destination The [CharArray] to copy into.
+ * @param destinationOffset The index in [destination] to start copying to.
+ * @param startIndex The index in `this` of the first character to copy from (inclusive).
+ * @param endIndex The index in `this` of the last character to copy from (exclusive).
+ */
+// TODO(halilibo): Revert back to expect/actual when moving to foundation
+internal fun String.toCharArray(
+    destination: CharArray,
+    destinationOffset: Int,
+    startIndex: Int,
+    endIndex: Int
+) {
+    @Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN")
+    (this as java.lang.String).getChars(startIndex, endIndex, destination, destinationOffset)
+}
+
+/**
+ * The gap buffer implementation
+ *
+ * @param initBuffer An initial buffer. This class takes ownership of this object, so do not modify
+ *                   array after passing to this constructor
+ * @param initGapStart An initial inclusive gap start offset of the buffer
+ * @param initGapEnd An initial exclusive gap end offset of the buffer
+ */
+private class GapBuffer(initBuffer: CharArray, initGapStart: Int, initGapEnd: Int) {
+
+    /**
+     * The current capacity of the buffer
+     */
+    private var capacity = initBuffer.size
+
+    /**
+     * The buffer
+     */
+    private var buffer = initBuffer
+
+    /**
+     * The inclusive start offset of the gap
+     */
+    private var gapStart = initGapStart
+
+    /**
+     * The exclusive end offset of the gap
+     */
+    private var gapEnd = initGapEnd
+
+    /**
+     * The length of the gap.
+     */
+    private fun gapLength(): Int = gapEnd - gapStart
+
+    /**
+     * [] operator for the character at the index.
+     */
+    operator fun get(index: Int): Char {
+        if (index < gapStart) {
+            return buffer[index]
+        } else {
+            return buffer[index - gapStart + gapEnd]
+        }
+    }
+
+    /**
+     * Check if the gap has a requested size, and allocate new buffer if there is enough space.
+     */
+    private fun makeSureAvailableSpace(requestSize: Int) {
+        if (requestSize <= gapLength()) {
+            return
+        }
+
+        // Allocating necessary memory space by doubling the array size.
+        val necessarySpace = requestSize - gapLength()
+        var newCapacity = capacity * 2
+        while ((newCapacity - capacity) < necessarySpace) {
+            newCapacity *= 2
+        }
+
+        val newBuffer = CharArray(newCapacity)
+        buffer.copyInto(newBuffer, 0, 0, gapStart)
+        val tailLength = capacity - gapEnd
+        val newEnd = newCapacity - tailLength
+        buffer.copyInto(newBuffer, newEnd, gapEnd, gapEnd + tailLength)
+
+        buffer = newBuffer
+        capacity = newCapacity
+        gapEnd = newEnd
+    }
+
+    /**
+     * Delete the given range of the text.
+     */
+    private fun delete(start: Int, end: Int) {
+        if (start < gapStart && end <= gapStart) {
+            // The remove happens in the head buffer. Copy the tail part of the head buffer to the
+            // tail buffer.
+            //
+            // Example:
+            // Input:
+            //   buffer:     ABCDEFGHIJKLMNOPQ*************RSTUVWXYZ
+            //   del region:     |-----|
+            //
+            // First, move the remaining part of the head buffer to the tail buffer.
+            //   buffer:     ABCDEFGHIJKLMNOPQ*****KLKMNOPQRSTUVWXYZ
+            //   move data:            ^^^^^^^ =>  ^^^^^^^^
+            //
+            // Then, delete the given range. (just updating gap positions)
+            //   buffer:     ABCD******************KLKMNOPQRSTUVWXYZ
+            //   del region:     |-----|
+            //
+            // Output:       ABCD******************KLKMNOPQRSTUVWXYZ
+            val copyLen = gapStart - end
+            buffer.copyInto(buffer, gapEnd - copyLen, end, gapStart)
+            gapStart = start
+            gapEnd -= copyLen
+        } else if (start < gapStart && end >= gapStart) {
+            // The remove happens with accrossing the gap region. Just update the gap position
+            //
+            // Example:
+            // Input:
+            //   buffer:     ABCDEFGHIJKLMNOPQ************RSTUVWXYZ
+            //   del region:             |-------------------|
+            //
+            // Output:       ABCDEFGHIJKL********************UVWXYZ
+            gapEnd = end + gapLength()
+            gapStart = start
+        } else { // start > gapStart && end > gapStart
+            // The remove happens in the tail buffer. Copy the head part of the tail buffer to the
+            // head buffer.
+            //
+            // Example:
+            // Input:
+            //   buffer:     ABCDEFGHIJKL************MNOPQRSTUVWXYZ
+            //   del region:                            |-----|
+            //
+            // First, move the remaining part in the tail buffer to the head buffer.
+            //   buffer:     ABCDEFGHIJKLMNO*********MNOPQRSTUVWXYZ
+            //   move dat:               ^^^    <=   ^^^
+            //
+            // Then, delete the given range. (just updating gap positions)
+            //   buffer:     ABCDEFGHIJKLMNO******************VWXYZ
+            //   del region:                            |-----|
+            //
+            // Output:       ABCDEFGHIJKLMNO******************VWXYZ
+            val startInBuffer = start + gapLength()
+            val endInBuffer = end + gapLength()
+            val copyLen = startInBuffer - gapEnd
+            buffer.copyInto(buffer, gapStart, gapEnd, startInBuffer)
+            gapStart += copyLen
+            gapEnd = endInBuffer
+        }
+    }
+
+    /**
+     * Replace the certain region of text with given text
+     *
+     * @param start an inclusive start offset for replacement.
+     * @param end an exclusive end offset for replacement
+     * @param text a text to replace
+     */
+    fun replace(start: Int, end: Int, text: String) {
+        makeSureAvailableSpace(text.length - (end - start))
+
+        delete(start, end)
+
+        text.toCharArray(buffer, gapStart)
+        gapStart += text.length
+    }
+
+    /**
+     * Write the current text into outBuf.
+     * @param builder The output string builder
+     */
+    fun append(builder: StringBuilder) {
+        builder.append(buffer, 0, gapStart)
+        builder.append(buffer, gapEnd, capacity - gapEnd)
+    }
+
+    /**
+     * The lengh of this gap buffer.
+     *
+     * This doesn't include internal hidden gap length.
+     */
+    fun length() = capacity - gapLength()
+
+    override fun toString(): String = StringBuilder().apply { append(this) }.toString()
+}
+
+/**
+ * An editing buffer that uses Gap Buffer only around the cursor location.
+ *
+ * Different from the original gap buffer, this gap buffer doesn't convert all given text into
+ * mutable buffer. Instead, this gap buffer converts cursor around text into mutable gap buffer
+ * for saving construction time and memory space. If text modification outside of the gap buffer
+ * is requested, this class flush the buffer and create new String, then start new gap buffer.
+ *
+ * @param text The initial text
+ * @suppress
+ */
+internal class PartialGapBuffer(var text: String) {
+    internal companion object {
+        const val BUF_SIZE = 255
+        const val SURROUNDING_SIZE = 64
+        const val NOWHERE = -1
+    }
+
+    private var buffer: GapBuffer? = null
+    private var bufStart = NOWHERE
+    private var bufEnd = NOWHERE
+
+    /**
+     * The text length
+     */
+    val length: Int
+        get() {
+            val buffer = buffer ?: return text.length
+            return text.length - (bufEnd - bufStart) + buffer.length()
+        }
+
+    /**
+     * Replace the certain region of text with given text
+     *
+     * @param start an inclusive start offset for replacement.
+     * @param end an exclusive end offset for replacement
+     * @param text a text to replace
+     */
+    fun replace(start: Int, end: Int, text: String) {
+        require(start <= end) {
+            "start index must be less than or equal to end index: $start > $end"
+        }
+        require(start >= 0) {
+            "start must be non-negative, but was $start"
+        }
+
+        val buffer = buffer
+        if (buffer == null) { // First time to create gap buffer
+            val charArray = CharArray(maxOf(BUF_SIZE, text.length + 2 * SURROUNDING_SIZE))
+
+            // Convert surrounding text into buffer.
+            val leftCopyCount = minOf(start, SURROUNDING_SIZE)
+            val rightCopyCount = minOf(this.text.length - end, SURROUNDING_SIZE)
+
+            // Copy left surrounding
+            this.text.toCharArray(charArray, 0, start - leftCopyCount, start)
+
+            // Copy right surrounding
+            this.text.toCharArray(
+                charArray,
+                charArray.size - rightCopyCount,
+                end,
+                end + rightCopyCount
+            )
+
+            // Copy given text into buffer
+            text.toCharArray(charArray, leftCopyCount)
+
+            this.buffer = GapBuffer(
+                charArray,
+                initGapStart = leftCopyCount + text.length,
+                initGapEnd = charArray.size - rightCopyCount
+            )
+            bufStart = start - leftCopyCount
+            bufEnd = end + rightCopyCount
+            return
+        }
+
+        // Convert user space offset into buffer space offset
+        val bufferStart = start - bufStart
+        val bufferEnd = end - bufStart
+        if (bufferStart < 0 || bufferEnd > buffer.length()) {
+            // Text modification outside of gap buffer has requested. Flush the buffer and try it
+            // again.
+            this.text = toString()
+            this.buffer = null
+            bufStart = NOWHERE
+            bufEnd = NOWHERE
+            return replace(start, end, text)
+        }
+
+        buffer.replace(bufferStart, bufferEnd, text)
+    }
+
+    /**
+     * [] operator for the character at the index.
+     */
+    operator fun get(index: Int): Char {
+        val buffer = buffer ?: return text[index]
+        if (index < bufStart) {
+            return text[index]
+        }
+        val gapBufLength = buffer.length()
+        if (index < gapBufLength + bufStart) {
+            return buffer[index - bufStart]
+        }
+        return text[index - (gapBufLength - bufEnd + bufStart)]
+    }
+
+    override fun toString(): String {
+        val b = buffer ?: return text
+        val sb = StringBuilder()
+        sb.append(text, 0, bufStart)
+        b.append(sb)
+        sb.append(text, bufEnd, text.length)
+        return sb.toString()
+    }
+}
diff --git a/compose/foundation/foundation/integration-tests/foundation-demos/src/test/kotlin/androidx/compose/foundation/text2/input/CommitTextCommandTest.kt b/compose/foundation/foundation/integration-tests/foundation-demos/src/test/kotlin/androidx/compose/foundation/text2/input/CommitTextCommandTest.kt
new file mode 100644
index 0000000..a049a805b
--- /dev/null
+++ b/compose/foundation/foundation/integration-tests/foundation-demos/src/test/kotlin/androidx/compose/foundation/text2/input/CommitTextCommandTest.kt
@@ -0,0 +1,185 @@
+/*
+ * 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.compose.foundation.text2.input
+
+import androidx.compose.ui.text.TextRange
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+
+@RunWith(JUnit4::class)
+class CommitTextCommandTest {
+
+    @Test
+    fun test_insert_empty() {
+        val eb = EditingBuffer("", TextRange.Zero)
+
+        eb.update(CommitTextCommand("X", 1))
+
+        assertThat(eb.toString()).isEqualTo("X")
+        assertThat(eb.cursor).isEqualTo(1)
+        assertThat(eb.hasComposition()).isFalse()
+    }
+
+    @Test
+    fun test_insert_cursor_tail() {
+        val eb = EditingBuffer("A", TextRange(1))
+
+        eb.update(CommitTextCommand("X", 1))
+
+        assertThat(eb.toString()).isEqualTo("AX")
+        assertThat(eb.cursor).isEqualTo(2)
+        assertThat(eb.hasComposition()).isFalse()
+    }
+
+    @Test
+    fun test_insert_cursor_head() {
+        val eb = EditingBuffer("A", TextRange(1))
+
+        eb.update(CommitTextCommand("X", 0))
+
+        assertThat(eb.toString()).isEqualTo("AX")
+        assertThat(eb.cursor).isEqualTo(1)
+        assertThat(eb.hasComposition()).isFalse()
+    }
+
+    @Test
+    fun test_insert_cursor_far_tail() {
+        val eb = EditingBuffer("ABCDE", TextRange(1))
+
+        eb.update(CommitTextCommand("X", 2))
+
+        assertThat(eb.toString()).isEqualTo("AXBCDE")
+        assertThat(eb.cursor).isEqualTo(3)
+        assertThat(eb.hasComposition()).isFalse()
+    }
+
+    @Test
+    fun test_insert_cursor_far_head() {
+        val eb = EditingBuffer("ABCDE", TextRange(4))
+
+        eb.update(CommitTextCommand("X", -2))
+
+        assertThat(eb.toString()).isEqualTo("ABCDXE")
+        assertThat(eb.cursor).isEqualTo(2)
+        assertThat(eb.hasComposition()).isFalse()
+    }
+
+    @Test
+    fun test_insert_empty_text_cursor_head() {
+        val eb = EditingBuffer("ABCDE", TextRange(1))
+
+        eb.update(CommitTextCommand("", 0))
+
+        assertThat(eb.toString()).isEqualTo("ABCDE")
+        assertThat(eb.cursor).isEqualTo(1)
+        assertThat(eb.hasComposition()).isFalse()
+    }
+
+    @Test
+    fun test_insert_empty_text_cursor_tail() {
+        val eb = EditingBuffer("ABCDE", TextRange(1))
+
+        eb.update(CommitTextCommand("", 1))
+
+        assertThat(eb.toString()).isEqualTo("ABCDE")
+        assertThat(eb.cursor).isEqualTo(1)
+        assertThat(eb.hasComposition()).isFalse()
+    }
+
+    @Test
+    fun test_insert_empty_text_cursor_far_tail() {
+        val eb = EditingBuffer("ABCDE", TextRange(1))
+
+        eb.update(CommitTextCommand("", 2))
+
+        assertThat(eb.toString()).isEqualTo("ABCDE")
+        assertThat(eb.cursor).isEqualTo(2)
+        assertThat(eb.hasComposition()).isFalse()
+    }
+
+    @Test
+    fun test_insert_empty_text_cursor_far_head() {
+        val eb = EditingBuffer("ABCDE", TextRange(4))
+
+        eb.update(CommitTextCommand("", -2))
+
+        assertThat(eb.toString()).isEqualTo("ABCDE")
+        assertThat(eb.cursor).isEqualTo(2)
+        assertThat(eb.hasComposition()).isFalse()
+    }
+
+    @Test
+    fun test_cancel_composition() {
+        val eb = EditingBuffer("ABCDE", TextRange.Zero)
+
+        eb.setComposition(1, 4) // Mark "BCD" as composition
+        eb.update(CommitTextCommand("X", 1))
+
+        assertThat(eb.toString()).isEqualTo("AXE")
+        assertThat(eb.cursor).isEqualTo(2)
+        assertThat(eb.hasComposition()).isFalse()
+    }
+
+    @Test
+    fun test_replace_selection() {
+        val eb = EditingBuffer("ABCDE", TextRange(1, 4)) // select "BCD"
+
+        eb.update(CommitTextCommand("X", 1))
+
+        assertThat(eb.toString()).isEqualTo("AXE")
+        assertThat(eb.cursor).isEqualTo(2)
+        assertThat(eb.hasComposition()).isFalse()
+    }
+
+    @Test
+    fun test_composition_and_selection() {
+        val eb = EditingBuffer("ABCDE", TextRange(1, 3)) // select "BC"
+
+        eb.setComposition(2, 4) // Mark "CD" as composition
+        eb.update(CommitTextCommand("X", 1))
+
+        // If composition and selection exists at the same time, replace composition and cancel
+        // selection and place cursor.
+        assertThat(eb.toString()).isEqualTo("ABXE")
+        assertThat(eb.cursor).isEqualTo(3)
+        assertThat(eb.hasComposition()).isFalse()
+    }
+
+    @Test
+    fun test_cursor_position_too_small() {
+        val eb = EditingBuffer("ABCDE", TextRange(5))
+
+        eb.update(CommitTextCommand("X", -1000))
+
+        assertThat(eb.toString()).isEqualTo("ABCDEX")
+        assertThat(eb.cursor).isEqualTo(0)
+        assertThat(eb.hasComposition()).isFalse()
+    }
+
+    @Test
+    fun test_cursor_position_too_large() {
+        val eb = EditingBuffer("ABCDE", TextRange(5))
+
+        eb.update(CommitTextCommand("X", 1000))
+
+        assertThat(eb.toString()).isEqualTo("ABCDEX")
+        assertThat(eb.cursor).isEqualTo(6)
+        assertThat(eb.hasComposition()).isFalse()
+    }
+}
\ No newline at end of file
diff --git a/compose/foundation/foundation/integration-tests/foundation-demos/src/test/kotlin/androidx/compose/foundation/text2/input/DeleteSurroundingTextCommandTest.kt b/compose/foundation/foundation/integration-tests/foundation-demos/src/test/kotlin/androidx/compose/foundation/text2/input/DeleteSurroundingTextCommandTest.kt
new file mode 100644
index 0000000..31e3b39
--- /dev/null
+++ b/compose/foundation/foundation/integration-tests/foundation-demos/src/test/kotlin/androidx/compose/foundation/text2/input/DeleteSurroundingTextCommandTest.kt
@@ -0,0 +1,238 @@
+/*
+ * 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.compose.foundation.text2.input
+
+import androidx.compose.ui.text.TextRange
+import com.google.common.truth.Truth.assertThat
+import kotlin.test.assertFailsWith
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+
+@RunWith(JUnit4::class)
+class DeleteSurroundingTextCommandTest {
+
+    @Test
+    fun test_delete_after() {
+        val eb = EditingBuffer("ABCDE", TextRange(1))
+
+        eb.update(DeleteSurroundingTextCommand(0, 1))
+
+        assertThat(eb.toString()).isEqualTo("ACDE")
+        assertThat(eb.cursor).isEqualTo(1)
+        assertThat(eb.hasComposition()).isFalse()
+    }
+
+    @Test
+    fun test_delete_before() {
+        val eb = EditingBuffer("ABCDE", TextRange(1))
+
+        eb.update(DeleteSurroundingTextCommand(1, 0))
+
+        assertThat(eb.toString()).isEqualTo("BCDE")
+        assertThat(eb.cursor).isEqualTo(0)
+        assertThat(eb.hasComposition()).isFalse()
+    }
+
+    @Test
+    fun test_delete_both() {
+        val eb = EditingBuffer("ABCDE", TextRange(3))
+
+        eb.update(DeleteSurroundingTextCommand(1, 1))
+
+        assertThat(eb.toString()).isEqualTo("ABE")
+        assertThat(eb.cursor).isEqualTo(2)
+        assertThat(eb.hasComposition()).isFalse()
+    }
+
+    @Test
+    fun test_delete_after_multiple() {
+        val eb = EditingBuffer("ABCDE", TextRange(2))
+
+        eb.update(DeleteSurroundingTextCommand(0, 2))
+
+        assertThat(eb.toString()).isEqualTo("ABE")
+        assertThat(eb.cursor).isEqualTo(2)
+        assertThat(eb.hasComposition()).isFalse()
+    }
+
+    @Test
+    fun test_delete_before_multiple() {
+        val eb = EditingBuffer("ABCDE", TextRange(3))
+
+        eb.update(DeleteSurroundingTextCommand(2, 0))
+
+        assertThat(eb.toString()).isEqualTo("ADE")
+        assertThat(eb.cursor).isEqualTo(1)
+        assertThat(eb.hasComposition()).isFalse()
+    }
+
+    @Test
+    fun test_delete_both_multiple() {
+        val eb = EditingBuffer("ABCDE", TextRange(3))
+
+        eb.update(DeleteSurroundingTextCommand(2, 2))
+
+        assertThat(eb.toString()).isEqualTo("A")
+        assertThat(eb.cursor).isEqualTo(1)
+        assertThat(eb.hasComposition()).isFalse()
+    }
+
+    @Test
+    fun test_delete_selection_preserve() {
+        val eb = EditingBuffer("ABCDE", TextRange(2, 4))
+
+        eb.update(DeleteSurroundingTextCommand(1, 1))
+
+        assertThat(eb.toString()).isEqualTo("ACD")
+        assertThat(eb.selectionStart).isEqualTo(1)
+        assertThat(eb.selectionEnd).isEqualTo(3)
+        assertThat(eb.hasComposition()).isFalse()
+    }
+
+    @Test
+    fun test_delete_before_too_many() {
+        val eb = EditingBuffer("ABCDE", TextRange(3))
+
+        eb.update(DeleteSurroundingTextCommand(1000, 0))
+
+        assertThat(eb.toString()).isEqualTo("DE")
+        assertThat(eb.cursor).isEqualTo(0)
+        assertThat(eb.hasComposition()).isFalse()
+    }
+
+    @Test
+    fun test_delete_after_too_many() {
+        val eb = EditingBuffer("ABCDE", TextRange(3))
+
+        eb.update(DeleteSurroundingTextCommand(0, 1000))
+
+        assertThat(eb.toString()).isEqualTo("ABC")
+        assertThat(eb.cursor).isEqualTo(3)
+        assertThat(eb.hasComposition()).isFalse()
+    }
+
+    @Test
+    fun test_delete_both_too_many() {
+        val eb = EditingBuffer("ABCDE", TextRange(3))
+
+        eb.update(DeleteSurroundingTextCommand(1000, 1000))
+
+        assertThat(eb.toString()).isEqualTo("")
+        assertThat(eb.cursor).isEqualTo(0)
+        assertThat(eb.hasComposition()).isFalse()
+    }
+
+    @Test
+    fun test_delete_composition_no_intersection_preceding_composition() {
+        val eb = EditingBuffer("ABCDE", TextRange(3))
+
+        eb.setComposition(0, 1)
+
+        eb.update(DeleteSurroundingTextCommand(1, 1))
+
+        assertThat(eb.toString()).isEqualTo("ABE")
+        assertThat(eb.cursor).isEqualTo(2)
+        assertThat(eb.compositionStart).isEqualTo(0)
+        assertThat(eb.compositionEnd).isEqualTo(1)
+    }
+
+    @Test
+    fun test_delete_composition_no_intersection_trailing_composition() {
+        val eb = EditingBuffer("ABCDE", TextRange(3))
+
+        eb.setComposition(4, 5)
+
+        eb.update(DeleteSurroundingTextCommand(1, 1))
+
+        assertThat(eb.toString()).isEqualTo("ABE")
+        assertThat(eb.cursor).isEqualTo(2)
+        assertThat(eb.compositionStart).isEqualTo(2)
+        assertThat(eb.compositionEnd).isEqualTo(3)
+    }
+
+    @Test
+    fun test_delete_composition_intersection_preceding_composition() {
+        val eb = EditingBuffer("ABCDE", TextRange(3))
+
+        eb.setComposition(0, 3)
+
+        eb.update(DeleteSurroundingTextCommand(1, 1))
+
+        assertThat(eb.toString()).isEqualTo("ABE")
+        assertThat(eb.cursor).isEqualTo(2)
+        assertThat(eb.compositionStart).isEqualTo(0)
+        assertThat(eb.compositionEnd).isEqualTo(2)
+    }
+
+    @Test
+    fun test_delete_composition_intersection_trailing_composition() {
+        val eb = EditingBuffer("ABCDE", TextRange(3))
+
+        eb.setComposition(3, 5)
+
+        eb.update(DeleteSurroundingTextCommand(1, 1))
+
+        assertThat(eb.toString()).isEqualTo("ABE")
+        assertThat(eb.cursor).isEqualTo(2)
+        assertThat(eb.compositionStart).isEqualTo(2)
+        assertThat(eb.compositionEnd).isEqualTo(3)
+    }
+
+    @Test
+    fun test_delete_covered_composition() {
+        val eb = EditingBuffer("ABCDE", TextRange(3))
+
+        eb.setComposition(2, 3)
+
+        eb.update(DeleteSurroundingTextCommand(1, 1))
+
+        assertThat(eb.toString()).isEqualTo("ABE")
+        assertThat(eb.cursor).isEqualTo(2)
+        assertThat(eb.hasComposition()).isFalse()
+    }
+
+    @Test
+    fun test_delete_composition_covered() {
+        val eb = EditingBuffer("ABCDE", TextRange(3))
+
+        eb.setComposition(0, 5)
+
+        eb.update(DeleteSurroundingTextCommand(1, 1))
+
+        assertThat(eb.toString()).isEqualTo("ABE")
+        assertThat(eb.cursor).isEqualTo(2)
+        assertThat(eb.compositionStart).isEqualTo(0)
+        assertThat(eb.compositionEnd).isEqualTo(3)
+    }
+
+    @Test
+    fun throws_whenLengthBeforeInvalid() {
+        val error = assertFailsWith<IllegalArgumentException> {
+            DeleteSurroundingTextCommand(lengthBeforeCursor = -42, lengthAfterCursor = 0)
+        }
+        assertThat(error).hasMessageThat().contains("-42")
+    }
+
+    @Test
+    fun throws_whenLengthAfterInvalid() {
+        val error = assertFailsWith<IllegalArgumentException> {
+            DeleteSurroundingTextCommand(lengthBeforeCursor = 0, lengthAfterCursor = -42)
+        }
+        assertThat(error).hasMessageThat().contains("-42")
+    }
+}
\ No newline at end of file
diff --git a/compose/foundation/foundation/integration-tests/foundation-demos/src/test/kotlin/androidx/compose/foundation/text2/input/DeleteSurroundingTextInCodePointsCommandTest.kt b/compose/foundation/foundation/integration-tests/foundation-demos/src/test/kotlin/androidx/compose/foundation/text2/input/DeleteSurroundingTextInCodePointsCommandTest.kt
new file mode 100644
index 0000000..f8eca54
--- /dev/null
+++ b/compose/foundation/foundation/integration-tests/foundation-demos/src/test/kotlin/androidx/compose/foundation/text2/input/DeleteSurroundingTextInCodePointsCommandTest.kt
@@ -0,0 +1,249 @@
+/*
+ * 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.compose.foundation.text2.input
+
+import androidx.compose.ui.text.TextRange
+import com.google.common.truth.Truth.assertThat
+import kotlin.test.assertFailsWith
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+
+@RunWith(JUnit4::class)
+class DeleteSurroundingTextInCodePointsCommandTest {
+    val CH1 = "\uD83D\uDE00" // U+1F600
+    val CH2 = "\uD83D\uDE01" // U+1F601
+    val CH3 = "\uD83D\uDE02" // U+1F602
+    val CH4 = "\uD83D\uDE03" // U+1F603
+    val CH5 = "\uD83D\uDE04" // U+1F604
+
+    @Test
+    fun test_delete_after() {
+        val eb = EditingBuffer("$CH1$CH2$CH3$CH4$CH5", TextRange(2))
+
+        eb.update(DeleteSurroundingTextInCodePointsCommand(0, 1))
+
+        assertThat(eb.toString()).isEqualTo("$CH1$CH3$CH4$CH5")
+        assertThat(eb.cursor).isEqualTo(2)
+        assertThat(eb.hasComposition()).isFalse()
+    }
+
+    @Test
+    fun test_delete_before() {
+        val eb = EditingBuffer("$CH1$CH2$CH3$CH4$CH5", TextRange(2))
+
+        eb.update(DeleteSurroundingTextInCodePointsCommand(1, 0))
+
+        assertThat(eb.toString()).isEqualTo("$CH2$CH3$CH4$CH5")
+        assertThat(eb.cursor).isEqualTo(0)
+        assertThat(eb.hasComposition()).isFalse()
+    }
+
+    @Test
+    fun test_delete_both() {
+        val eb = EditingBuffer("$CH1$CH2$CH3$CH4$CH5", TextRange(6))
+
+        eb.update(DeleteSurroundingTextInCodePointsCommand(1, 1))
+
+        assertThat(eb.toString()).isEqualTo("$CH1$CH2$CH5")
+        assertThat(eb.cursor).isEqualTo(4)
+        assertThat(eb.hasComposition()).isFalse()
+    }
+
+    @Test
+    fun test_delete_after_multiple() {
+        val eb = EditingBuffer("$CH1$CH2$CH3$CH4$CH5", TextRange(4))
+
+        eb.update(DeleteSurroundingTextInCodePointsCommand(0, 2))
+
+        assertThat(eb.toString()).isEqualTo("$CH1$CH2$CH5")
+        assertThat(eb.cursor).isEqualTo(4)
+        assertThat(eb.hasComposition()).isFalse()
+    }
+
+    @Test
+    fun test_delete_before_multiple() {
+        val eb = EditingBuffer("$CH1$CH2$CH3$CH4$CH5", TextRange(6))
+
+        eb.update(DeleteSurroundingTextInCodePointsCommand(2, 0))
+
+        assertThat(eb.toString()).isEqualTo("$CH1$CH4$CH5")
+        assertThat(eb.cursor).isEqualTo(2)
+        assertThat(eb.hasComposition()).isFalse()
+    }
+
+    @Test
+    fun test_delete_both_multiple() {
+        val eb = EditingBuffer("$CH1$CH2$CH3$CH4$CH5", TextRange(6))
+
+        eb.update(DeleteSurroundingTextInCodePointsCommand(2, 2))
+
+        assertThat(eb.toString()).isEqualTo(CH1)
+        assertThat(eb.cursor).isEqualTo(2)
+        assertThat(eb.hasComposition()).isFalse()
+    }
+
+    @Test
+    fun test_delete_selection_preserve() {
+        val eb = EditingBuffer("$CH1$CH2$CH3$CH4$CH5", TextRange(4, 8))
+
+        eb.update(DeleteSurroundingTextInCodePointsCommand(1, 1))
+
+        assertThat(eb.toString()).isEqualTo("$CH1$CH3$CH4")
+        assertThat(eb.selectionStart).isEqualTo(2)
+        assertThat(eb.selectionEnd).isEqualTo(6)
+        assertThat(eb.hasComposition()).isFalse()
+    }
+
+    @Test
+    fun test_delete_before_too_many() {
+        val eb = EditingBuffer("$CH1$CH2$CH3$CH4$CH5", TextRange(6))
+
+        eb.update(DeleteSurroundingTextInCodePointsCommand(1000, 0))
+
+        assertThat(eb.toString()).isEqualTo("$CH4$CH5")
+        assertThat(eb.cursor).isEqualTo(0)
+        assertThat(eb.hasComposition()).isFalse()
+    }
+
+    @Test
+    fun test_delete_after_too_many() {
+        val eb = EditingBuffer("$CH1$CH2$CH3$CH4$CH5", TextRange(6))
+
+        eb.update(DeleteSurroundingTextInCodePointsCommand(0, 1000))
+
+        assertThat(eb.toString()).isEqualTo("$CH1$CH2$CH3")
+        assertThat(eb.cursor).isEqualTo(6)
+        assertThat(eb.hasComposition()).isFalse()
+    }
+
+    @Test
+    fun test_delete_both_too_many() {
+        val eb = EditingBuffer("$CH1$CH2$CH3$CH4$CH5", TextRange(6))
+
+        eb.update(DeleteSurroundingTextInCodePointsCommand(1000, 1000))
+
+        assertThat(eb.toString()).isEqualTo("")
+        assertThat(eb.cursor).isEqualTo(0)
+        assertThat(eb.hasComposition()).isFalse()
+    }
+
+    @Test
+    fun test_delete_composition_no_intersection_preceding_composition() {
+        val eb = EditingBuffer("$CH1$CH2$CH3$CH4$CH5", TextRange(6))
+
+        eb.setComposition(0, 2)
+
+        eb.update(DeleteSurroundingTextInCodePointsCommand(1, 1))
+
+        assertThat(eb.toString()).isEqualTo("$CH1$CH2$CH5")
+        assertThat(eb.cursor).isEqualTo(4)
+        assertThat(eb.compositionStart).isEqualTo(0)
+        assertThat(eb.compositionEnd).isEqualTo(2)
+    }
+
+    @Test
+    fun test_delete_composition_no_intersection_trailing_composition() {
+        val eb = EditingBuffer("$CH1$CH2$CH3$CH4$CH5", TextRange(6))
+
+        eb.setComposition(8, 10)
+
+        eb.update(DeleteSurroundingTextInCodePointsCommand(1, 1))
+
+        assertThat(eb.toString()).isEqualTo("$CH1$CH2$CH5")
+        assertThat(eb.cursor).isEqualTo(4)
+        assertThat(eb.compositionStart).isEqualTo(4)
+        assertThat(eb.compositionEnd).isEqualTo(6)
+    }
+
+    @Test
+    fun test_delete_composition_intersection_preceding_composition() {
+        val eb = EditingBuffer("$CH1$CH2$CH3$CH4$CH5", TextRange(6))
+
+        eb.setComposition(0, 6)
+
+        eb.update(DeleteSurroundingTextInCodePointsCommand(1, 1))
+
+        assertThat(eb.toString()).isEqualTo("$CH1$CH2$CH5")
+        assertThat(eb.cursor).isEqualTo(4)
+        assertThat(eb.compositionStart).isEqualTo(0)
+        assertThat(eb.compositionEnd).isEqualTo(4)
+    }
+
+    @Test
+    fun test_delete_composition_intersection_trailing_composition() {
+        val eb = EditingBuffer("$CH1$CH2$CH3$CH4$CH5", TextRange(6))
+
+        eb.setComposition(6, 10)
+
+        eb.update(DeleteSurroundingTextInCodePointsCommand(1, 1))
+
+        assertThat(eb.toString()).isEqualTo("$CH1$CH2$CH5")
+        assertThat(eb.cursor).isEqualTo(4)
+        assertThat(eb.compositionStart).isEqualTo(4)
+        assertThat(eb.compositionEnd).isEqualTo(6)
+    }
+
+    @Test
+    fun test_delete_covered_composition() {
+        val eb = EditingBuffer("$CH1$CH2$CH3$CH4$CH5", TextRange(6))
+
+        eb.setComposition(4, 6)
+
+        eb.update(DeleteSurroundingTextInCodePointsCommand(1, 1))
+
+        assertThat(eb.toString()).isEqualTo("$CH1$CH2$CH5")
+        assertThat(eb.cursor).isEqualTo(4)
+        assertThat(eb.hasComposition()).isFalse()
+    }
+
+    @Test
+    fun test_delete_composition_covered() {
+        val eb = EditingBuffer("$CH1$CH2$CH3$CH4$CH5", TextRange(6))
+
+        eb.setComposition(0, 10)
+
+        eb.update(DeleteSurroundingTextInCodePointsCommand(1, 1))
+
+        assertThat(eb.toString()).isEqualTo("$CH1$CH2$CH5")
+        assertThat(eb.cursor).isEqualTo(4)
+        assertThat(eb.compositionStart).isEqualTo(0)
+        assertThat(eb.compositionEnd).isEqualTo(6)
+    }
+
+    @Test
+    fun throws_whenLengthBeforeInvalid() {
+        val error = assertFailsWith<IllegalArgumentException> {
+            DeleteSurroundingTextInCodePointsCommand(
+                lengthBeforeCursor = -42,
+                lengthAfterCursor = 0
+            )
+        }
+        assertThat(error).hasMessageThat().contains("-42")
+    }
+
+    @Test
+    fun throws_whenLengthAfterInvalid() {
+        val error = assertFailsWith<IllegalArgumentException> {
+            DeleteSurroundingTextInCodePointsCommand(
+                lengthBeforeCursor = 0,
+                lengthAfterCursor = -42
+            )
+        }
+        assertThat(error).hasMessageThat().contains("-42")
+    }
+}
\ No newline at end of file
diff --git a/compose/foundation/foundation/integration-tests/foundation-demos/src/test/kotlin/androidx/compose/foundation/text2/input/EditProcessorTest.kt b/compose/foundation/foundation/integration-tests/foundation-demos/src/test/kotlin/androidx/compose/foundation/text2/input/EditProcessorTest.kt
new file mode 100644
index 0000000..1f52bc8
--- /dev/null
+++ b/compose/foundation/foundation/integration-tests/foundation-demos/src/test/kotlin/androidx/compose/foundation/text2/input/EditProcessorTest.kt
@@ -0,0 +1,303 @@
+/*
+ * 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.compose.foundation.text2.input
+
+import androidx.compose.ui.text.TextRange
+import androidx.compose.ui.text.input.TextFieldValue
+import androidx.compose.ui.text.input.TextInputSession
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+import org.mockito.kotlin.any
+import org.mockito.kotlin.argumentCaptor
+import org.mockito.kotlin.eq
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.never
+import org.mockito.kotlin.reset
+import org.mockito.kotlin.times
+import org.mockito.kotlin.verify
+
+@RunWith(JUnit4::class)
+class EditProcessorTest {
+
+    @Test
+    fun test_new_state_and_edit_commands() {
+        val proc = EditProcessor()
+        val tis: TextInputSession = mock()
+
+        val model = TextFieldValue("ABCDE", TextRange.Zero)
+        proc.reset(model, tis)
+
+        assertThat(proc.value).isEqualTo(model)
+        val captor = argumentCaptor<TextFieldValue>()
+        verify(tis, times(1)).updateState(
+            eq(TextFieldValue("", TextRange.Zero)),
+            captor.capture()
+        )
+        assertThat(captor.allValues.size).isEqualTo(1)
+        assertThat(captor.firstValue.text).isEqualTo("ABCDE")
+        assertThat(captor.firstValue.selection.min).isEqualTo(0)
+        assertThat(captor.firstValue.selection.max).isEqualTo(0)
+
+        reset(tis)
+
+        val newState = proc.update(
+            listOf(
+                CommitTextCommand("X", 1)
+            )
+        )
+
+        assertThat(newState.text).isEqualTo("XABCDE")
+        assertThat(newState.selection.min).isEqualTo(1)
+        assertThat(newState.selection.max).isEqualTo(1)
+        // onEditCommands should not fire onStateUpdated since need to pass it to developer first.
+        verify(tis, never()).updateState(any(), any())
+    }
+
+    @Test
+    fun testNewState_bufferNotUpdated_ifSameModelStructurally() {
+        val processor = EditProcessor()
+        val textInputSession = mock<TextInputSession>()
+
+        val initialBuffer = processor.mBuffer
+        processor.reset(
+            TextFieldValue("qwerty", TextRange.Zero, TextRange.Zero),
+            textInputSession
+        )
+        assertThat(processor.mBuffer).isNotEqualTo(initialBuffer)
+
+        val updatedBuffer = processor.mBuffer
+        processor.reset(
+            TextFieldValue("qwerty", TextRange.Zero, TextRange.Zero),
+            textInputSession
+        )
+        assertThat(processor.mBuffer).isEqualTo(updatedBuffer)
+    }
+
+    @Test
+    fun testNewState_new_buffer_created_if_text_is_different() {
+        val processor = EditProcessor()
+        val textInputSession = mock<TextInputSession>()
+
+        val textFieldValue = TextFieldValue("qwerty", TextRange.Zero, TextRange.Zero)
+        processor.reset(
+            textFieldValue,
+            textInputSession
+        )
+        val initialBuffer = processor.mBuffer
+
+        val newTextFieldValue = textFieldValue.copy("abc")
+        processor.reset(
+            newTextFieldValue,
+            textInputSession
+        )
+
+        assertThat(processor.mBuffer).isNotEqualTo(initialBuffer)
+    }
+
+    @Test
+    fun testNewState_buffer_not_recreated_if_selection_is_different() {
+        val processor = EditProcessor()
+        val textInputSession = mock<TextInputSession>()
+        val textFieldValue = TextFieldValue("qwerty", TextRange.Zero, TextRange.Zero)
+        processor.reset(
+            textFieldValue,
+            textInputSession
+        )
+        val initialBuffer = processor.mBuffer
+
+        val newTextFieldValue = textFieldValue.copy(selection = TextRange(1))
+        processor.reset(
+            newTextFieldValue,
+            textInputSession
+        )
+
+        assertThat(processor.mBuffer).isEqualTo(initialBuffer)
+        assertThat(newTextFieldValue.selection.start).isEqualTo(processor.mBuffer.selectionStart)
+        assertThat(newTextFieldValue.selection.end).isEqualTo(processor.mBuffer.selectionEnd)
+    }
+
+    @Test
+    fun testNewState_buffer_not_recreated_if_composition_is_different() {
+        val processor = EditProcessor()
+        val textInputSeson = mock<TextInputSession>()
+        val textFieldValue = TextFieldValue("qwerty", TextRange.Zero, TextRange(1))
+        processor.reset(
+            textFieldValue,
+            textInputSeson
+        )
+        val initialBuffer = processor.mBuffer
+
+        // composition can not be set from app, IME owns it.
+        assertThat(EditingBuffer.NOWHERE).isEqualTo(initialBuffer.compositionStart)
+        assertThat(EditingBuffer.NOWHERE).isEqualTo(initialBuffer.compositionEnd)
+
+        val newTextFieldValue = textFieldValue.copy(composition = null)
+        processor.reset(
+            newTextFieldValue,
+            textInputSeson
+        )
+
+        assertThat(processor.mBuffer).isEqualTo(initialBuffer)
+        assertThat(EditingBuffer.NOWHERE).isEqualTo(processor.mBuffer.compositionStart)
+        assertThat(EditingBuffer.NOWHERE).isEqualTo(processor.mBuffer.compositionEnd)
+    }
+
+    @Test
+    fun testNewState_reversedSelection_setsTheSelection() {
+        val processor = EditProcessor()
+        val textInputSession = mock<TextInputSession>()
+        val initialSelection = TextRange(2, 1)
+        val textFieldValue = TextFieldValue("qwerty", initialSelection, TextRange(1))
+
+        // set the initial selection to be reversed
+        processor.reset(
+            textFieldValue,
+            textInputSession
+        )
+        val initialBuffer = processor.mBuffer
+
+        assertThat(initialSelection.min).isEqualTo(initialBuffer.selectionStart)
+        assertThat(initialSelection.max).isEqualTo(initialBuffer.selectionEnd)
+
+        val updatedSelection = TextRange(3, 0)
+        val newTextFieldValue = textFieldValue.copy(selection = updatedSelection)
+        // set the new selection
+        processor.reset(
+            newTextFieldValue,
+            textInputSession
+        )
+
+        assertThat(processor.mBuffer).isEqualTo(initialBuffer)
+        assertThat(updatedSelection.min).isEqualTo(initialBuffer.selectionStart)
+        assertThat(updatedSelection.max).isEqualTo(initialBuffer.selectionEnd)
+    }
+
+    @Test
+    fun compositionIsCleared_when_textChanged() {
+        val processor = EditProcessor()
+        val textInputSession = mock<TextInputSession>()
+
+        // set the initial value
+        processor.update(
+            listOf(
+                CommitTextCommand("ab", 0),
+                SetComposingRegionCommand(0, 2)
+            )
+        )
+
+        // change the text
+        val newValue = processor.value.copy(text = "cd")
+        processor.reset(newValue, textInputSession)
+
+        assertThat(processor.value.text).isEqualTo(newValue.text)
+        assertThat(processor.value.composition).isNull()
+    }
+
+    @Test
+    fun compositionIsNotCleared_when_textIsSame() {
+        val processor = EditProcessor()
+        val textInputSession = mock<TextInputSession>()
+        val composition = TextRange(0, 2)
+
+        // set the initial value
+        processor.update(
+            listOf(
+                CommitTextCommand("ab", 0),
+                SetComposingRegionCommand(composition.start, composition.end)
+            )
+        )
+
+        // use the same TextFieldValue
+        val newValue = processor.value.copy()
+        processor.reset(newValue, textInputSession)
+
+        assertThat(processor.value.text).isEqualTo(newValue.text)
+        assertThat(processor.value.composition).isEqualTo(composition)
+    }
+
+    @Test
+    fun compositionIsCleared_when_compositionReset() {
+        val processor = EditProcessor()
+        val textInputSession = mock<TextInputSession>()
+
+        // set the initial value
+        processor.update(
+            listOf(
+                CommitTextCommand("ab", 0),
+                SetComposingRegionCommand(-1, -1)
+            )
+        )
+
+        // change the composition
+        val newValue = processor.value.copy(composition = TextRange(0, 2))
+        processor.reset(newValue, textInputSession)
+
+        assertThat(processor.value.text).isEqualTo(newValue.text)
+        assertThat(processor.value.composition).isNull()
+    }
+
+    @Test
+    fun compositionIsCleared_when_compositionChanged() {
+        val processor = EditProcessor()
+        val textInputSession = mock<TextInputSession>()
+
+        // set the initial value
+        processor.update(
+            listOf(
+                CommitTextCommand("ab", 0),
+                SetComposingRegionCommand(0, 2)
+            )
+        )
+
+        // change the composition
+        val newValue = processor.value.copy(composition = TextRange(0, 1))
+        processor.reset(newValue, textInputSession)
+
+        assertThat(processor.value.text).isEqualTo(newValue.text)
+        assertThat(processor.value.composition).isNull()
+    }
+
+    @Test
+    fun compositionIsNotCleared_when_onlySelectionChanged() {
+        val processor = EditProcessor()
+        val textInputSession = mock<TextInputSession>()
+        val composition = TextRange(0, 2)
+        val selection = TextRange(0, 2)
+
+        // set the initial value
+        processor.update(
+            listOf(
+                CommitTextCommand("ab", 0),
+                SetComposingRegionCommand(composition.start, composition.end),
+                SetSelectionCommand(selection.start, selection.end)
+            )
+        )
+
+        // change selection
+        val newSelection = TextRange(1)
+        val newValue = processor.value.copy(selection = newSelection)
+        processor.reset(newValue, textInputSession)
+
+        assertThat(processor.value.text).isEqualTo(newValue.text)
+        assertThat(processor.value.composition).isEqualTo(composition)
+        assertThat(processor.value.selection).isEqualTo(newSelection)
+    }
+
+    // removed descriptive message test because EditCommand a sealed interface now.
+}
\ No newline at end of file
diff --git a/compose/foundation/foundation/integration-tests/foundation-demos/src/test/kotlin/androidx/compose/foundation/text2/input/EditingBufferDeleteRangeTest.kt b/compose/foundation/foundation/integration-tests/foundation-demos/src/test/kotlin/androidx/compose/foundation/text2/input/EditingBufferDeleteRangeTest.kt
new file mode 100644
index 0000000..e32c256
--- /dev/null
+++ b/compose/foundation/foundation/integration-tests/foundation-demos/src/test/kotlin/androidx/compose/foundation/text2/input/EditingBufferDeleteRangeTest.kt
@@ -0,0 +1,84 @@
+/*
+ * 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.compose.foundation.text2.input
+
+import androidx.compose.ui.text.TextRange
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+
+@RunWith(JUnit4::class)
+class EditingBufferDeleteRangeTest {
+
+    @Test
+    fun test_does_not_intersect_deleted_is_after_the_target() {
+        val target = TextRange(0, 1)
+        val deleted = TextRange(2, 3)
+        assertThat(updateRangeAfterDelete(target, deleted))
+            .isEqualTo(TextRange(target.start, target.end))
+    }
+
+    @Test
+    fun test_does_not_intersect_deleted_is_before_the_target() {
+        val target = TextRange(4, 5)
+        val deleted = TextRange(0, 2)
+        assertThat(updateRangeAfterDelete(target, deleted)).isEqualTo(TextRange(2, 3))
+    }
+
+    @Test
+    fun test_deleted_covers_target() {
+        val target = TextRange(1, 2)
+        val deleted = TextRange(0, 3)
+        assertThat(updateRangeAfterDelete(target, deleted)).isEqualTo(TextRange(0, 0))
+    }
+
+    @Test
+    fun test_target_covers_deleted() {
+        val target = TextRange(0, 3)
+        val deleted = TextRange(1, 2)
+        assertThat(updateRangeAfterDelete(target, deleted)).isEqualTo(TextRange(0, 2))
+    }
+
+    @Test
+    fun test_deleted_same_as_target() {
+        val target = TextRange(1, 2)
+        val deleted = TextRange(1, 2)
+        assertThat(updateRangeAfterDelete(target, deleted)).isEqualTo(TextRange(1, 1))
+    }
+
+    @Test
+    fun test_deleted_covers_first_half_of_target() {
+        val target = TextRange(1, 4)
+        val deleted = TextRange(0, 2)
+        assertThat(updateRangeAfterDelete(target, deleted)).isEqualTo(TextRange(0, 2))
+    }
+
+    @Test
+    fun test_deleted_covers_second_half_of_target() {
+        val target = TextRange(1, 4)
+        val deleted = TextRange(3, 5)
+        assertThat(updateRangeAfterDelete(target, deleted)).isEqualTo(TextRange(1, 3))
+    }
+
+    @Test
+    fun test_delete_trailing_cursor() {
+        val target = TextRange(3, 3)
+        val deleted = TextRange(1, 2)
+        assertThat(updateRangeAfterDelete(target, deleted)).isEqualTo(TextRange(2, 2))
+    }
+}
diff --git a/compose/foundation/foundation/integration-tests/foundation-demos/src/test/kotlin/androidx/compose/foundation/text2/input/EditingBufferTest.kt b/compose/foundation/foundation/integration-tests/foundation-demos/src/test/kotlin/androidx/compose/foundation/text2/input/EditingBufferTest.kt
new file mode 100644
index 0000000..4a23a8e
--- /dev/null
+++ b/compose/foundation/foundation/integration-tests/foundation-demos/src/test/kotlin/androidx/compose/foundation/text2/input/EditingBufferTest.kt
@@ -0,0 +1,436 @@
+/*
+ * 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.compose.foundation.text2.input
+
+import androidx.compose.foundation.text2.input.matchers.assertThat
+import androidx.compose.ui.text.TextRange
+import com.google.common.truth.Truth.assertThat
+import kotlin.test.assertFailsWith
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+
+@RunWith(JUnit4::class)
+class EditingBufferTest {
+
+    @Test
+    fun insert() {
+        val eb = EditingBuffer("", TextRange.Zero)
+
+        eb.replace(0, 0, "A")
+
+        assertThat(eb).hasChars("A")
+        assertThat(eb.cursor).isEqualTo(1)
+        assertThat(eb.selectionStart).isEqualTo(1)
+        assertThat(eb.selectionEnd).isEqualTo(1)
+        assertThat(eb.hasComposition()).isFalse()
+        assertThat(eb.compositionStart).isEqualTo(-1)
+        assertThat(eb.compositionEnd).isEqualTo(-1)
+
+        // Keep inserting text to the end of string. Cursor should follow.
+        eb.replace(1, 1, "BC")
+        assertThat(eb).hasChars("ABC")
+        assertThat(eb.cursor).isEqualTo(3)
+        assertThat(eb.selectionStart).isEqualTo(3)
+        assertThat(eb.selectionEnd).isEqualTo(3)
+        assertThat(eb.hasComposition()).isFalse()
+        assertThat(eb.compositionStart).isEqualTo(-1)
+        assertThat(eb.compositionEnd).isEqualTo(-1)
+
+        // Insert into middle position. Cursor should be end of inserted text.
+        eb.replace(1, 1, "D")
+        assertThat(eb).hasChars("ADBC")
+        assertThat(eb.cursor).isEqualTo(2)
+        assertThat(eb.selectionStart).isEqualTo(2)
+        assertThat(eb.selectionEnd).isEqualTo(2)
+        assertThat(eb.hasComposition()).isFalse()
+        assertThat(eb.compositionStart).isEqualTo(-1)
+        assertThat(eb.compositionEnd).isEqualTo(-1)
+    }
+
+    @Test
+    fun delete() {
+        val eb = EditingBuffer("ABCDE", TextRange.Zero)
+
+        eb.replace(0, 1, "")
+
+        // Delete the left character at the cursor.
+        assertThat(eb).hasChars("BCDE")
+        assertThat(eb.cursor).isEqualTo(0)
+        assertThat(eb.selectionStart).isEqualTo(0)
+        assertThat(eb.selectionEnd).isEqualTo(0)
+        assertThat(eb.hasComposition()).isFalse()
+        assertThat(eb.compositionStart).isEqualTo(-1)
+        assertThat(eb.compositionEnd).isEqualTo(-1)
+
+        // Delete the text before the cursor
+        eb.replace(0, 2, "")
+        assertThat(eb).hasChars("DE")
+        assertThat(eb.cursor).isEqualTo(0)
+        assertThat(eb.selectionStart).isEqualTo(0)
+        assertThat(eb.selectionEnd).isEqualTo(0)
+        assertThat(eb.hasComposition()).isFalse()
+        assertThat(eb.compositionStart).isEqualTo(-1)
+        assertThat(eb.compositionEnd).isEqualTo(-1)
+
+        // Delete end of the text.
+        eb.replace(1, 2, "")
+        assertThat(eb).hasChars("D")
+        assertThat(eb.cursor).isEqualTo(1)
+        assertThat(eb.selectionStart).isEqualTo(1)
+        assertThat(eb.selectionEnd).isEqualTo(1)
+        assertThat(eb.hasComposition()).isFalse()
+        assertThat(eb.compositionStart).isEqualTo(-1)
+        assertThat(eb.compositionEnd).isEqualTo(-1)
+    }
+
+    @Test
+    fun setSelection() {
+        val eb = EditingBuffer("ABCDE", TextRange(0, 3))
+        assertThat(eb).hasChars("ABCDE")
+        assertThat(eb.cursor).isEqualTo(-1)
+        assertThat(eb.selectionStart).isEqualTo(0)
+        assertThat(eb.selectionEnd).isEqualTo(3)
+        assertThat(eb.hasComposition()).isFalse()
+        assertThat(eb.compositionStart).isEqualTo(-1)
+        assertThat(eb.compositionEnd).isEqualTo(-1)
+
+        eb.setSelection(0, 5) // Change the selection
+        assertThat(eb).hasChars("ABCDE")
+        assertThat(eb.cursor).isEqualTo(-1)
+        assertThat(eb.selectionStart).isEqualTo(0)
+        assertThat(eb.selectionEnd).isEqualTo(5)
+        assertThat(eb.hasComposition()).isFalse()
+        assertThat(eb.compositionStart).isEqualTo(-1)
+        assertThat(eb.compositionEnd).isEqualTo(-1)
+
+        eb.replace(0, 3, "X") // replace function cancel the selection and place cursor.
+        assertThat(eb).hasChars("XDE")
+        assertThat(eb.cursor).isEqualTo(1)
+        assertThat(eb.selectionStart).isEqualTo(1)
+        assertThat(eb.selectionEnd).isEqualTo(1)
+        assertThat(eb.hasComposition()).isFalse()
+        assertThat(eb.compositionStart).isEqualTo(-1)
+        assertThat(eb.compositionEnd).isEqualTo(-1)
+
+        eb.setSelection(0, 2) // Set the selection again
+        assertThat(eb).hasChars("XDE")
+        assertThat(eb.cursor).isEqualTo(-1)
+        assertThat(eb.selectionStart).isEqualTo(0)
+        assertThat(eb.selectionEnd).isEqualTo(2)
+        assertThat(eb.hasComposition()).isFalse()
+        assertThat(eb.compositionStart).isEqualTo(-1)
+        assertThat(eb.compositionEnd).isEqualTo(-1)
+    }
+
+    @Test fun setSelection_throws_whenNegativeStart() {
+        val eb = EditingBuffer("ABCDE", TextRange.Zero)
+
+        assertFailsWith<IndexOutOfBoundsException> {
+            eb.setSelection(-1, 0)
+        }
+    }
+
+    @Test fun setSelection_throws_whenNegativeEnd() {
+        val eb = EditingBuffer("ABCDE", TextRange.Zero)
+
+        assertFailsWith<IndexOutOfBoundsException> {
+            eb.setSelection(0, -1)
+        }
+    }
+
+    @Test
+    fun setCompostion_and_cancelComposition() {
+        val eb = EditingBuffer("ABCDE", TextRange.Zero)
+
+        eb.setComposition(0, 5) // Make all text as composition
+        assertThat(eb).hasChars("ABCDE")
+        assertThat(eb.cursor).isEqualTo(0)
+        assertThat(eb.selectionStart).isEqualTo(0)
+        assertThat(eb.selectionEnd).isEqualTo(0)
+        assertThat(eb.hasComposition()).isTrue()
+        assertThat(eb.compositionStart).isEqualTo(0)
+        assertThat(eb.compositionEnd).isEqualTo(5)
+
+        eb.replace(2, 3, "X") // replace function cancel the composition text.
+        assertThat(eb).hasChars("ABXDE")
+        assertThat(eb.cursor).isEqualTo(3)
+        assertThat(eb.selectionStart).isEqualTo(3)
+        assertThat(eb.selectionEnd).isEqualTo(3)
+        assertThat(eb.hasComposition()).isFalse()
+        assertThat(eb.compositionStart).isEqualTo(-1)
+        assertThat(eb.compositionEnd).isEqualTo(-1)
+
+        eb.setComposition(2, 4) // set composition again
+        assertThat(eb).hasChars("ABXDE")
+        assertThat(eb.cursor).isEqualTo(3)
+        assertThat(eb.selectionStart).isEqualTo(3)
+        assertThat(eb.selectionEnd).isEqualTo(3)
+        assertThat(eb.hasComposition()).isTrue()
+        assertThat(eb.compositionStart).isEqualTo(2)
+        assertThat(eb.compositionEnd).isEqualTo(4)
+
+        eb.cancelComposition() // cancel the composition
+        assertThat(eb).hasChars("ABE")
+        assertThat(eb.cursor).isEqualTo(2)
+        assertThat(eb.selectionStart).isEqualTo(2)
+        assertThat(eb.selectionEnd).isEqualTo(2)
+        assertThat(eb.hasComposition()).isFalse()
+        assertThat(eb.compositionStart).isEqualTo(-1)
+        assertThat(eb.compositionEnd).isEqualTo(-1)
+    }
+
+    @Test
+    fun setCompostion_and_commitComposition() {
+        val eb = EditingBuffer("ABCDE", TextRange.Zero)
+
+        eb.setComposition(0, 5) // Make all text as composition
+        assertThat(eb).hasChars("ABCDE")
+        assertThat(eb.cursor).isEqualTo(0)
+        assertThat(eb.selectionStart).isEqualTo(0)
+        assertThat(eb.selectionEnd).isEqualTo(0)
+        assertThat(eb.hasComposition()).isTrue()
+        assertThat(eb.compositionStart).isEqualTo(0)
+        assertThat(eb.compositionEnd).isEqualTo(5)
+
+        eb.replace(2, 3, "X") // replace function cancel the composition text.
+        assertThat(eb).hasChars("ABXDE")
+        assertThat(eb.cursor).isEqualTo(3)
+        assertThat(eb.selectionStart).isEqualTo(3)
+        assertThat(eb.selectionEnd).isEqualTo(3)
+        assertThat(eb.hasComposition()).isFalse()
+        assertThat(eb.compositionStart).isEqualTo(-1)
+        assertThat(eb.compositionEnd).isEqualTo(-1)
+
+        eb.setComposition(2, 4) // set composition again
+        assertThat(eb).hasChars("ABXDE")
+        assertThat(eb.cursor).isEqualTo(3)
+        assertThat(eb.selectionStart).isEqualTo(3)
+        assertThat(eb.selectionEnd).isEqualTo(3)
+        assertThat(eb.hasComposition()).isTrue()
+        assertThat(eb.compositionStart).isEqualTo(2)
+        assertThat(eb.compositionEnd).isEqualTo(4)
+
+        eb.commitComposition() // commit the composition
+        assertThat(eb).hasChars("ABXDE")
+        assertThat(eb.cursor).isEqualTo(3)
+        assertThat(eb.selectionStart).isEqualTo(3)
+        assertThat(eb.selectionEnd).isEqualTo(3)
+        assertThat(eb.hasComposition()).isFalse()
+        assertThat(eb.compositionStart).isEqualTo(-1)
+        assertThat(eb.compositionEnd).isEqualTo(-1)
+    }
+
+    @Test
+    fun setCursor_and_get_cursor() {
+        val eb = EditingBuffer("ABCDE", TextRange.Zero)
+
+        eb.cursor = 1
+        assertThat(eb).hasChars("ABCDE")
+        assertThat(eb.cursor).isEqualTo(1)
+        assertThat(eb.selectionStart).isEqualTo(1)
+        assertThat(eb.selectionEnd).isEqualTo(1)
+        assertThat(eb.hasComposition()).isFalse()
+        assertThat(eb.compositionStart).isEqualTo(-1)
+        assertThat(eb.compositionEnd).isEqualTo(-1)
+
+        eb.cursor = 2
+        assertThat(eb).hasChars("ABCDE")
+        assertThat(eb.cursor).isEqualTo(2)
+        assertThat(eb.selectionStart).isEqualTo(2)
+        assertThat(eb.selectionEnd).isEqualTo(2)
+        assertThat(eb.hasComposition()).isFalse()
+        assertThat(eb.compositionStart).isEqualTo(-1)
+        assertThat(eb.compositionEnd).isEqualTo(-1)
+
+        eb.cursor = 5
+        assertThat(eb).hasChars("ABCDE")
+        assertThat(eb.cursor).isEqualTo(5)
+        assertThat(eb.selectionStart).isEqualTo(5)
+        assertThat(eb.selectionEnd).isEqualTo(5)
+        assertThat(eb.hasComposition()).isFalse()
+        assertThat(eb.compositionStart).isEqualTo(-1)
+        assertThat(eb.compositionEnd).isEqualTo(-1)
+    }
+
+    @Test
+    fun delete_preceding_cursor_no_composition() {
+        val eb = EditingBuffer("ABCDE", TextRange.Zero)
+
+        eb.delete(1, 2)
+        assertThat(eb).hasChars("ACDE")
+        assertThat(eb.cursor).isEqualTo(0)
+        assertThat(eb.hasComposition()).isFalse()
+    }
+
+    @Test
+    fun delete_trailing_cursor_no_composition() {
+        val eb = EditingBuffer("ABCDE", TextRange(3))
+
+        eb.delete(1, 2)
+        assertThat(eb).hasChars("ACDE")
+        assertThat(eb.cursor).isEqualTo(2)
+        assertThat(eb.hasComposition()).isFalse()
+    }
+
+    @Test
+    fun delete_preceding_selection_no_composition() {
+        val eb = EditingBuffer("ABCDE", TextRange(0, 1))
+
+        eb.delete(1, 2)
+        assertThat(eb).hasChars("ACDE")
+        assertThat(eb.selectionStart).isEqualTo(0)
+        assertThat(eb.selectionEnd).isEqualTo(1)
+        assertThat(eb.hasComposition()).isFalse()
+    }
+
+    @Test
+    fun delete_trailing_selection_no_composition() {
+        val eb = EditingBuffer("ABCDE", TextRange(4, 5))
+
+        eb.delete(1, 2)
+        assertThat(eb).hasChars("ACDE")
+        assertThat(eb.selectionStart).isEqualTo(3)
+        assertThat(eb.selectionEnd).isEqualTo(4)
+        assertThat(eb.hasComposition()).isFalse()
+    }
+
+    @Test
+    fun delete_covered_cursor() {
+        // AB[]CDE
+        val eb = EditingBuffer("ABCDE", TextRange(2, 2))
+
+        eb.delete(1, 3)
+        // A[]DE
+        assertThat(eb).hasChars("ADE")
+        assertThat(eb.selectionStart).isEqualTo(1)
+        assertThat(eb.selectionEnd).isEqualTo(1)
+    }
+
+    @Test
+    fun delete_covered_selection() {
+        // A[BC]DE
+        val eb = EditingBuffer("ABCDE", TextRange(1, 3))
+
+        eb.delete(0, 4)
+        // []E
+        assertThat(eb).hasChars("E")
+        assertThat(eb.selectionStart).isEqualTo(0)
+        assertThat(eb.selectionEnd).isEqualTo(0)
+    }
+
+    @Test
+    fun delete_intersects_first_half_of_selection() {
+        // AB[CD]E
+        val eb = EditingBuffer("ABCDE", TextRange(2, 4))
+
+        eb.delete(1, 3)
+        // A[D]E
+        assertThat(eb).hasChars("ADE")
+        assertThat(eb.selectionStart).isEqualTo(1)
+        assertThat(eb.selectionEnd).isEqualTo(2)
+    }
+
+    @Test
+    fun delete_intersects_second_half_of_selection() {
+        // A[BCD]EFG
+        val eb = EditingBuffer("ABCDEFG", TextRange(1, 4))
+
+        eb.delete(3, 5)
+        // A[BC]FG
+        assertThat(eb).hasChars("ABCFG")
+        assertThat(eb.selectionStart).isEqualTo(1)
+        assertThat(eb.selectionEnd).isEqualTo(3)
+    }
+
+    @Test
+    fun delete_preceding_composition_no_intersection() {
+        val eb = EditingBuffer("ABCDE", TextRange.Zero)
+
+        eb.setComposition(1, 2)
+        eb.delete(2, 3)
+
+        assertThat(eb).hasChars("ABDE")
+        assertThat(eb.cursor).isEqualTo(0)
+        assertThat(eb.compositionStart).isEqualTo(1)
+        assertThat(eb.compositionEnd).isEqualTo(2)
+    }
+
+    @Test
+    fun delete_trailing_composition_no_intersection() {
+        val eb = EditingBuffer("ABCDE", TextRange.Zero)
+
+        eb.setComposition(3, 4)
+        eb.delete(2, 3)
+
+        assertThat(eb).hasChars("ABDE")
+        assertThat(eb.cursor).isEqualTo(0)
+        assertThat(eb.compositionStart).isEqualTo(2)
+        assertThat(eb.compositionEnd).isEqualTo(3)
+    }
+
+    @Test
+    fun delete_preceding_composition_intersection() {
+        val eb = EditingBuffer("ABCDE", TextRange.Zero)
+
+        eb.setComposition(1, 3)
+        eb.delete(2, 4)
+
+        assertThat(eb).hasChars("ABE")
+        assertThat(eb.cursor).isEqualTo(0)
+        assertThat(eb.compositionStart).isEqualTo(1)
+        assertThat(eb.compositionEnd).isEqualTo(2)
+    }
+
+    @Test
+    fun delete_trailing_composition_intersection() {
+        val eb = EditingBuffer("ABCDE", TextRange.Zero)
+
+        eb.setComposition(3, 5)
+        eb.delete(2, 4)
+
+        assertThat(eb).hasChars("ABE")
+        assertThat(eb.cursor).isEqualTo(0)
+        assertThat(eb.compositionStart).isEqualTo(2)
+        assertThat(eb.compositionEnd).isEqualTo(3)
+    }
+
+    @Test
+    fun delete_composition_contains_delrange() {
+        val eb = EditingBuffer("ABCDE", TextRange.Zero)
+
+        eb.setComposition(2, 5)
+        eb.delete(3, 4)
+
+        assertThat(eb).hasChars("ABCE")
+        assertThat(eb.cursor).isEqualTo(0)
+        assertThat(eb.compositionStart).isEqualTo(2)
+        assertThat(eb.compositionEnd).isEqualTo(4)
+    }
+
+    @Test
+    fun delete_delrange_contains_composition() {
+        val eb = EditingBuffer("ABCDE", TextRange.Zero)
+
+        eb.setComposition(3, 4)
+        eb.delete(2, 5)
+
+        assertThat(eb).hasChars("AB")
+        assertThat(eb.cursor).isEqualTo(0)
+        assertThat(eb.hasComposition()).isFalse()
+    }
+}
\ No newline at end of file
diff --git a/compose/foundation/foundation/integration-tests/foundation-demos/src/test/kotlin/androidx/compose/foundation/text2/input/FinishComposingTextCommandTest.kt b/compose/foundation/foundation/integration-tests/foundation-demos/src/test/kotlin/androidx/compose/foundation/text2/input/FinishComposingTextCommandTest.kt
new file mode 100644
index 0000000..cb64f20
--- /dev/null
+++ b/compose/foundation/foundation/integration-tests/foundation-demos/src/test/kotlin/androidx/compose/foundation/text2/input/FinishComposingTextCommandTest.kt
@@ -0,0 +1,52 @@
+/*
+ * 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.compose.foundation.text2.input
+
+import androidx.compose.ui.text.TextRange
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+
+@RunWith(JUnit4::class)
+class FinishComposingTextCommandTest {
+
+    @Test
+    fun test_set() {
+        val eb = EditingBuffer("ABCDE", TextRange.Zero)
+
+        eb.setComposition(1, 4)
+        eb.update(FinishComposingTextCommand)
+
+        assertThat(eb.toString()).isEqualTo("ABCDE")
+        assertThat(eb.cursor).isEqualTo(0)
+        assertThat(eb.hasComposition()).isFalse()
+    }
+
+    @Test
+    fun test_preserve_selection() {
+        val eb = EditingBuffer("ABCDE", TextRange(1, 4))
+
+        eb.setComposition(2, 5)
+        eb.update(FinishComposingTextCommand)
+
+        assertThat(eb.toString()).isEqualTo("ABCDE")
+        assertThat(eb.selectionStart).isEqualTo(1)
+        assertThat(eb.selectionEnd).isEqualTo(4)
+        assertThat(eb.hasComposition()).isFalse()
+    }
+}
\ No newline at end of file
diff --git a/compose/foundation/foundation/integration-tests/foundation-demos/src/test/kotlin/androidx/compose/foundation/text2/input/GapBufferTest.kt b/compose/foundation/foundation/integration-tests/foundation-demos/src/test/kotlin/androidx/compose/foundation/text2/input/GapBufferTest.kt
new file mode 100644
index 0000000..1e090aa
--- /dev/null
+++ b/compose/foundation/foundation/integration-tests/foundation-demos/src/test/kotlin/androidx/compose/foundation/text2/input/GapBufferTest.kt
@@ -0,0 +1,685 @@
+/*
+ * 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.compose.foundation.text2.input
+
+import androidx.compose.foundation.text.InternalFoundationTextApi
+import androidx.compose.foundation.text2.input.matchers.assertThat
+import com.google.common.truth.Truth.assertThat
+import kotlin.random.Random
+import kotlin.test.assertFailsWith
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+
+@OptIn(InternalFoundationTextApi::class)
+@RunWith(JUnit4::class)
+class GapBufferTest {
+
+    @Test
+    fun insertTest_insert_to_empty_string() {
+        assertThat(
+            PartialGapBuffer("").apply {
+                replace(0, 0, "A")
+            }
+        ).hasChars("A")
+    }
+
+    @Test
+    fun insertTest_insert_and_append() {
+        assertThat(
+            PartialGapBuffer("").apply {
+                replace(0, 0, "A")
+                replace(0, 0, "B")
+            }
+        ).hasChars("BA")
+    }
+
+    @Test
+    fun insertTest_insert_and_prepend() {
+        assertThat(
+            PartialGapBuffer("").apply {
+                replace(0, 0, "A")
+                replace(1, 1, "B")
+            }
+        ).hasChars("AB")
+    }
+
+    @Test
+    fun insertTest_insert_and_insert_into_middle() {
+        assertThat(
+            PartialGapBuffer("").apply {
+                replace(0, 0, "AA")
+                replace(1, 1, "B")
+            }
+        ).hasChars("ABA")
+    }
+
+    @Test
+    fun insertTest_intoExistingText_prepend() {
+        assertThat(
+            PartialGapBuffer("XX").apply {
+                replace(0, 0, "A")
+            }
+        ).hasChars("AXX")
+    }
+
+    @Test
+    fun insertTest_intoExistingText_insert_into_middle() {
+        assertThat(
+            PartialGapBuffer("XX").apply {
+                replace(1, 1, "A")
+            }
+        ).hasChars("XAX")
+    }
+
+    @Test
+    fun insertTest_intoExistingText_append() {
+        assertThat(
+            PartialGapBuffer("XX").apply {
+                replace(2, 2, "A")
+            }
+        ).hasChars("XXA")
+    }
+
+    @Test
+    fun insertTest_intoExistingText_prepend_and_prepend() {
+        assertThat(
+            PartialGapBuffer("XX").apply {
+                replace(0, 0, "A")
+                replace(0, 0, "B")
+            }
+        ).hasChars("BAXX")
+    }
+
+    @Test
+    fun insertTest_intoExistingText_prepend_and_append() {
+        assertThat(
+            PartialGapBuffer("XX").apply {
+                replace(0, 0, "A")
+                replace(1, 1, "B")
+            }
+        ).hasChars("ABXX")
+    }
+
+    @Test
+    fun insertTest_intoExistingText_prepend_and_insert_middle() {
+        assertThat(
+            PartialGapBuffer("XX").apply {
+                replace(0, 0, "A")
+                replace(2, 2, "B")
+            }
+        ).hasChars("AXBX")
+    }
+
+    @Test
+    fun insertTest_intoExistingText_insert_two_chars_and_append() {
+        assertThat(
+            PartialGapBuffer("XX").apply {
+                replace(0, 0, "AA")
+                replace(1, 1, "B")
+            }
+        ).hasChars("ABAXX")
+    }
+
+    @Test
+    fun deleteTest_insert_and_delete_from_head() {
+        assertThat(
+            PartialGapBuffer("").apply {
+                replace(0, 0, "ABC")
+                replace(0, 1, "")
+            }
+        ).hasChars("BC")
+    }
+
+    @Test
+    fun deleteTest_insert_and_delete_middle() {
+        assertThat(
+            PartialGapBuffer("").apply {
+                replace(0, 0, "ABC")
+                replace(1, 2, "")
+            }
+        ).hasChars("AC")
+    }
+
+    @Test
+    fun deleteTest_insert_and_delete_tail() {
+        assertThat(
+            PartialGapBuffer("").apply {
+                replace(0, 0, "ABC")
+                replace(2, 3, "")
+            }
+        ).hasChars("AB")
+    }
+
+    @Test
+    fun deleteTest_insert_and_delete_two_head() {
+        assertThat(
+            PartialGapBuffer("").apply {
+                replace(0, 0, "ABC")
+                replace(0, 2, "")
+            }
+        ).hasChars("C")
+    }
+
+    @Test
+    fun deleteTest_insert_and_delete_two_tail() {
+        assertThat(
+            PartialGapBuffer("").apply {
+                replace(0, 0, "ABC")
+                replace(1, 3, "")
+            }
+        ).hasChars("A")
+    }
+
+    @Test
+    fun deleteTest_insert_and_delete_with_two_instruction_from_haed() {
+        assertThat(
+            PartialGapBuffer("").apply {
+                replace(0, 0, "ABC")
+                replace(0, 1, "")
+                replace(0, 1, "")
+            }
+        ).hasChars("C")
+    }
+
+    @Test
+    fun deleteTest_insert_and_delet_with_two_instruction_from_head_and_tail() {
+        assertThat(
+            PartialGapBuffer("").apply {
+                replace(0, 0, "ABC")
+                replace(0, 1, "")
+                replace(1, 2, "")
+            }
+        ).hasChars("B")
+    }
+
+    @Test
+    fun deleteTest_insert_and_delet_with_two_instruction_from_tail() {
+        assertThat(
+            PartialGapBuffer("").apply {
+                replace(0, 0, "ABC")
+                replace(1, 2, "")
+                replace(1, 2, "")
+            }
+        ).hasChars("A")
+    }
+
+    @Test
+    fun deleteTest_insert_and_delete_three_chars() {
+        assertThat(
+            PartialGapBuffer("").apply {
+                replace(0, 0, "ABC")
+                replace(0, 3, "")
+            }
+        ).hasChars("")
+    }
+
+    @Test
+    fun deleteTest_insert_and_delete_three_chars_with_three_instructions() {
+        assertThat(
+            PartialGapBuffer("").apply {
+                replace(0, 0, "ABC")
+                replace(0, 1, "")
+                replace(0, 1, "")
+                replace(0, 1, "")
+            }
+        ).hasChars("")
+    }
+
+    @Test
+    fun deleteTest_fromExistingText_from_head() {
+        assertThat(
+            PartialGapBuffer("ABC").apply {
+                replace(0, 1, "")
+            }
+        ).hasChars("BC")
+    }
+
+    @Test
+    fun deleteTest_fromExistingText_from_middle() {
+        assertThat(
+            PartialGapBuffer("ABC").apply {
+                replace(1, 2, "")
+            }
+        ).hasChars("AC")
+    }
+
+    @Test
+    fun deleteTest_fromExistingText_from_tail() {
+        assertThat(
+            PartialGapBuffer("ABC").apply {
+                replace(2, 3, "")
+            }
+        ).hasChars("AB")
+    }
+
+    @Test
+    fun deleteTest_fromExistingText_delete_two_chars_from_head() {
+        assertThat(
+            PartialGapBuffer("ABC").apply {
+                replace(0, 2, "")
+            }
+        ).hasChars("C")
+    }
+
+    @Test
+    fun deleteTest_fromExistingText_delete_two_chars_from_tail() {
+        assertThat(
+            PartialGapBuffer("ABC").apply {
+                replace(1, 3, "")
+            }
+        ).hasChars("A")
+    }
+
+    @Test
+    fun deleteTest_fromExistingText_delete_two_chars_with_two_instruction_from_head() {
+        assertThat(
+            PartialGapBuffer("ABC").apply {
+                replace(0, 1, "")
+                replace(0, 1, "")
+            }
+        ).hasChars("C")
+    }
+
+    @Test
+    fun deleteTest_fromExistingText_delete_two_chars_with_two_instruction_from_head_and_tail() {
+        assertThat(
+            PartialGapBuffer("ABC").apply {
+                replace(0, 1, "")
+                replace(1, 2, "")
+            }
+        ).hasChars("B")
+    }
+
+    @Test
+    fun deleteTest_fromExistingText_delete_two_chars_with_two_instruction_from_tail() {
+        assertThat(
+            PartialGapBuffer("ABC").apply {
+                replace(1, 2, "")
+                replace(1, 2, "")
+            }
+        ).hasChars("A")
+    }
+
+    @Test
+    fun deleteTest_fromExistingText_delete_three_chars() {
+        assertThat(
+            PartialGapBuffer("ABC").apply {
+                replace(0, 3, "")
+            }
+        ).hasChars("")
+    }
+
+    @Test
+    fun deleteTest_fromExistingText_delete_three_chars_with_three_instructions() {
+        assertThat(
+            PartialGapBuffer("ABC").apply {
+                replace(0, 1, "")
+                replace(0, 1, "")
+                replace(0, 1, "")
+            }
+        ).hasChars("")
+    }
+
+    @Test
+    fun replaceTest_head() {
+        assertThat(
+            PartialGapBuffer("").apply {
+                replace(0, 0, "ABC")
+                replace(0, 1, "X")
+            }
+        ).hasChars("XBC")
+    }
+
+    @Test
+    fun replaceTest_middle() {
+        assertThat(
+            PartialGapBuffer("").apply {
+                replace(0, 0, "ABC")
+                replace(1, 2, "X")
+            }
+        ).hasChars("AXC")
+    }
+
+    @Test
+    fun replaceTest_tail() {
+        assertThat(
+            PartialGapBuffer("").apply {
+                replace(0, 0, "ABC")
+                replace(2, 3, "X")
+            }
+        ).hasChars("ABX")
+    }
+
+    @Test
+    fun replaceTest_head_two_chars() {
+        assertThat(
+            PartialGapBuffer("").apply {
+                replace(0, 0, "ABC")
+                replace(0, 2, "X")
+            }
+        ).hasChars("XC")
+    }
+
+    @Test
+    fun replaceTest_middle_two_chars() {
+        assertThat(
+            PartialGapBuffer("").apply {
+                replace(0, 0, "ABC")
+                replace(1, 3, "X")
+            }
+        ).hasChars("AX")
+    }
+
+    @Test
+    fun replaceTest_three_chars() {
+        assertThat(
+            PartialGapBuffer("").apply {
+                replace(0, 0, "ABC")
+                replace(0, 3, "X")
+            }
+        ).hasChars("X")
+    }
+
+    @Test
+    fun replaceTest_one_char_with_two_chars_from_head() {
+        assertThat(
+            PartialGapBuffer("").apply {
+                replace(0, 0, "ABC")
+                replace(0, 1, "XY")
+            }
+        ).hasChars("XYBC")
+    }
+
+    @Test
+    fun replaceTest_one_char_with_two_chars_from_middle() {
+        assertThat(
+            PartialGapBuffer("").apply {
+                replace(0, 0, "ABC")
+                replace(1, 2, "XY")
+            }
+        ).hasChars("AXYC")
+    }
+
+    @Test
+    fun replaceTest_one_char_with_two_chars_from_tail() {
+        assertThat(
+            PartialGapBuffer("").apply {
+                replace(0, 0, "ABC")
+                replace(2, 3, "XY")
+            }
+        ).hasChars("ABXY")
+    }
+
+    @Test
+    fun replaceTest_two_chars_with_two_chars_from_head() {
+        assertThat(
+            PartialGapBuffer("").apply {
+                replace(0, 0, "ABC")
+                replace(0, 2, "XY")
+            }
+        ).hasChars("XYC")
+    }
+
+    @Test
+    fun replaceTest_two_chars_with_two_chars_from_tail() {
+        assertThat(
+            PartialGapBuffer("").apply {
+                replace(0, 0, "ABC")
+                replace(1, 3, "XY")
+            }
+        ).hasChars("AXY")
+    }
+
+    @Test
+    fun replaceTest_three_chars_with_two_char() {
+        assertThat(
+            PartialGapBuffer("").apply {
+                replace(0, 0, "ABC")
+                replace(0, 3, "XY")
+            }
+        ).hasChars("XY")
+    }
+
+    @Test
+    fun replaceTest_fromExistingText_head() {
+        assertThat(
+            PartialGapBuffer("ABC").apply {
+                replace(0, 1, "X")
+            }
+        ).hasChars("XBC")
+    }
+
+    @Test
+    fun replaceTest_fromExistingText_middle() {
+        assertThat(
+            PartialGapBuffer("ABC").apply {
+                replace(1, 2, "X")
+            }
+        ).hasChars("AXC")
+    }
+
+    @Test
+    fun replaceTest_fromExistingText_tail() {
+        assertThat(
+            PartialGapBuffer("ABC").apply {
+                replace(2, 3, "X")
+            }
+        ).hasChars("ABX")
+    }
+
+    @Test
+    fun replaceTest_fromExistingText_two_chars_with_one_char_from_head() {
+        assertThat(
+            PartialGapBuffer("ABC").apply {
+                replace(0, 2, "X")
+            }
+        ).hasChars("XC")
+    }
+
+    @Test
+    fun replaceTest_fromExistingText_two_chars_with_one_char_from_tail() {
+        assertThat(
+            PartialGapBuffer("ABC").apply {
+                replace(1, 3, "X")
+            }
+        ).hasChars("AX")
+    }
+
+    @Test
+    fun replaceTest_fromExistingText_three_chars() {
+        assertThat(
+            PartialGapBuffer("ABC").apply {
+                replace(0, 3, "X")
+            }
+        ).hasChars("X")
+    }
+
+    @Test
+    fun replaceTest_fromExistingText_one_char_with_two_chars_from_head() {
+        assertThat(
+            PartialGapBuffer("ABC").apply {
+                replace(0, 1, "XY")
+            }
+        ).hasChars("XYBC")
+    }
+
+    @Test
+    fun replaceTest_fromExistingText_one_char_with_two_chars_from_middle() {
+        assertThat(
+            PartialGapBuffer("ABC").apply {
+                replace(1, 2, "XY")
+            }
+        ).hasChars("AXYC")
+    }
+
+    @Test
+    fun replaceTest_fromExistingText_one_char_with_two_chars_from_tail() {
+        assertThat(
+            PartialGapBuffer("ABC").apply {
+                replace(2, 3, "XY")
+            }
+        ).hasChars("ABXY")
+    }
+
+    @Test
+    fun replaceTest_fromExistingText_two_chars_with_two_chars_from_head() {
+        assertThat(
+            PartialGapBuffer("ABC").apply {
+                replace(0, 2, "XY")
+            }
+        ).hasChars("XYC")
+    }
+
+    @Test
+    fun replaceTest_fromExistingText_two_chars_with_two_chars_from_tail() {
+        assertThat(
+            PartialGapBuffer("ABC").apply {
+                replace(1, 3, "XY")
+            }
+        ).hasChars("AXY")
+    }
+
+    @Test
+    fun replaceTest_fromExistingText_three_chars_with_three_chars() {
+        assertThat(
+            PartialGapBuffer("ABC").apply {
+                replace(0, 3, "XY")
+            }
+        ).hasChars("XY")
+    }
+
+    @Test
+    fun replace_throws_whenStartGreaterThanEnd() {
+        val buffer = PartialGapBuffer("ABCD")
+
+        val error = assertFailsWith<IllegalArgumentException> {
+            buffer.replace(3, 2, "")
+        }
+        assertThat(error).hasMessageThat().contains("3 > 2")
+    }
+
+    @Test
+    fun replace_throws_whenStartNegative() {
+        val buffer = PartialGapBuffer("ABCD")
+
+        val error = assertFailsWith<IllegalArgumentException> {
+            buffer.replace(-1, 2, "XY")
+        }
+        assertThat(error).hasMessageThat().contains("-1")
+    }
+
+    // Compare with the result of StringBuffer. We trust the StringBuffer works correctly
+    private fun assertReplace(
+        start: Int,
+        end: Int,
+        str: String,
+        sb: StringBuffer,
+        gb: PartialGapBuffer
+    ) {
+        sb.replace(start, end, str)
+        gb.replace(start, end, str)
+        assertThat(gb).hasChars(sb.toString())
+    }
+
+    private val LONG_INIT_TEXT = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".repeat(256)
+    private val SHORT_TEXT = "A"
+    private val MEDIUM_TEXT = "Hello, World"
+    private val LONG_TEXT = "abcdefghijklmnopqrstuvwxyz".repeat(16)
+
+    @Test
+    fun longTextTest_keep_insertion() {
+        val sb = StringBuffer(LONG_INIT_TEXT)
+        val gb = PartialGapBuffer(LONG_INIT_TEXT)
+
+        var c = 256 // cursor
+        assertReplace(c, c, SHORT_TEXT, sb, gb)
+        c += SHORT_TEXT.length
+        assertReplace(c, c, MEDIUM_TEXT, sb, gb)
+        c += MEDIUM_TEXT.length
+        assertReplace(c, c, LONG_TEXT, sb, gb)
+        c += LONG_TEXT.length
+        assertReplace(c, c, MEDIUM_TEXT, sb, gb)
+        c += MEDIUM_TEXT.length
+        assertReplace(c, c, SHORT_TEXT, sb, gb)
+    }
+
+    @Test
+    fun longTextTest_keep_deletion() {
+        val sb = StringBuffer(LONG_INIT_TEXT)
+        val gb = PartialGapBuffer(LONG_INIT_TEXT)
+
+        var c = 2048 // cursor
+        // Forward deletion
+        assertReplace(c, c + 10, "", sb, gb)
+        assertReplace(c, c + 100, "", sb, gb)
+        assertReplace(c, c + 1000, "", sb, gb)
+
+        // Backspacing
+        assertReplace(c - 10, c, "", sb, gb)
+        c -= 10
+        assertReplace(c - 100, c, "", sb, gb)
+        c -= 100
+        assertReplace(c - 1000, c, "", sb, gb)
+    }
+
+    @Test
+    fun longTextTest_farInput() {
+        val sb = StringBuffer(LONG_INIT_TEXT)
+        val gb = PartialGapBuffer(LONG_INIT_TEXT)
+
+        assertReplace(1024, 1024, "Hello, World", sb, gb)
+        assertReplace(128, 128, LONG_TEXT, sb, gb)
+    }
+
+    @Test
+    fun randomInsertDeleteStressTest() {
+        val sb = StringBuffer(LONG_INIT_TEXT)
+        val gb = PartialGapBuffer(LONG_INIT_TEXT)
+
+        val r = Random(10 /* fix the seed for reproduction */)
+
+        val insertTexts = arrayOf(SHORT_TEXT, MEDIUM_TEXT, LONG_TEXT)
+        val delLengths = arrayOf(1, 10, 100)
+
+        var c = LONG_INIT_TEXT.length / 2
+
+        for (i in 0..100) {
+            when (r.nextInt() % 4) {
+                0 -> { // insert
+                    val txt = insertTexts.random(r)
+                    assertReplace(c, c, txt, sb, gb)
+                    c += txt.length
+                }
+                1 -> { // forward delete
+                    assertReplace(c, c + delLengths.random(r), "", sb, gb)
+                }
+                2 -> { // backspacing
+                    val len = delLengths.random(r)
+                    assertReplace(c - len, c, "", sb, gb)
+                    c -= len
+                }
+                3 -> { // replacing
+                    val txt = insertTexts.random(r)
+                    val len = delLengths.random(r)
+
+                    assertReplace(c, c + len, txt, sb, gb)
+                }
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/compose/foundation/foundation/integration-tests/foundation-demos/src/test/kotlin/androidx/compose/foundation/text2/input/SetComposingRegionCommandTest.kt b/compose/foundation/foundation/integration-tests/foundation-demos/src/test/kotlin/androidx/compose/foundation/text2/input/SetComposingRegionCommandTest.kt
new file mode 100644
index 0000000..0e5891d
--- /dev/null
+++ b/compose/foundation/foundation/integration-tests/foundation-demos/src/test/kotlin/androidx/compose/foundation/text2/input/SetComposingRegionCommandTest.kt
@@ -0,0 +1,130 @@
+/*
+ * 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.compose.foundation.text2.input
+
+import androidx.compose.ui.text.TextRange
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+
+@RunWith(JUnit4::class)
+class SetComposingRegionCommandTest {
+
+    @Test
+    fun test_set() {
+        val eb = EditingBuffer("ABCDE", TextRange.Zero)
+
+        eb.update(SetComposingRegionCommand(1, 4))
+
+        assertThat(eb.toString()).isEqualTo("ABCDE")
+        assertThat(eb.cursor).isEqualTo(0)
+        assertThat(eb.hasComposition()).isTrue()
+        assertThat(eb.compositionStart).isEqualTo(1)
+        assertThat(eb.compositionEnd).isEqualTo(4)
+    }
+
+    @Test
+    fun test_preserve_ongoing_composition() {
+        val eb = EditingBuffer("ABCDE", TextRange.Zero)
+
+        eb.setComposition(1, 3)
+
+        eb.update(SetComposingRegionCommand(2, 4))
+
+        assertThat(eb.toString()).isEqualTo("ABCDE")
+        assertThat(eb.cursor).isEqualTo(0)
+        assertThat(eb.hasComposition()).isTrue()
+        assertThat(eb.compositionStart).isEqualTo(2)
+        assertThat(eb.compositionEnd).isEqualTo(4)
+    }
+
+    @Test
+    fun test_preserve_selection() {
+        val eb = EditingBuffer("ABCDE", TextRange(1, 4))
+
+        eb.update(SetComposingRegionCommand(2, 4))
+
+        assertThat(eb.toString()).isEqualTo("ABCDE")
+        assertThat(eb.selectionStart).isEqualTo(1)
+        assertThat(eb.selectionEnd).isEqualTo(4)
+        assertThat(eb.hasComposition()).isTrue()
+        assertThat(eb.compositionStart).isEqualTo(2)
+        assertThat(eb.compositionEnd).isEqualTo(4)
+    }
+
+    @Test
+    fun test_set_reversed() {
+        val eb = EditingBuffer("ABCDE", TextRange.Zero)
+
+        eb.update(SetComposingRegionCommand(4, 1))
+
+        assertThat(eb.toString()).isEqualTo("ABCDE")
+        assertThat(eb.cursor).isEqualTo(0)
+        assertThat(eb.hasComposition()).isTrue()
+        assertThat(eb.compositionStart).isEqualTo(1)
+        assertThat(eb.compositionEnd).isEqualTo(4)
+    }
+
+    @Test
+    fun test_set_too_small() {
+        val eb = EditingBuffer("ABCDE", TextRange.Zero)
+
+        eb.update(SetComposingRegionCommand(-1000, -1000))
+
+        assertThat(eb.toString()).isEqualTo("ABCDE")
+        assertThat(eb.cursor).isEqualTo(0)
+        assertThat(eb.hasComposition()).isFalse()
+    }
+
+    @Test
+    fun test_set_too_large() {
+        val eb = EditingBuffer("ABCDE", TextRange.Zero)
+
+        eb.update(SetComposingRegionCommand(1000, 1000))
+
+        assertThat(eb.toString()).isEqualTo("ABCDE")
+        assertThat(eb.cursor).isEqualTo(0)
+        assertThat(eb.hasComposition()).isFalse()
+    }
+
+    @Test
+    fun test_set_too_small_and_too_large() {
+        val eb = EditingBuffer("ABCDE", TextRange.Zero)
+
+        eb.update(SetComposingRegionCommand(-1000, 1000))
+
+        assertThat(eb.toString()).isEqualTo("ABCDE")
+        assertThat(eb.cursor).isEqualTo(0)
+        assertThat(eb.hasComposition()).isTrue()
+        assertThat(eb.compositionStart).isEqualTo(0)
+        assertThat(eb.compositionEnd).isEqualTo(5)
+    }
+
+    @Test
+    fun test_set_too_small_and_too_large_reversed() {
+        val eb = EditingBuffer("ABCDE", TextRange.Zero)
+
+        eb.update(SetComposingRegionCommand(1000, -1000))
+
+        assertThat(eb.toString()).isEqualTo("ABCDE")
+        assertThat(eb.cursor).isEqualTo(0)
+        assertThat(eb.hasComposition()).isTrue()
+        assertThat(eb.compositionStart).isEqualTo(0)
+        assertThat(eb.compositionEnd).isEqualTo(5)
+    }
+}
\ No newline at end of file
diff --git a/compose/foundation/foundation/integration-tests/foundation-demos/src/test/kotlin/androidx/compose/foundation/text2/input/SetComposingTextCommandTest.kt b/compose/foundation/foundation/integration-tests/foundation-demos/src/test/kotlin/androidx/compose/foundation/text2/input/SetComposingTextCommandTest.kt
new file mode 100644
index 0000000..3e483a9
--- /dev/null
+++ b/compose/foundation/foundation/integration-tests/foundation-demos/src/test/kotlin/androidx/compose/foundation/text2/input/SetComposingTextCommandTest.kt
@@ -0,0 +1,205 @@
+/*
+ * 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.compose.foundation.text2.input
+
+import androidx.compose.ui.text.TextRange
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+
+@RunWith(JUnit4::class)
+class SetComposingTextCommandTest {
+
+    @Test
+    fun test_insert_empty() {
+        val eb = EditingBuffer("", TextRange.Zero)
+
+        eb.update(SetComposingTextCommand("X", 1))
+
+        assertThat(eb.toString()).isEqualTo("X")
+        assertThat(eb.cursor).isEqualTo(1)
+        assertThat(eb.hasComposition()).isTrue()
+        assertThat(eb.compositionStart).isEqualTo(0)
+        assertThat(eb.compositionEnd).isEqualTo(1)
+    }
+
+    @Test
+    fun test_insert_cursor_tail() {
+        val eb = EditingBuffer("A", TextRange(1))
+
+        eb.update(SetComposingTextCommand("X", 1))
+
+        assertThat(eb.toString()).isEqualTo("AX")
+        assertThat(eb.cursor).isEqualTo(2)
+        assertThat(eb.hasComposition()).isTrue()
+        assertThat(eb.compositionStart).isEqualTo(1)
+        assertThat(eb.compositionEnd).isEqualTo(2)
+    }
+
+    @Test
+    fun test_insert_cursor_head() {
+        val eb = EditingBuffer("A", TextRange(1))
+
+        eb.update(SetComposingTextCommand("X", 0))
+
+        assertThat(eb.toString()).isEqualTo("AX")
+        assertThat(eb.cursor).isEqualTo(1)
+        assertThat(eb.hasComposition()).isTrue()
+        assertThat(eb.compositionStart).isEqualTo(1)
+        assertThat(eb.compositionEnd).isEqualTo(2)
+    }
+
+    @Test
+    fun test_insert_cursor_far_tail() {
+        val eb = EditingBuffer("ABCDE", TextRange(1))
+
+        eb.update(SetComposingTextCommand("X", 2))
+
+        assertThat(eb.toString()).isEqualTo("AXBCDE")
+        assertThat(eb.cursor).isEqualTo(3)
+        assertThat(eb.hasComposition()).isTrue()
+        assertThat(eb.compositionStart).isEqualTo(1)
+        assertThat(eb.compositionEnd).isEqualTo(2)
+    }
+
+    @Test
+    fun test_insert_cursor_far_head() {
+        val eb = EditingBuffer("ABCDE", TextRange(4))
+
+        eb.update(SetComposingTextCommand("X", -2))
+
+        assertThat(eb.toString()).isEqualTo("ABCDXE")
+        assertThat(eb.cursor).isEqualTo(2)
+        assertThat(eb.hasComposition()).isTrue()
+        assertThat(eb.compositionStart).isEqualTo(4)
+        assertThat(eb.compositionEnd).isEqualTo(5)
+    }
+
+    @Test
+    fun test_insert_empty_text_cursor_head() {
+        val eb = EditingBuffer("ABCDE", TextRange(1))
+
+        eb.update(SetComposingTextCommand("", 0))
+
+        assertThat(eb.toString()).isEqualTo("ABCDE")
+        assertThat(eb.cursor).isEqualTo(1)
+        assertThat(eb.hasComposition()).isFalse()
+    }
+
+    @Test
+    fun test_insert_empty_text_cursor_tail() {
+        val eb = EditingBuffer("ABCDE", TextRange(1))
+
+        eb.update(SetComposingTextCommand("", 1))
+
+        assertThat(eb.toString()).isEqualTo("ABCDE")
+        assertThat(eb.cursor).isEqualTo(1)
+        assertThat(eb.hasComposition()).isFalse()
+    }
+
+    @Test
+    fun test_insert_empty_text_cursor_far_tail() {
+        val eb = EditingBuffer("ABCDE", TextRange(1))
+
+        eb.update(SetComposingTextCommand("", 2))
+
+        assertThat(eb.toString()).isEqualTo("ABCDE")
+        assertThat(eb.cursor).isEqualTo(2)
+        assertThat(eb.hasComposition()).isFalse()
+    }
+
+    @Test
+    fun test_insert_empty_text_cursor_far_head() {
+        val eb = EditingBuffer("ABCDE", TextRange(4))
+
+        eb.update(SetComposingTextCommand("", -2))
+
+        assertThat(eb.toString()).isEqualTo("ABCDE")
+        assertThat(eb.cursor).isEqualTo(2)
+        assertThat(eb.hasComposition()).isFalse()
+    }
+
+    @Test
+    fun test_cancel_composition() {
+        val eb = EditingBuffer("ABCDE", TextRange.Zero)
+
+        eb.setComposition(1, 4) // Mark "BCD" as composition
+        eb.update(SetComposingTextCommand("X", 1))
+
+        assertThat(eb.toString()).isEqualTo("AXE")
+        assertThat(eb.cursor).isEqualTo(2)
+        assertThat(eb.hasComposition()).isTrue()
+        assertThat(eb.compositionStart).isEqualTo(1)
+        assertThat(eb.compositionEnd).isEqualTo(2)
+    }
+
+    @Test
+    fun test_replace_selection() {
+        val eb = EditingBuffer("ABCDE", TextRange(1, 4)) // select "BCD"
+
+        eb.update(SetComposingTextCommand("X", 1))
+
+        assertThat(eb.toString()).isEqualTo("AXE")
+        assertThat(eb.cursor).isEqualTo(2)
+        assertThat(eb.hasComposition()).isTrue()
+        assertThat(eb.compositionStart).isEqualTo(1)
+        assertThat(eb.compositionEnd).isEqualTo(2)
+    }
+
+    @Test
+    fun test_composition_and_selection() {
+        val eb = EditingBuffer("ABCDE", TextRange(1, 3)) // select "BC"
+
+        eb.setComposition(2, 4) // Mark "CD" as composition
+        eb.update(SetComposingTextCommand("X", 1))
+
+        // If composition and selection exists at the same time, replace composition and cancel
+        // selection and place cursor.
+        assertThat(eb.toString()).isEqualTo("ABXE")
+        assertThat(eb.cursor).isEqualTo(3)
+        assertThat(eb.hasComposition()).isTrue()
+        assertThat(eb.compositionStart).isEqualTo(2)
+        assertThat(eb.compositionEnd).isEqualTo(3)
+    }
+
+    @Test
+    fun test_cursor_position_too_small() {
+        val eb = EditingBuffer("ABCDE", TextRange(5))
+
+        eb.update(SetComposingTextCommand("X", -1000))
+
+        assertThat(eb.toString()).isEqualTo("ABCDEX")
+        assertThat(eb.cursor).isEqualTo(0)
+        assertThat(eb.hasComposition()).isTrue()
+        assertThat(eb.compositionStart).isEqualTo(5)
+        assertThat(eb.compositionEnd).isEqualTo(6)
+    }
+
+    @Test
+    fun test_cursor_position_too_large() {
+        val eb = EditingBuffer("ABCDE", TextRange(5))
+
+        eb.update(SetComposingTextCommand("X", 1000))
+
+        assertThat(eb.toString()).isEqualTo("ABCDEX")
+        assertThat(eb.cursor).isEqualTo(6)
+        assertThat(eb.hasComposition()).isTrue()
+        assertThat(eb.compositionStart).isEqualTo(5)
+        assertThat(eb.compositionEnd).isEqualTo(6)
+    }
+}
\ No newline at end of file
diff --git a/compose/foundation/foundation/integration-tests/foundation-demos/src/test/kotlin/androidx/compose/foundation/text2/input/SetSelectionCommandTest.kt b/compose/foundation/foundation/integration-tests/foundation-demos/src/test/kotlin/androidx/compose/foundation/text2/input/SetSelectionCommandTest.kt
new file mode 100644
index 0000000..a0c8683
--- /dev/null
+++ b/compose/foundation/foundation/integration-tests/foundation-demos/src/test/kotlin/androidx/compose/foundation/text2/input/SetSelectionCommandTest.kt
@@ -0,0 +1,125 @@
+/*
+ * 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.compose.foundation.text2.input
+
+import androidx.compose.ui.text.TextRange
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+
+@RunWith(JUnit4::class)
+class SetSelectionCommandTest {
+
+    @Test
+    fun test_set() {
+        val eb = EditingBuffer("ABCDE", TextRange.Zero)
+
+        eb.update(SetSelectionCommand(1, 4))
+
+        assertThat(eb.toString()).isEqualTo("ABCDE")
+        assertThat(eb.selectionStart).isEqualTo(1)
+        assertThat(eb.selectionEnd).isEqualTo(4)
+        assertThat(eb.hasComposition()).isFalse()
+    }
+
+    @Test
+    fun test_preserve_ongoing_composition() {
+        val eb = EditingBuffer("ABCDE", TextRange.Zero)
+
+        eb.setComposition(1, 3)
+
+        eb.update(SetSelectionCommand(2, 4))
+
+        assertThat(eb.toString()).isEqualTo("ABCDE")
+        assertThat(eb.selectionStart).isEqualTo(2)
+        assertThat(eb.selectionEnd).isEqualTo(4)
+        assertThat(eb.hasComposition()).isTrue()
+        assertThat(eb.compositionStart).isEqualTo(1)
+        assertThat(eb.compositionEnd).isEqualTo(3)
+    }
+
+    @Test
+    fun test_cancel_ongoing_selection() {
+        val eb = EditingBuffer("ABCDE", TextRange(1, 4))
+
+        eb.update(SetSelectionCommand(2, 5))
+
+        assertThat(eb.toString()).isEqualTo("ABCDE")
+        assertThat(eb.selectionStart).isEqualTo(2)
+        assertThat(eb.selectionEnd).isEqualTo(5)
+        assertThat(eb.hasComposition()).isFalse()
+    }
+
+    @Test
+    fun test_set_reversed() {
+        val eb = EditingBuffer("ABCDE", TextRange.Zero)
+
+        eb.update(SetSelectionCommand(4, 1))
+
+        assertThat(eb.toString()).isEqualTo("ABCDE")
+        assertThat(eb.selectionStart).isEqualTo(1)
+        assertThat(eb.selectionEnd).isEqualTo(4)
+        assertThat(eb.hasComposition()).isFalse()
+    }
+
+    @Test
+    fun test_set_too_small() {
+        val eb = EditingBuffer("ABCDE", TextRange.Zero)
+
+        eb.update(SetSelectionCommand(-1000, -1000))
+
+        assertThat(eb.toString()).isEqualTo("ABCDE")
+        assertThat(eb.cursor).isEqualTo(0)
+        assertThat(eb.hasComposition()).isFalse()
+    }
+
+    @Test
+    fun test_set_too_large() {
+        val eb = EditingBuffer("ABCDE", TextRange.Zero)
+
+        eb.update(SetSelectionCommand(1000, 1000))
+
+        assertThat(eb.toString()).isEqualTo("ABCDE")
+        assertThat(eb.cursor).isEqualTo(5)
+        assertThat(eb.hasComposition()).isFalse()
+    }
+
+    @Test
+    fun test_set_too_small_too_large() {
+        val eb = EditingBuffer("ABCDE", TextRange.Zero)
+
+        eb.update(SetSelectionCommand(-1000, 1000))
+
+        assertThat(eb.toString()).isEqualTo("ABCDE")
+        assertThat(eb.selectionStart).isEqualTo(0)
+        assertThat(eb.selectionEnd).isEqualTo(5)
+        assertThat(eb.hasComposition()).isFalse()
+    }
+
+    @Test
+    fun test_set_too_small_too_large_reversed() {
+        val eb = EditingBuffer("ABCDE", TextRange.Zero)
+
+        eb.update(SetSelectionCommand(1000, -1000))
+
+        assertThat(eb.toString()).isEqualTo("ABCDE")
+        assertThat(eb.selectionStart).isEqualTo(0)
+        assertThat(eb.selectionEnd).isEqualTo(5)
+        assertThat(eb.hasComposition()).isFalse()
+    }
+}
\ No newline at end of file
diff --git a/compose/foundation/foundation/integration-tests/foundation-demos/src/test/kotlin/androidx/compose/foundation/text2/input/matchers/EditBufferSubject.kt b/compose/foundation/foundation/integration-tests/foundation-demos/src/test/kotlin/androidx/compose/foundation/text2/input/matchers/EditBufferSubject.kt
new file mode 100644
index 0000000..5858808
--- /dev/null
+++ b/compose/foundation/foundation/integration-tests/foundation-demos/src/test/kotlin/androidx/compose/foundation/text2/input/matchers/EditBufferSubject.kt
@@ -0,0 +1,74 @@
+/*
+ * 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.
+ */
+
+@file:OptIn(InternalFoundationTextApi::class)
+
+package androidx.compose.foundation.text2.input.matchers
+
+import androidx.compose.foundation.text.InternalFoundationTextApi
+import androidx.compose.foundation.text2.input.EditingBuffer
+import androidx.compose.foundation.text2.input.PartialGapBuffer
+import com.google.common.truth.FailureMetadata
+import com.google.common.truth.Subject
+import com.google.common.truth.Subject.Factory
+import com.google.common.truth.Truth.assertAbout
+import com.google.common.truth.Truth.assertThat
+
+@OptIn(InternalFoundationTextApi::class)
+internal fun assertThat(buffer: PartialGapBuffer): EditBufferSubject {
+    return assertAbout(EditBufferSubject.SUBJECT_FACTORY)
+        .that(GapBufferWrapper(buffer))!!
+}
+
+internal fun assertThat(buffer: EditingBuffer): EditBufferSubject {
+    return assertAbout(EditBufferSubject.SUBJECT_FACTORY)
+        .that(EditingBufferWrapper(buffer))!!
+}
+
+internal abstract class GetOperatorWrapper(val buffer: Any) {
+    abstract operator fun get(index: Int): Char
+    override fun toString(): String = buffer.toString()
+}
+
+private class EditingBufferWrapper(buffer: EditingBuffer) : GetOperatorWrapper(buffer) {
+    override fun get(index: Int): Char = (buffer as EditingBuffer)[index]
+}
+
+@OptIn(InternalFoundationTextApi::class)
+private class GapBufferWrapper(buffer: PartialGapBuffer) : GetOperatorWrapper(buffer) {
+    override fun get(index: Int): Char = (buffer as PartialGapBuffer)[index]
+}
+
+/**
+ * Truth extension for Editing Buffers.
+ */
+internal class EditBufferSubject private constructor(
+    failureMetadata: FailureMetadata?,
+    private val subject: GetOperatorWrapper
+) : Subject(failureMetadata, subject) {
+
+    companion object {
+        internal val SUBJECT_FACTORY: Factory<EditBufferSubject, GetOperatorWrapper> =
+            Factory { failureMetadata, subject -> EditBufferSubject(failureMetadata, subject) }
+    }
+
+    fun hasChars(expected: String) {
+        assertThat(subject.buffer.toString()).isEqualTo(expected)
+        for (i in expected.indices) {
+            assertThat(subject[i]).isEqualTo(expected[i])
+        }
+    }
+}
\ No newline at end of file
diff --git a/compose/foundation/foundation/integration-tests/foundation-demos/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/compose/foundation/foundation/integration-tests/foundation-demos/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker
new file mode 100644
index 0000000..1f0955d
--- /dev/null
+++ b/compose/foundation/foundation/integration-tests/foundation-demos/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker
@@ -0,0 +1 @@
+mock-maker-inline
diff --git a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/ScrollableTest.kt b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/ScrollableTest.kt
index 7f51f44..c93f952 100644
--- a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/ScrollableTest.kt
+++ b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/ScrollableTest.kt
@@ -102,7 +102,6 @@
 import androidx.test.espresso.action.Swipe
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.LargeTest
-import androidx.test.filters.SdkSuppress
 import com.google.common.truth.Truth.assertThat
 import com.google.common.truth.Truth.assertWithMessage
 import kotlin.math.abs
@@ -2342,7 +2341,6 @@
         }
     }
 
-    @SdkSuppress(maxSdkVersion = 32) // b/268753157
     @Test
     fun offsetsScrollable_velocityCalculationShouldConsiderLocalPositions() {
         // arrange
@@ -2593,8 +2591,8 @@
     onView(allOf(instanceOf(AbstractComposeView::class.java)))
         .perform(
             espressoSwipe(
-                GeneralLocation.BOTTOM_CENTER,
-                GeneralLocation.CENTER
+                GeneralLocation.CENTER,
+                GeneralLocation.TOP_CENTER
             )
         )
 }
diff --git a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/list/LazyListTest.kt b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/list/LazyListTest.kt
index 0a5cc61..7784e47 100644
--- a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/list/LazyListTest.kt
+++ b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/list/LazyListTest.kt
@@ -2000,7 +2000,6 @@
             .assertStartPositionInRootIsEqualTo(0.dp)
     }
 
-    @SdkSuppress(maxSdkVersion = 32) // b/269178188
     @Test
     fun assertVelocityCalculationIsSimilar_witHistoricalValues() {
         // arrange
diff --git a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridTest.kt b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridTest.kt
index 01cadd0..0da50f5 100644
--- a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridTest.kt
+++ b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridTest.kt
@@ -1693,4 +1693,159 @@
         assertThat(state.firstVisibleItemIndex).isEqualTo(8)
         assertThat(state.firstVisibleItemScrollOffset).isEqualTo(0)
     }
+
+    @Test
+    fun initialIndex_largerThanItemCount_ordersItemsCorrectly_withFullSpan() {
+        rule.setContent {
+            state = rememberLazyStaggeredGridState(20)
+            Box(Modifier.mainAxisSize(itemSizeDp * 4)) {
+                LazyStaggeredGrid(
+                    lanes = 3,
+                    state = state,
+                    modifier = Modifier
+                        .crossAxisSize(itemSizeDp * 3)
+                        .testTag(LazyStaggeredGridTag),
+                ) {
+                    item(span = StaggeredGridItemSpan.FullLine) {
+                        Spacer(
+                            Modifier
+                                .testTag("full")
+                                .mainAxisSize(itemSizeDp * 2)
+                        )
+                    }
+                    items(6) {
+                        val size = when (it) {
+                            0, 3 -> itemSizeDp * 2
+                            1, 4 -> itemSizeDp * 1.5f
+                            2, 5 -> itemSizeDp
+                            else -> error("unexpected item $it")
+                        }
+                        Spacer(
+                            Modifier
+                                .testTag("$it")
+                                .mainAxisSize(size)
+                        )
+                    }
+                }
+            }
+        }
+
+        // ┌───────────┐
+        // │           │
+        // │   full    │ <-- scroll offset
+        // │           │
+        // ├───┬───┬───┤
+        // │ 0 │ 1 │ 2 │
+        // │   │   ├───┤
+        // │   │───┤ 3 │
+        // ├───┤ 4 │   │
+        // │ 5 │   │   │
+        // └───┴───┴───┘ <-- end of grid
+
+        rule.onNodeWithTag("full")
+            .assertAxisBounds(
+                DpOffset(0.dp, -itemSizeDp), DpSize(itemSizeDp * 3, itemSizeDp * 2)
+            )
+
+        rule.onNodeWithTag("0")
+            .assertAxisBounds(
+                DpOffset(0.dp, itemSizeDp), DpSize(itemSizeDp, itemSizeDp * 2f)
+            )
+
+        rule.onNodeWithTag("1")
+            .assertAxisBounds(
+                DpOffset(itemSizeDp, itemSizeDp), DpSize(itemSizeDp, itemSizeDp * 1.5f)
+            )
+
+        rule.onNodeWithTag("2")
+            .assertAxisBounds(
+                DpOffset(itemSizeDp * 2f, itemSizeDp), DpSize(itemSizeDp, itemSizeDp)
+            )
+
+        rule.onNodeWithTag("3")
+            .assertAxisBounds(
+                DpOffset(itemSizeDp * 2f, itemSizeDp * 2f), DpSize(itemSizeDp, itemSizeDp * 2)
+            )
+
+        rule.onNodeWithTag("4")
+            .assertAxisBounds(
+                DpOffset(itemSizeDp, itemSizeDp * 2.5f), DpSize(itemSizeDp, itemSizeDp * 1.5f)
+            )
+
+        rule.onNodeWithTag("5")
+            .assertAxisBounds(
+                DpOffset(0.dp, itemSizeDp * 3), DpSize(itemSizeDp, itemSizeDp)
+            )
+    }
+
+    @Test
+    fun initialIndex_largerThanItemCount_ordersItemsCorrectly() {
+        rule.setContent {
+            state = rememberLazyStaggeredGridState(20)
+            Box(Modifier.mainAxisSize(itemSizeDp * 4)) {
+                LazyStaggeredGrid(
+                    lanes = 3,
+                    state = state,
+                    modifier = Modifier
+                        .crossAxisSize(itemSizeDp * 3)
+                        .testTag(LazyStaggeredGridTag),
+                ) {
+                    items(6) {
+                        val size = when (it) {
+                            0, 3 -> itemSizeDp * 2
+                            1, 4 -> itemSizeDp * 1.5f
+                            2, 5 -> itemSizeDp
+                            else -> error("unexpected item $it")
+                        }
+                        Spacer(
+                            Modifier
+                                .testTag("$it")
+                                .mainAxisSize(size)
+                        )
+                    }
+                }
+            }
+        }
+
+        // ┌───┬───┬───┐
+        // │ 0 │ 1 │ 2 │
+        // │   │   ├───┤
+        // │   │───┤ 3 │
+        // ├───┤ 4 │   │
+        // │ 5 │   │   │
+        // └───┴───┴───┘
+
+        rule.onNodeWithTag(LazyStaggeredGridTag)
+            .assertMainAxisSizeIsEqualTo(itemSizeDp * 3)
+
+        rule.onNodeWithTag("0")
+            .assertAxisBounds(
+                DpOffset(0.dp, 0.dp), DpSize(itemSizeDp, itemSizeDp * 2f)
+            )
+
+        rule.onNodeWithTag("1")
+            .assertAxisBounds(
+                DpOffset(itemSizeDp, 0.dp), DpSize(itemSizeDp, itemSizeDp * 1.5f)
+            )
+
+        rule.onNodeWithTag("2")
+            .assertAxisBounds(
+                DpOffset(itemSizeDp * 2f, 0.dp), DpSize(itemSizeDp, itemSizeDp)
+            )
+
+        rule.onNodeWithTag("3")
+            .assertAxisBounds(
+                DpOffset(itemSizeDp * 2f, itemSizeDp), DpSize(itemSizeDp, itemSizeDp * 2)
+            )
+
+        rule.onNodeWithTag("4")
+            .assertAxisBounds(
+                DpOffset(itemSizeDp, itemSizeDp * 1.5f), DpSize(itemSizeDp, itemSizeDp * 1.5f)
+            )
+
+        rule.onNodeWithTag("5")
+            .assertAxisBounds(
+                DpOffset(0.dp, itemSizeDp * 2), DpSize(itemSizeDp, itemSizeDp)
+            )
+    }
 }
diff --git a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/pager/PagerNestedScrollContentTest.kt b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/pager/PagerNestedScrollContentTest.kt
index ff4a3e3..1e6ee8d 100644
--- a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/pager/PagerNestedScrollContentTest.kt
+++ b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/pager/PagerNestedScrollContentTest.kt
@@ -35,7 +35,7 @@
 import androidx.compose.ui.unit.Velocity
 import androidx.compose.ui.unit.dp
 import androidx.test.filters.LargeTest
-import androidx.test.filters.SdkSuppress
+import org.junit.Assert.assertEquals
 import com.google.common.truth.Truth.assertThat
 import kotlin.math.absoluteValue
 import kotlinx.coroutines.runBlocking
@@ -50,7 +50,6 @@
     config: ParamConfig
 ) : BasePagerTest(config = config) {
 
-    @SdkSuppress(maxSdkVersion = 32) // b/269171814
     @OptIn(ExperimentalFoundationApi::class)
     @Test
     fun nestedScrollContent_shouldNotPropagateUnconsumedFlings() {
@@ -87,7 +86,7 @@
 
         // Assert: Fling was not propagated, so we didn't move pages
         assertThat(pagerState.currentPage).isEqualTo(0)
-        assertThat(pagerState.currentPageOffsetFraction).isEqualTo(0f)
+        assertEquals(pagerState.currentPageOffsetFraction, 0f, 0.01f)
     }
 
     @OptIn(ExperimentalFoundationApi::class)
diff --git a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/textfield/TextFieldTest.kt b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/textfield/TextFieldTest.kt
index cbc9f6f5..37899be 100644
--- a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/textfield/TextFieldTest.kt
+++ b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/textfield/TextFieldTest.kt
@@ -21,11 +21,11 @@
 
 import android.os.Build
 import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
 import androidx.compose.foundation.interaction.FocusInteraction
 import androidx.compose.foundation.interaction.Interaction
 import androidx.compose.foundation.interaction.MutableInteractionSource
-import androidx.compose.foundation.background
-import androidx.compose.foundation.border
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.IntrinsicSize
@@ -97,11 +97,11 @@
 import androidx.compose.ui.test.onNodeWithTag
 import androidx.compose.ui.test.performClick
 import androidx.compose.ui.test.performKeyPress
-import androidx.compose.ui.test.performTouchInput
 import androidx.compose.ui.test.performSemanticsAction
 import androidx.compose.ui.test.performTextClearance
 import androidx.compose.ui.test.performTextInput
 import androidx.compose.ui.test.performTextInputSelection
+import androidx.compose.ui.test.performTouchInput
 import androidx.compose.ui.text.AnnotatedString
 import androidx.compose.ui.text.ExperimentalTextApi
 import androidx.compose.ui.text.ParagraphStyle
@@ -1037,6 +1037,39 @@
     }
 
     @Test
+    fun textField_stringOverload_doesNotCallOnValueChange_whenCompositionUpdatesOnly_semantics() {
+        var callbackCounter = 0
+
+        rule.setContent {
+            val focusManager = LocalFocusManager.current
+            val text = remember { mutableStateOf("A") }
+
+            BasicTextField(
+                value = text.value,
+                onValueChange = {
+                    callbackCounter += 1
+                    text.value = it
+
+                    // causes TextFieldValue's composition clearing
+                    focusManager.clearFocus(true)
+                },
+                modifier = Modifier.testTag("tag")
+            )
+        }
+
+        rule.onNodeWithTag("tag")
+            .performClick()
+        rule.waitForIdle()
+
+        rule.onNodeWithTag("tag")
+            .performSemanticsAction(SemanticsActions.SetText) { it(AnnotatedString("")) }
+
+        rule.runOnIdle {
+            assertThat(callbackCounter).isEqualTo(1)
+        }
+    }
+
+    @Test
     @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
     fun textField_textAlignCenter_defaultWidth() {
         val fontSize = 50
@@ -1147,7 +1180,9 @@
                 onValueChange = {
                     textFieldValue.value = it
                 },
-                modifier = Modifier.testTag(Tag).wrapContentSize()
+                modifier = Modifier
+                    .testTag(Tag)
+                    .wrapContentSize()
             )
         }
         val textNode = rule.onNodeWithTag(Tag)
@@ -1172,7 +1207,9 @@
                 onValueChange = {
                     textFieldValue.value = it
                 },
-                modifier = Modifier.testTag(Tag).wrapContentSize()
+                modifier = Modifier
+                    .testTag(Tag)
+                    .wrapContentSize()
             )
         }
         val textNode = rule.onNodeWithTag(Tag)
@@ -1198,7 +1235,9 @@
                 onValueChange = {
                     textFieldValue.value = it
                 },
-                modifier = Modifier.testTag(Tag).wrapContentSize()
+                modifier = Modifier
+                    .testTag(Tag)
+                    .wrapContentSize()
             )
         }
         val textNode = rule.onNodeWithTag(Tag)
@@ -1230,7 +1269,9 @@
                 onValueChange = {
                     textFieldValue.value = it
                 },
-                modifier = Modifier.testTag(Tag).wrapContentSize()
+                modifier = Modifier
+                    .testTag(Tag)
+                    .wrapContentSize()
             )
         }
         val textNode = rule.onNodeWithTag(Tag)
@@ -1265,7 +1306,9 @@
                 onValueChange = {
                     textFieldValue.value = it
                 },
-                modifier = Modifier.testTag(Tag).wrapContentSize()
+                modifier = Modifier
+                    .testTag(Tag)
+                    .wrapContentSize()
             )
         }
         val textNode = rule.onNodeWithTag(Tag)
@@ -1279,12 +1322,14 @@
                 )
             )
         )
-        textNode.performKeyPress(KeyEvent(
-            NativeKeyEvent(
-                NativeKeyEvent.ACTION_UP,
-                NativeKeyEvent.KEYCODE_DEL
+        textNode.performKeyPress(
+            KeyEvent(
+                NativeKeyEvent(
+                    NativeKeyEvent.ACTION_UP,
+                    NativeKeyEvent.KEYCODE_DEL
+                )
             )
-        ))
+        )
 
         textFieldValue.value = "Hello"
 
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 5dd773cdc..a17de5a 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
@@ -552,10 +552,24 @@
             val toScrollBack = mainAxisAvailableSize - currentItemOffsets[maxOffsetLane]
             firstItemOffsets.offsetBy(-toScrollBack)
             currentItemOffsets.offsetBy(toScrollBack)
+
+            var gapDetected = false
             while (
                 firstItemOffsets.any { it < beforeContentPadding }
             ) {
+                // We choose the minimum offset value and try to put items on top.
+                // Note that it is different from initial pass up where we selected largest index
+                // instead. The reason is that we already distributed items on downward pass and
+                // gap would be incorrect if those are moved.
                 val laneIndex = firstItemOffsets.indexOfMinValue()
+
+                if (laneIndex != firstItemIndices.indexOfMaxValue()) {
+                    // If min offset lane doesn't have largest value, it means items are misaligned.
+                    // The correct thing here is to restart measure. We will measure up to the end
+                    // and restart measure from there after this pass.
+                    gapDetected = true
+                }
+
                 val currentIndex =
                     if (firstItemIndices[laneIndex] == -1) {
                         itemCount
@@ -567,7 +581,7 @@
                     findPreviousItemIndex(currentIndex, laneIndex)
 
                 if (previousIndex < 0) {
-                    if (misalignedStart(laneIndex) && canRestartMeasure) {
+                    if ((gapDetected || misalignedStart(laneIndex)) && canRestartMeasure) {
                         laneInfo.reset()
                         return measure(
                             initialScrollDelta = scrollDelta,
@@ -591,12 +605,32 @@
                 val offset = firstItemOffsets.maxInRange(spanRange)
                 val gaps = if (spanRange.isFullSpan) laneInfo.getGaps(previousIndex) else null
                 spanRange.forEach { lane ->
+                    if (firstItemOffsets[lane] != offset) {
+                        // Some items below fully spanned item don't match it exactly. We skip over,
+                        // but this should be corrected through remeasure.
+                        gapDetected = true
+                    }
+
                     measuredItems[lane].addFirst(measuredItem)
                     firstItemIndices[lane] = previousIndex
                     val gap = if (gaps == null) 0 else gaps[lane]
                     firstItemOffsets[lane] = offset + measuredItem.sizeWithSpacings + gap
                 }
             }
+
+            // If incorrectly offset lanes were detected before, restart measure from the current
+            // point. Incorrectly offset items will be redistributed to the correct lanes on the
+            // downward pass.
+            if (gapDetected && canRestartMeasure) {
+                laneInfo.reset()
+                return measure(
+                    initialScrollDelta = scrollDelta,
+                    initialItemIndices = firstItemIndices,
+                    initialItemOffsets = firstItemOffsets,
+                    canRestartMeasure = false
+                )
+            }
+
             scrollDelta += toScrollBack
 
             val minOffsetLane = firstItemOffsets.indexOfMinValue()
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/CoreTextField.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/CoreTextField.kt
index 52f41d93..b8b69e7 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/CoreTextField.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/CoreTextField.kt
@@ -95,6 +95,8 @@
 import androidx.compose.ui.text.TextRange
 import androidx.compose.ui.text.TextStyle
 import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.input.CommitTextCommand
+import androidx.compose.ui.text.input.DeleteAllCommand
 import androidx.compose.ui.text.input.EditProcessor
 import androidx.compose.ui.text.input.ImeAction
 import androidx.compose.ui.text.input.ImeOptions
@@ -417,20 +419,33 @@
                 false
             }
         }
-        setText {
-            state.onValueChange(TextFieldValue(it.text, TextRange(it.text.length)))
+        setText { text ->
+            // If the action is performed while in an active text editing session, treat this like
+            // an IME command and update the text by going through the buffer. This keeps the buffer
+            // state consistent if other IME commands are performed before the next recomposition,
+            // and is used for the testing code path.
+            state.inputSession?.let { session ->
+                TextFieldDelegate.onEditCommand(
+                    ops = listOf(DeleteAllCommand(), CommitTextCommand(text, 1)),
+                    editProcessor = state.processor,
+                    state.onValueChange,
+                    session
+                )
+            } ?: run {
+                state.onValueChange(TextFieldValue(text.text, TextRange(text.text.length)))
+            }
             true
         }
-        setSelection { selectionStart, selectionEnd, traversalMode ->
+        setSelection { selectionStart, selectionEnd, relativeToOriginalText ->
             // in traversal mode we get selection from the `textSelectionRange` semantics which is
             // selection in original text. In non-traversal mode selection comes from the Talkback
             // and indices are relative to the transformed text
-            val start = if (traversalMode) {
+            val start = if (relativeToOriginalText) {
                 selectionStart
             } else {
                 offsetMapping.transformedToOriginal(selectionStart)
             }
-            val end = if (traversalMode) {
+            val end = if (relativeToOriginalText) {
                 selectionEnd
             } else {
                 offsetMapping.transformedToOriginal(selectionEnd)
@@ -445,7 +460,7 @@
             ) {
                 // Do not show toolbar if it's a traversal mode (with the volume keys), or
                 // if the cursor just moved to beginning or end.
-                if (traversalMode || start == end) {
+                if (relativeToOriginalText || start == end) {
                     manager.exitSelectionMode()
                 } else {
                     manager.enterSelectionMode()
@@ -956,9 +971,11 @@
         selectionEndInTransformed < textLayoutResult.layoutInput.text.length -> {
             textLayoutResult.getBoundingBox(selectionEndInTransformed)
         }
+
         selectionEndInTransformed != 0 -> {
             textLayoutResult.getBoundingBox(selectionEndInTransformed - 1)
         }
+
         else -> { // empty text.
             val defaultSize = computeSizeForDefaultText(
                 textDelegate.style,
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/TextFieldDelegate.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/TextFieldDelegate.kt
index 71ec490..c93715b 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/TextFieldDelegate.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/TextFieldDelegate.kt
@@ -185,7 +185,7 @@
          * @param onValueChange The callback called when the new editor state arrives.
          */
         @JvmStatic
-        private fun onEditCommand(
+        internal fun onEditCommand(
             ops: List<EditCommand>,
             editProcessor: EditProcessor,
             onValueChange: (TextFieldValue) -> Unit,
diff --git a/compose/integration-tests/docs-snippets/src/main/java/androidx/compose/integration/docs/accessibility/Accessibility.kt b/compose/integration-tests/docs-snippets/src/main/java/androidx/compose/integration/docs/accessibility/Accessibility.kt
deleted file mode 100644
index 8df8bdc..0000000
--- a/compose/integration-tests/docs-snippets/src/main/java/androidx/compose/integration/docs/accessibility/Accessibility.kt
+++ /dev/null
@@ -1,197 +0,0 @@
-/*
- * Copyright 2021 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-// Ignore lint warnings in documentation snippets
-@file:Suppress("unused", "UNUSED_PARAMETER", "UNUSED_VARIABLE")
-
-package androidx.compose.integration.docs.accessibility
-
-import androidx.compose.foundation.Image
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.selection.toggleable
-import androidx.compose.material.Icon
-import androidx.compose.material.IconButton
-import androidx.compose.material.MaterialTheme
-import androidx.compose.material.Text
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.AccountCircle
-import androidx.compose.material.icons.filled.Share
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.clip
-import androidx.compose.ui.graphics.ImageBitmap
-import androidx.compose.ui.graphics.painter.BitmapPainter
-import androidx.compose.ui.res.painterResource
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.semantics.CustomAccessibilityAction
-import androidx.compose.ui.semantics.clearAndSetSemantics
-import androidx.compose.ui.semantics.contentDescription
-import androidx.compose.ui.semantics.customActions
-import androidx.compose.ui.semantics.heading
-import androidx.compose.ui.semantics.semantics
-import androidx.compose.ui.semantics.stateDescription
-import androidx.compose.ui.unit.dp
-
-/**
- * This file lets DevRel track changes to snippets present in
- * https://developer.android.com/jetpack/compose/xxxxxxxxxxxxxxx
- *
- * No action required if it's modified.
- */
-
-private object AccessibilitySnippet1 {
-    @Composable
-    fun ShareButton(onClick: () -> Unit) {
-        IconButton(onClick = onClick) {
-            Icon(
-                imageVector = Icons.Filled.Share,
-                contentDescription = stringResource(R.string.label_share)
-            )
-        }
-    }
-}
-
-private object AccessibilitySnippet2 {
-    @Composable
-    fun PostImage(post: Post, modifier: Modifier = Modifier) {
-        val image = if (post.imageThumb != null) {
-            BitmapPainter(post.imageThumb)
-        } else {
-            painterResource(R.drawable.placeholder)
-        }
-
-        Image(
-            painter = image,
-            // Specify that this image has no semantic meaning
-            contentDescription = null,
-            modifier = modifier
-                .size(40.dp, 40.dp)
-                .clip(MaterialTheme.shapes.small)
-        )
-    }
-}
-
-private object AccessibilitySnippet4 {
-    @Composable
-    private fun PostMetadata(metadata: Metadata) {
-        // Merge elements below for accessibility purposes
-        Row(modifier = Modifier.semantics(mergeDescendants = true) {}) {
-            Image(
-                imageVector = Icons.Filled.AccountCircle,
-                // As this image is decorative, contentDescription is set to null
-                contentDescription = null
-            )
-            Column {
-                Text(metadata.author.name)
-                Text("${metadata.date} • ${metadata.readTimeMinutes} min read")
-            }
-        }
-    }
-}
-
-private object AccessibilitySnippet5 {
-    @Composable
-    fun PostCardSimple(
-        /* ... */
-        isFavorite: Boolean,
-        onToggleFavorite: () -> Boolean
-    ) {
-        val actionLabel = stringResource(
-            if (isFavorite) R.string.unfavorite else R.string.favorite
-        )
-        Row(
-            modifier = Modifier
-                .clickable(onClick = { /* ... */ })
-                .semantics {
-                    // Set any explicit semantic properties
-                    customActions = listOf(
-                        CustomAccessibilityAction(actionLabel, onToggleFavorite)
-                    )
-                }
-        ) {
-            /* ... */
-            BookmarkButton(
-                isBookmarked = isFavorite,
-                onClick = onToggleFavorite,
-                // Clear any semantics properties set on this node
-                modifier = Modifier.clearAndSetSemantics { }
-            )
-        }
-    }
-}
-
-private object AccessibilitySnippet6 {
-    @Composable
-    private fun TopicItem(itemTitle: String, selected: Boolean, onToggle: () -> Unit) {
-        val stateSubscribed = stringResource(R.string.subscribed)
-        val stateNotSubscribed = stringResource(R.string.not_subscribed)
-        Row(
-            modifier = Modifier
-                .semantics {
-                    // Set any explicit semantic properties
-                    stateDescription = if (selected) stateSubscribed else stateNotSubscribed
-                }
-                .toggleable(
-                    value = selected,
-                    onValueChange = { onToggle() }
-                )
-        ) {
-            /* ... */
-        }
-    }
-}
-
-private object AccessibilitySnippet7 {
-    @Composable
-    private fun Subsection(text: String) {
-        Text(
-            text = text,
-            style = MaterialTheme.typography.h5,
-            modifier = Modifier.semantics { heading() }
-        )
-    }
-}
-
-/*
-Fakes needed for snippets to build:
- */
-
-private object R {
-    object string {
-        const val label_share = 4
-        const val unfavorite = 6
-        const val favorite = 7
-        const val subscribed = 8
-        const val not_subscribed = 9
-    }
-
-    object drawable {
-        const val placeholder = 1
-    }
-}
-
-private class Post(val imageThumb: ImageBitmap? = null)
-private class Metadata(
-    val author: Author = Author(),
-    val date: String? = null,
-    val readTimeMinutes: String? = null
-)
-
-private class Author(val name: String = "fake")
-private class BookmarkButton(isBookmarked: Boolean, onClick: () -> Boolean, modifier: Modifier)
diff --git a/compose/material/material/build.gradle b/compose/material/material/build.gradle
index 1d95cf2..1ca97a2 100644
--- a/compose/material/material/build.gradle
+++ b/compose/material/material/build.gradle
@@ -47,8 +47,8 @@
         implementation("androidx.compose.ui:ui-util:1.2.1")
 
         // TODO: remove next 3 dependencies when b/202810604 is fixed
-        implementation("androidx.savedstate:savedstate:1.1.0")
-        implementation("androidx.lifecycle:lifecycle-runtime:2.5.1")
+        implementation("androidx.savedstate:savedstate:1.2.0")
+        implementation("androidx.lifecycle:lifecycle-runtime:2.6.0-rc01")
         implementation("androidx.lifecycle:lifecycle-viewmodel:2.6.0-rc01")
 
         testImplementation(libs.testRules)
@@ -106,9 +106,9 @@
                 api("androidx.annotation:annotation:1.1.0")
 
                 // TODO: remove next 3 dependencies when b/202810604 is fixed
-                implementation("androidx.savedstate:savedstate:1.1.0")
-                implementation("androidx.lifecycle:lifecycle-runtime:2.5.1")
-                implementation("androidx.lifecycle:lifecycle-viewmodel:2.5.1")
+                implementation("androidx.savedstate:savedstate:1.2.0")
+                implementation("androidx.lifecycle:lifecycle-runtime:2.6.0-rc01")
+                implementation("androidx.lifecycle:lifecycle-viewmodel:2.6.0-rc01")
             }
 
             desktopMain.dependencies {
diff --git a/compose/material3/material3/api/current.txt b/compose/material3/material3/api/current.txt
index e220974..191440e 100644
--- a/compose/material3/material3/api/current.txt
+++ b/compose/material3/material3/api/current.txt
@@ -408,19 +408,19 @@
 
   public final class ListItemDefaults {
     method @androidx.compose.runtime.Composable public androidx.compose.material3.ListItemColors colors(optional long containerColor, optional long headlineColor, optional long leadingIconColor, optional long overlineColor, optional long supportingColor, optional long trailingIconColor, optional long disabledHeadlineColor, optional long disabledLeadingIconColor, optional long disabledTrailingIconColor);
-    method @androidx.compose.runtime.Composable public long getContainerColor();
-    method @androidx.compose.runtime.Composable public long getContentColor();
+    method @androidx.compose.runtime.Composable @androidx.compose.runtime.ReadOnlyComposable public long getContainerColor();
+    method @androidx.compose.runtime.Composable @androidx.compose.runtime.ReadOnlyComposable public long getContentColor();
     method public float getElevation();
-    method @androidx.compose.runtime.Composable public androidx.compose.ui.graphics.Shape getShape();
+    method @androidx.compose.runtime.Composable @androidx.compose.runtime.ReadOnlyComposable public androidx.compose.ui.graphics.Shape getShape();
     property public final float Elevation;
-    property @androidx.compose.runtime.Composable public final long containerColor;
-    property @androidx.compose.runtime.Composable public final long contentColor;
-    property @androidx.compose.runtime.Composable public final androidx.compose.ui.graphics.Shape shape;
+    property @androidx.compose.runtime.Composable @androidx.compose.runtime.ReadOnlyComposable public final long containerColor;
+    property @androidx.compose.runtime.Composable @androidx.compose.runtime.ReadOnlyComposable public final long contentColor;
+    property @androidx.compose.runtime.Composable @androidx.compose.runtime.ReadOnlyComposable public final androidx.compose.ui.graphics.Shape shape;
     field public static final androidx.compose.material3.ListItemDefaults INSTANCE;
   }
 
   public final class ListItemKt {
-    method @androidx.compose.runtime.Composable public static void ListItem(kotlin.jvm.functions.Function0<kotlin.Unit> headlineText, optional androidx.compose.ui.Modifier modifier, optional kotlin.jvm.functions.Function0<kotlin.Unit>? overlineText, optional kotlin.jvm.functions.Function0<kotlin.Unit>? supportingText, optional kotlin.jvm.functions.Function0<kotlin.Unit>? leadingContent, optional kotlin.jvm.functions.Function0<kotlin.Unit>? trailingContent, optional androidx.compose.material3.ListItemColors colors, optional float tonalElevation, optional float shadowElevation);
+    method @androidx.compose.runtime.Composable public static void ListItem(kotlin.jvm.functions.Function0<kotlin.Unit> headlineContent, optional androidx.compose.ui.Modifier modifier, optional kotlin.jvm.functions.Function0<kotlin.Unit>? overlineContent, optional kotlin.jvm.functions.Function0<kotlin.Unit>? supportingContent, optional kotlin.jvm.functions.Function0<kotlin.Unit>? leadingContent, optional kotlin.jvm.functions.Function0<kotlin.Unit>? trailingContent, optional androidx.compose.material3.ListItemColors colors, optional float tonalElevation, optional float shadowElevation);
   }
 
   public final class MaterialTheme {
diff --git a/compose/material3/material3/api/public_plus_experimental_current.txt b/compose/material3/material3/api/public_plus_experimental_current.txt
index 4a45aed..872d779 100644
--- a/compose/material3/material3/api/public_plus_experimental_current.txt
+++ b/compose/material3/material3/api/public_plus_experimental_current.txt
@@ -626,19 +626,19 @@
 
   public final class ListItemDefaults {
     method @androidx.compose.runtime.Composable public androidx.compose.material3.ListItemColors colors(optional long containerColor, optional long headlineColor, optional long leadingIconColor, optional long overlineColor, optional long supportingColor, optional long trailingIconColor, optional long disabledHeadlineColor, optional long disabledLeadingIconColor, optional long disabledTrailingIconColor);
-    method @androidx.compose.runtime.Composable public long getContainerColor();
-    method @androidx.compose.runtime.Composable public long getContentColor();
+    method @androidx.compose.runtime.Composable @androidx.compose.runtime.ReadOnlyComposable public long getContainerColor();
+    method @androidx.compose.runtime.Composable @androidx.compose.runtime.ReadOnlyComposable public long getContentColor();
     method public float getElevation();
-    method @androidx.compose.runtime.Composable public androidx.compose.ui.graphics.Shape getShape();
+    method @androidx.compose.runtime.Composable @androidx.compose.runtime.ReadOnlyComposable public androidx.compose.ui.graphics.Shape getShape();
     property public final float Elevation;
-    property @androidx.compose.runtime.Composable public final long containerColor;
-    property @androidx.compose.runtime.Composable public final long contentColor;
-    property @androidx.compose.runtime.Composable public final androidx.compose.ui.graphics.Shape shape;
+    property @androidx.compose.runtime.Composable @androidx.compose.runtime.ReadOnlyComposable public final long containerColor;
+    property @androidx.compose.runtime.Composable @androidx.compose.runtime.ReadOnlyComposable public final long contentColor;
+    property @androidx.compose.runtime.Composable @androidx.compose.runtime.ReadOnlyComposable public final androidx.compose.ui.graphics.Shape shape;
     field public static final androidx.compose.material3.ListItemDefaults INSTANCE;
   }
 
   public final class ListItemKt {
-    method @androidx.compose.runtime.Composable public static void ListItem(kotlin.jvm.functions.Function0<kotlin.Unit> headlineText, optional androidx.compose.ui.Modifier modifier, optional kotlin.jvm.functions.Function0<kotlin.Unit>? overlineText, optional kotlin.jvm.functions.Function0<kotlin.Unit>? supportingText, optional kotlin.jvm.functions.Function0<kotlin.Unit>? leadingContent, optional kotlin.jvm.functions.Function0<kotlin.Unit>? trailingContent, optional androidx.compose.material3.ListItemColors colors, optional float tonalElevation, optional float shadowElevation);
+    method @androidx.compose.runtime.Composable public static void ListItem(kotlin.jvm.functions.Function0<kotlin.Unit> headlineContent, optional androidx.compose.ui.Modifier modifier, optional kotlin.jvm.functions.Function0<kotlin.Unit>? overlineContent, optional kotlin.jvm.functions.Function0<kotlin.Unit>? supportingContent, optional kotlin.jvm.functions.Function0<kotlin.Unit>? leadingContent, optional kotlin.jvm.functions.Function0<kotlin.Unit>? trailingContent, optional androidx.compose.material3.ListItemColors colors, optional float tonalElevation, optional float shadowElevation);
   }
 
   public final class MaterialTheme {
diff --git a/compose/material3/material3/api/restricted_current.txt b/compose/material3/material3/api/restricted_current.txt
index e220974..191440e 100644
--- a/compose/material3/material3/api/restricted_current.txt
+++ b/compose/material3/material3/api/restricted_current.txt
@@ -408,19 +408,19 @@
 
   public final class ListItemDefaults {
     method @androidx.compose.runtime.Composable public androidx.compose.material3.ListItemColors colors(optional long containerColor, optional long headlineColor, optional long leadingIconColor, optional long overlineColor, optional long supportingColor, optional long trailingIconColor, optional long disabledHeadlineColor, optional long disabledLeadingIconColor, optional long disabledTrailingIconColor);
-    method @androidx.compose.runtime.Composable public long getContainerColor();
-    method @androidx.compose.runtime.Composable public long getContentColor();
+    method @androidx.compose.runtime.Composable @androidx.compose.runtime.ReadOnlyComposable public long getContainerColor();
+    method @androidx.compose.runtime.Composable @androidx.compose.runtime.ReadOnlyComposable public long getContentColor();
     method public float getElevation();
-    method @androidx.compose.runtime.Composable public androidx.compose.ui.graphics.Shape getShape();
+    method @androidx.compose.runtime.Composable @androidx.compose.runtime.ReadOnlyComposable public androidx.compose.ui.graphics.Shape getShape();
     property public final float Elevation;
-    property @androidx.compose.runtime.Composable public final long containerColor;
-    property @androidx.compose.runtime.Composable public final long contentColor;
-    property @androidx.compose.runtime.Composable public final androidx.compose.ui.graphics.Shape shape;
+    property @androidx.compose.runtime.Composable @androidx.compose.runtime.ReadOnlyComposable public final long containerColor;
+    property @androidx.compose.runtime.Composable @androidx.compose.runtime.ReadOnlyComposable public final long contentColor;
+    property @androidx.compose.runtime.Composable @androidx.compose.runtime.ReadOnlyComposable public final androidx.compose.ui.graphics.Shape shape;
     field public static final androidx.compose.material3.ListItemDefaults INSTANCE;
   }
 
   public final class ListItemKt {
-    method @androidx.compose.runtime.Composable public static void ListItem(kotlin.jvm.functions.Function0<kotlin.Unit> headlineText, optional androidx.compose.ui.Modifier modifier, optional kotlin.jvm.functions.Function0<kotlin.Unit>? overlineText, optional kotlin.jvm.functions.Function0<kotlin.Unit>? supportingText, optional kotlin.jvm.functions.Function0<kotlin.Unit>? leadingContent, optional kotlin.jvm.functions.Function0<kotlin.Unit>? trailingContent, optional androidx.compose.material3.ListItemColors colors, optional float tonalElevation, optional float shadowElevation);
+    method @androidx.compose.runtime.Composable public static void ListItem(kotlin.jvm.functions.Function0<kotlin.Unit> headlineContent, optional androidx.compose.ui.Modifier modifier, optional kotlin.jvm.functions.Function0<kotlin.Unit>? overlineContent, optional kotlin.jvm.functions.Function0<kotlin.Unit>? supportingContent, optional kotlin.jvm.functions.Function0<kotlin.Unit>? leadingContent, optional kotlin.jvm.functions.Function0<kotlin.Unit>? trailingContent, optional androidx.compose.material3.ListItemColors colors, optional float tonalElevation, optional float shadowElevation);
   }
 
   public final class MaterialTheme {
diff --git a/compose/material3/material3/build.gradle b/compose/material3/material3/build.gradle
index 7090d9c..f4188d4 100644
--- a/compose/material3/material3/build.gradle
+++ b/compose/material3/material3/build.gradle
@@ -50,7 +50,7 @@
 
         // TODO: remove next 3 dependencies when b/202810604 is fixed
         implementation("androidx.savedstate:savedstate-ktx:1.2.0")
-        implementation("androidx.lifecycle:lifecycle-runtime:2.5.1")
+        implementation("androidx.lifecycle:lifecycle-runtime:2.6.0-rc01")
         implementation("androidx.lifecycle:lifecycle-viewmodel:2.6.0-rc01")
 
         testImplementation(libs.testRules)
@@ -107,9 +107,9 @@
                 implementation("androidx.activity:activity-compose:1.5.0")
 
                 // TODO: remove next 3 dependencies when b/202810604 is fixed
-                implementation("androidx.savedstate:savedstate:1.1.0")
-                implementation("androidx.lifecycle:lifecycle-runtime:2.5.1")
-                implementation("androidx.lifecycle:lifecycle-viewmodel:2.5.1")
+                implementation("androidx.savedstate:savedstate-ktx:1.2.0")
+                implementation("androidx.lifecycle:lifecycle-runtime:2.6.0-rc01")
+                implementation("androidx.lifecycle:lifecycle-viewmodel:2.6.0-rc01")
             }
 
             desktopMain.dependencies {
diff --git a/compose/material3/material3/integration-tests/material3-demos/src/main/java/androidx/compose/material3/demos/SwipeToDismissDemo.kt b/compose/material3/material3/integration-tests/material3-demos/src/main/java/androidx/compose/material3/demos/SwipeToDismissDemo.kt
index 69eff5a..0cd93c7 100644
--- a/compose/material3/material3/integration-tests/material3-demos/src/main/java/androidx/compose/material3/demos/SwipeToDismissDemo.kt
+++ b/compose/material3/material3/integration-tests/material3-demos/src/main/java/androidx/compose/material3/demos/SwipeToDismissDemo.kt
@@ -139,7 +139,7 @@
                     dismissContent = {
                         Card {
                             ListItem(
-                                headlineText = {
+                                headlineContent = {
                                     Text(item, fontWeight = if (unread) FontWeight.Bold else null)
                                 },
                                 modifier = Modifier.semantics {
@@ -155,7 +155,7 @@
                                         }
                                     )
                                 },
-                                supportingText = { Text("Swipe me left or right!") },
+                                supportingContent = { Text("Swipe me left or right!") },
                             )
                         }
                     }
diff --git a/compose/material3/material3/samples/src/main/java/androidx/compose/material3/samples/BottomSheetSamples.kt b/compose/material3/material3/samples/src/main/java/androidx/compose/material3/samples/BottomSheetSamples.kt
index ba4e86b..e7a4944 100644
--- a/compose/material3/material3/samples/src/main/java/androidx/compose/material3/samples/BottomSheetSamples.kt
+++ b/compose/material3/material3/samples/src/main/java/androidx/compose/material3/samples/BottomSheetSamples.kt
@@ -106,7 +106,7 @@
             LazyColumn {
                 items(50) {
                     ListItem(
-                        headlineText = { Text("Item $it") },
+                        headlineContent = { Text("Item $it") },
                         leadingContent = {
                             Icon(
                                 Icons.Default.Favorite,
diff --git a/compose/material3/material3/samples/src/main/java/androidx/compose/material3/samples/ListSamples.kt b/compose/material3/material3/samples/src/main/java/androidx/compose/material3/samples/ListSamples.kt
index 94dede9..5355796 100644
--- a/compose/material3/material3/samples/src/main/java/androidx/compose/material3/samples/ListSamples.kt
+++ b/compose/material3/material3/samples/src/main/java/androidx/compose/material3/samples/ListSamples.kt
@@ -35,7 +35,7 @@
 fun OneLineListItem() {
     Column {
         ListItem(
-            headlineText = { Text("One line list item with 24x24 icon") },
+            headlineContent = { Text("One line list item with 24x24 icon") },
             leadingContent = {
                 Icon(
                     Icons.Filled.Favorite,
@@ -54,8 +54,8 @@
 fun TwoLineListItem() {
     Column {
         ListItem(
-            headlineText = { Text("Two line list item with trailing") },
-            supportingText = { Text("Secondary text") },
+            headlineContent = { Text("Two line list item with trailing") },
+            supportingContent = { Text("Secondary text") },
             trailingContent = { Text("meta") },
             leadingContent = {
                 Icon(
@@ -75,9 +75,9 @@
 fun ThreeLineListItem() {
     Column {
         ListItem(
-            headlineText = { Text("Three line list item") },
-            overlineText = { Text("OVERLINE") },
-            supportingText = { Text("Secondary text") },
+            headlineContent = { Text("Three line list item") },
+            overlineContent = { Text("OVERLINE") },
+            supportingContent = { Text("Secondary text") },
             leadingContent = {
                 Icon(
                     Icons.Filled.Favorite,
diff --git a/compose/material3/material3/samples/src/main/java/androidx/compose/material3/samples/SearchBarSamples.kt b/compose/material3/material3/samples/src/main/java/androidx/compose/material3/samples/SearchBarSamples.kt
index 174afb1..a202a70 100644
--- a/compose/material3/material3/samples/src/main/java/androidx/compose/material3/samples/SearchBarSamples.kt
+++ b/compose/material3/material3/samples/src/main/java/androidx/compose/material3/samples/SearchBarSamples.kt
@@ -90,8 +90,8 @@
                     items(4) { idx ->
                         val resultText = "Suggestion $idx"
                         ListItem(
-                            headlineText = { Text(resultText) },
-                            supportingText = { Text("Additional info") },
+                            headlineContent = { Text(resultText) },
+                            supportingContent = { Text("Additional info") },
                             leadingContent = { Icon(Icons.Filled.Star, contentDescription = null) },
                             modifier = Modifier.clickable {
                                 text = resultText
@@ -156,8 +156,8 @@
                     items(4) { idx ->
                         val resultText = "Suggestion $idx"
                         ListItem(
-                            headlineText = { Text(resultText) },
-                            supportingText = { Text("Additional info") },
+                            headlineContent = { Text(resultText) },
+                            supportingContent = { Text("Additional info") },
                             leadingContent = { Icon(Icons.Filled.Star, contentDescription = null) },
                             modifier = Modifier.clickable {
                                 text = resultText
diff --git a/compose/material3/material3/samples/src/main/java/androidx/compose/material3/samples/SwipeToDismissSamples.kt b/compose/material3/material3/samples/src/main/java/androidx/compose/material3/samples/SwipeToDismissSamples.kt
index 2f358a2..c36e41b 100644
--- a/compose/material3/material3/samples/src/main/java/androidx/compose/material3/samples/SwipeToDismissSamples.kt
+++ b/compose/material3/material3/samples/src/main/java/androidx/compose/material3/samples/SwipeToDismissSamples.kt
@@ -58,10 +58,10 @@
         dismissContent = {
             Card {
                 ListItem(
-                    headlineText = {
+                    headlineContent = {
                         Text("Cupcake")
                     },
-                    supportingText = { Text("Swipe me left or right!") }
+                    supportingContent = { Text("Swipe me left or right!") }
                 )
                 Divider()
             }
diff --git a/compose/material3/material3/samples/src/main/java/androidx/compose/material3/samples/TimePickerSamples.kt b/compose/material3/material3/samples/src/main/java/androidx/compose/material3/samples/TimePickerSamples.kt
index 8beec0a..42ecb00 100644
--- a/compose/material3/material3/samples/src/main/java/androidx/compose/material3/samples/TimePickerSamples.kt
+++ b/compose/material3/material3/samples/src/main/java/androidx/compose/material3/samples/TimePickerSamples.kt
@@ -20,6 +20,7 @@
 import androidx.compose.foundation.background
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.IntrinsicSize
 import androidx.compose.foundation.layout.Row
 import androidx.compose.foundation.layout.Spacer
 import androidx.compose.foundation.layout.fillMaxWidth
@@ -129,7 +130,6 @@
     }
 }
 
-@OptIn(ExperimentalMaterial3Api::class)
 @Composable
 fun TimePickerDialog(
     onCancel: () -> Unit,
@@ -144,7 +144,7 @@
             shape = MaterialTheme.shapes.extraLarge,
             tonalElevation = 6.dp,
             modifier = Modifier
-                .width(328.dp)
+                .width(IntrinsicSize.Min)
                 .background(
                     shape = MaterialTheme.shapes.extraLarge,
                     color = MaterialTheme.colorScheme.surface
diff --git a/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/DateRangeInputTest.kt b/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/DateRangeInputTest.kt
index aa623b5..3761870 100644
--- a/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/DateRangeInputTest.kt
+++ b/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/DateRangeInputTest.kt
@@ -20,11 +20,10 @@
 import androidx.compose.ui.test.SemanticsMatcher.Companion.expectValue
 import androidx.compose.ui.test.SemanticsMatcher.Companion.keyIsDefined
 import androidx.compose.ui.test.assert
+import androidx.compose.ui.test.assertCountEquals
 import androidx.compose.ui.test.assertIsDisplayed
 import androidx.compose.ui.test.junit4.createComposeRule
 import androidx.compose.ui.test.onAllNodesWithText
-import androidx.compose.ui.test.onFirst
-import androidx.compose.ui.test.onLast
 import androidx.compose.ui.test.onNodeWithContentDescription
 import androidx.compose.ui.test.onNodeWithText
 import androidx.compose.ui.test.performClick
@@ -49,14 +48,12 @@
 
     @Test
     fun dateRangeInput() {
-        lateinit var dateRangeInputLabel: String
         lateinit var state: DateRangePickerState
         lateinit var pickerStartDateHeadline: String
         lateinit var pickerEndDateHeadline: String
         rule.setMaterialContent(lightColorScheme()) {
             pickerStartDateHeadline = getString(string = Strings.DateRangePickerStartHeadline)
             pickerEndDateHeadline = getString(string = Strings.DateRangePickerEndHeadline)
-            dateRangeInputLabel = getString(string = Strings.DateInputLabel)
             val monthInUtcMillis = dayInUtcMilliseconds(year = 2019, month = 1, dayOfMonth = 1)
             state = rememberDateRangePickerState(
                 initialDisplayedMonthMillis = monthInUtcMillis,
@@ -65,14 +62,15 @@
             DateRangePicker(state = state)
         }
 
-        rule.onNodeWithText(pickerStartDateHeadline, useUnmergedTree = true).assertExists()
-        rule.onNodeWithText(pickerEndDateHeadline, useUnmergedTree = true).assertExists()
+        // Expecting 2 nodes with the text "Start date", and 2 with "End date".
+        rule.onAllNodesWithText(pickerStartDateHeadline, useUnmergedTree = true)
+            .assertCountEquals(2)
+        rule.onAllNodesWithText(pickerEndDateHeadline, useUnmergedTree = true)
+            .assertCountEquals(2)
 
         // Enter dates.
-        rule.onAllNodesWithText(dateRangeInputLabel).onFirst().performClick()
-            .performTextInput("01272019")
-        rule.onAllNodesWithText(dateRangeInputLabel).onLast().performClick()
-            .performTextInput("05102020")
+        rule.onNodeWithText(pickerStartDateHeadline).performClick().performTextInput("01272019")
+        rule.onNodeWithText(pickerEndDateHeadline).performClick().performTextInput("05102020")
 
         rule.runOnIdle {
             assertThat(state.selectedStartDateMillis).isEqualTo(
@@ -91,8 +89,11 @@
             )
         }
 
-        rule.onNodeWithText(pickerStartDateHeadline, useUnmergedTree = true).assertDoesNotExist()
-        rule.onNodeWithText(pickerEndDateHeadline, useUnmergedTree = true).assertDoesNotExist()
+        // Now expecting only one node with "Start date", and one with "End date".
+        rule.onAllNodesWithText(pickerStartDateHeadline, useUnmergedTree = true)
+            .assertCountEquals(1)
+        rule.onAllNodesWithText(pickerEndDateHeadline, useUnmergedTree = true)
+            .assertCountEquals(1)
         rule.onNodeWithText("Jan 27, 2019", useUnmergedTree = true).assertExists()
         rule.onNodeWithText("May 10, 2020", useUnmergedTree = true).assertExists()
     }
@@ -121,11 +122,13 @@
 
     @Test
     fun inputDateNotAllowed() {
-        lateinit var dateRangeInputLabel: String
+        lateinit var startDateRangeInputLabel: String
+        lateinit var endDateRangeInputLabel: String
         lateinit var errorMessage: String
         lateinit var state: DateRangePickerState
         rule.setMaterialContent(lightColorScheme()) {
-            dateRangeInputLabel = getString(string = Strings.DateInputLabel)
+            startDateRangeInputLabel = getString(string = Strings.DateRangePickerStartHeadline)
+            endDateRangeInputLabel = getString(string = Strings.DateRangePickerEndHeadline)
             errorMessage = getString(string = Strings.DateInputInvalidNotAllowed)
             state = rememberDateRangePickerState(initialDisplayMode = DisplayMode.Input)
             DateRangePicker(state = state,
@@ -135,10 +138,8 @@
         }
 
         // Enter dates.
-        rule.onAllNodesWithText(dateRangeInputLabel).onFirst().performClick()
-            .performTextInput("01272019")
-        rule.onAllNodesWithText(dateRangeInputLabel).onLast().performClick()
-            .performTextInput("05102020")
+        rule.onNodeWithText(startDateRangeInputLabel).performClick().performTextInput("01272019")
+        rule.onNodeWithText(endDateRangeInputLabel).performClick().performTextInput("05102020")
 
         rule.runOnIdle {
             assertThat(state.selectedStartDateMillis).isNull()
@@ -164,11 +165,13 @@
 
     @Test
     fun outOfOrderDateRange() {
-        lateinit var dateRangeInputLabel: String
+        lateinit var startDateRangeInputLabel: String
+        lateinit var endDateRangeInputLabel: String
         lateinit var errorMessage: String
         lateinit var state: DateRangePickerState
         rule.setMaterialContent(lightColorScheme()) {
-            dateRangeInputLabel = getString(string = Strings.DateInputLabel)
+            startDateRangeInputLabel = getString(string = Strings.DateRangePickerStartHeadline)
+            endDateRangeInputLabel = getString(string = Strings.DateRangePickerEndHeadline)
             errorMessage = getString(string = Strings.DateRangeInputInvalidRangeInput)
             state = rememberDateRangePickerState(
                 // Limit the years selection to 2018-2023
@@ -179,17 +182,15 @@
         }
 
         // Enter dates where the start date is later than the end date.
-        rule.onAllNodesWithText(dateRangeInputLabel).onFirst().performClick()
-            .performTextInput("01272020")
-        rule.onAllNodesWithText(dateRangeInputLabel).onLast().performClick()
-            .performTextInput("05102019")
+        rule.onNodeWithText(startDateRangeInputLabel).performClick().performTextInput("01272020")
+        rule.onNodeWithText(endDateRangeInputLabel).performClick().performTextInput("05102019")
 
         rule.runOnIdle {
             // Expecting the first stored date to still be valid, and the second one to be null.
             assertThat(state.selectedStartDateMillis).isNotNull()
             assertThat(state.selectedEndDateMillis).isNull()
         }
-        rule.onNodeWithText("05/10/2019")
+        rule.onNodeWithText("05/10/2019", useUnmergedTree = true)
             .assert(keyIsDefined(SemanticsProperties.Error))
             .assert(expectValue(SemanticsProperties.Error, errorMessage))
     }
@@ -197,12 +198,14 @@
     @Test
     fun switchToDateRangePicker() {
         lateinit var switchToPickerDescription: String
-        lateinit var dateRangeInputLabel: String
+        lateinit var startDateRangeInputLabel: String
+        lateinit var endDateRangeInputLabel: String
         lateinit var pickerStartDateHeadline: String
         lateinit var pickerEndDateHeadline: String
         rule.setMaterialContent(lightColorScheme()) {
             switchToPickerDescription = getString(string = Strings.DatePickerSwitchToCalendarMode)
-            dateRangeInputLabel = getString(string = Strings.DateInputLabel)
+            startDateRangeInputLabel = getString(string = Strings.DateRangePickerStartHeadline)
+            endDateRangeInputLabel = getString(string = Strings.DateRangePickerEndHeadline)
             pickerStartDateHeadline = getString(string = Strings.DateRangePickerStartHeadline)
             pickerEndDateHeadline = getString(string = Strings.DateRangePickerEndHeadline)
             DateRangePicker(
@@ -216,7 +219,8 @@
         rule.waitForIdle()
         rule.onNodeWithText(pickerStartDateHeadline, useUnmergedTree = true).assertIsDisplayed()
         rule.onNodeWithText(pickerEndDateHeadline, useUnmergedTree = true).assertIsDisplayed()
-        rule.onNodeWithText(dateRangeInputLabel).assertDoesNotExist()
+        rule.onNodeWithText(startDateRangeInputLabel).assertDoesNotExist()
+        rule.onNodeWithText(endDateRangeInputLabel).assertDoesNotExist()
     }
 
     @Test
diff --git a/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/ListItemScreenshotTest.kt b/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/ListItemScreenshotTest.kt
index 3f65222..febb1e6 100644
--- a/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/ListItemScreenshotTest.kt
+++ b/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/ListItemScreenshotTest.kt
@@ -52,7 +52,7 @@
         composeTestRule.setMaterialContent(lightColorScheme()) {
             Column(Modifier.testTag(Tag)) {
                 ListItem(
-                    headlineText = { Text("One line list item with 24x24 icon") },
+                    headlineContent = { Text("One line list item with 24x24 icon") },
                     leadingContent = {
                         Icon(
                             Icons.Filled.Favorite,
@@ -73,10 +73,10 @@
     fun oneLine_lightTheme() {
         composeTestRule.setMaterialContent(lightColorScheme()) {
             Column(Modifier.testTag(Tag)) {
-                ListItem(headlineText = { Text("One line list item with no icon") })
+                ListItem(headlineContent = { Text("One line list item with no icon") })
                 Divider()
                 ListItem(
-                    headlineText = { Text("One line list item with 24x24 icon") },
+                    headlineContent = { Text("One line list item with 24x24 icon") },
                     leadingContent = {
                         Icon(
                             Icons.Filled.Favorite,
@@ -96,10 +96,10 @@
     fun oneLine_darkTheme() {
         composeTestRule.setMaterialContent(darkColorScheme()) {
             Column(Modifier.testTag(Tag)) {
-                ListItem(headlineText = { Text("One line list item with no icon") })
+                ListItem(headlineContent = { Text("One line list item with no icon") })
                 Divider()
                 ListItem(
-                    headlineText = { Text("One line list item with 24x24 icon") },
+                    headlineContent = { Text("One line list item with 24x24 icon") },
                     leadingContent = {
                         Icon(
                             Icons.Filled.Favorite,
@@ -120,18 +120,18 @@
         composeTestRule.setMaterialContent(lightColorScheme()) {
             Column(Modifier.testTag(Tag)) {
                 ListItem(
-                    headlineText = { Text("Two line list item") },
-                    supportingText = { Text("Secondary text") }
+                    headlineContent = { Text("Two line list item") },
+                    supportingContent = { Text("Secondary text") }
                 )
                 Divider()
                 ListItem(
-                    headlineText = { Text("Two line list item") },
-                    overlineText = { Text("OVERLINE") }
+                    headlineContent = { Text("Two line list item") },
+                    overlineContent = { Text("OVERLINE") }
                 )
                 Divider()
                 ListItem(
-                    headlineText = { Text("Two line list item with 24x24 icon") },
-                    supportingText = { Text("Secondary text") },
+                    headlineContent = { Text("Two line list item with 24x24 icon") },
+                    supportingContent = { Text("Secondary text") },
                     leadingContent = {
                         Icon(
                             Icons.Filled.Favorite,
@@ -152,18 +152,18 @@
         composeTestRule.setMaterialContent(darkColorScheme()) {
             Column(Modifier.testTag(Tag)) {
                 ListItem(
-                    headlineText = { Text("Two line list item") },
-                    supportingText = { Text("Secondary text") }
+                    headlineContent = { Text("Two line list item") },
+                    supportingContent = { Text("Secondary text") }
                 )
                 Divider()
                 ListItem(
-                    headlineText = { Text("Two line list item") },
-                    overlineText = { Text("OVERLINE") }
+                    headlineContent = { Text("Two line list item") },
+                    overlineContent = { Text("OVERLINE") }
                 )
                 Divider()
                 ListItem(
-                    headlineText = { Text("Two line list item with 24x24 icon") },
-                    supportingText = { Text("Secondary text") },
+                    headlineContent = { Text("Two line list item with 24x24 icon") },
+                    supportingContent = { Text("Secondary text") },
                     leadingContent = {
                         Icon(
                             Icons.Filled.Favorite,
@@ -184,22 +184,22 @@
         composeTestRule.setMaterialContent(lightColorScheme()) {
             Column(Modifier.testTag(Tag)) {
                 ListItem(
-                    headlineText = { Text("Three line list item") },
-                    overlineText = { Text("OVERLINE") },
-                    supportingText = { Text("Secondary text") },
+                    headlineContent = { Text("Three line list item") },
+                    overlineContent = { Text("OVERLINE") },
+                    supportingContent = { Text("Secondary text") },
                     trailingContent = { Text("meta") }
                 )
                 Divider()
                 ListItem(
-                    headlineText = { Text("Three line list item") },
-                    overlineText = { Text("OVERLINE") },
-                    supportingText = { Text("Secondary text") }
+                    headlineContent = { Text("Three line list item") },
+                    overlineContent = { Text("OVERLINE") },
+                    supportingContent = { Text("Secondary text") }
                 )
                 Divider()
                 ListItem(
-                    headlineText = { Text("Three line list item") },
-                    overlineText = { Text("OVERLINE") },
-                    supportingText = { Text("Secondary text") },
+                    headlineContent = { Text("Three line list item") },
+                    overlineContent = { Text("OVERLINE") },
+                    supportingContent = { Text("Secondary text") },
                     leadingContent = {
                         Icon(
                             Icons.Filled.Favorite,
@@ -220,22 +220,22 @@
         composeTestRule.setMaterialContent(darkColorScheme()) {
             Column(Modifier.testTag(Tag)) {
                 ListItem(
-                    headlineText = { Text("Three line list item") },
-                    overlineText = { Text("OVERLINE") },
-                    supportingText = { Text("Secondary text") },
+                    headlineContent = { Text("Three line list item") },
+                    overlineContent = { Text("OVERLINE") },
+                    supportingContent = { Text("Secondary text") },
                     trailingContent = { Text("meta") }
                 )
                 Divider()
                 ListItem(
-                    headlineText = { Text("Three line list item") },
-                    overlineText = { Text("OVERLINE") },
-                    supportingText = { Text("Secondary text") }
+                    headlineContent = { Text("Three line list item") },
+                    overlineContent = { Text("OVERLINE") },
+                    supportingContent = { Text("Secondary text") }
                 )
                 Divider()
                 ListItem(
-                    headlineText = { Text("Three line list item") },
-                    overlineText = { Text("OVERLINE") },
-                    supportingText = { Text("Secondary text") },
+                    headlineContent = { Text("Three line list item") },
+                    overlineContent = { Text("OVERLINE") },
+                    supportingContent = { Text("Secondary text") },
                     leadingContent = {
                         Icon(
                             Icons.Filled.Favorite,
diff --git a/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/ListItemTest.kt b/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/ListItemTest.kt
index 996a9b7..b80c1c4 100644
--- a/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/ListItemTest.kt
+++ b/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/ListItemTest.kt
@@ -45,7 +45,6 @@
 
 @MediumTest
 @RunWith(AndroidJUnit4::class)
-@OptIn(ExperimentalMaterial3Api::class)
 class ListItemTest {
 
     @get:Rule
@@ -56,10 +55,10 @@
 
     @Test
     fun listItem_oneLine_size() {
-        val expectedHeightNoIcon = ListTokens.ListItemContainerHeight
+        val expectedHeightNoIcon = ListTokens.ListItemOneLineContainerHeight
         rule
             .setMaterialContentForSizeAssertions {
-                ListItem(headlineText = { Text("Primary text") })
+                ListItem(headlineContent = { Text("Primary text") })
             }
             .assertHeightIsEqualTo(expectedHeightNoIcon)
             .assertWidthIsEqualTo(rule.rootWidth())
@@ -67,11 +66,11 @@
 
     @Test
     fun listItem_oneLine_withIcon_size() {
-        val expectedHeightSmallIcon = ListTokens.ListItemContainerHeight
+        val expectedHeightSmallIcon = ListTokens.ListItemOneLineContainerHeight
         rule
             .setMaterialContentForSizeAssertions {
                 ListItem(
-                    headlineText = { Text("Primary text") },
+                    headlineContent = { Text("Primary text") },
                     leadingContent = { Icon(icon24x24, null) }
                 )
             }
@@ -81,12 +80,12 @@
 
     @Test
     fun listItem_twoLine_size() {
-        val expectedHeightNoIcon = 72.dp
+        val expectedHeightNoIcon = ListTokens.ListItemTwoLineContainerHeight
         rule
             .setMaterialContentForSizeAssertions {
                 ListItem(
-                    headlineText = { Text("Primary text") },
-                    supportingText = { Text("Secondary text") }
+                    headlineContent = { Text("Primary text") },
+                    supportingContent = { Text("Secondary text") }
                 )
             }
             .assertHeightIsEqualTo(expectedHeightNoIcon)
@@ -95,13 +94,13 @@
 
     @Test
     fun listItem_twoLine_withIcon_size() {
-        val expectedHeightWithIcon = 72.dp
+        val expectedHeightWithIcon = ListTokens.ListItemTwoLineContainerHeight
 
         rule
             .setMaterialContentForSizeAssertions {
                 ListItem(
-                    headlineText = { Text("Primary text") },
-                    supportingText = { Text("Secondary text") },
+                    headlineContent = { Text("Primary text") },
+                    supportingContent = { Text("Secondary text") },
                     leadingContent = { Icon(icon24x24, null) }
                 )
             }
@@ -111,13 +110,13 @@
 
     @Test
     fun listItem_threeLine_size() {
-        val expectedHeight = 88.dp
+        val expectedHeight = ListTokens.ListItemThreeLineContainerHeight
         rule
             .setMaterialContentForSizeAssertions {
                 ListItem(
-                    overlineText = { Text("OVERLINE") },
-                    headlineText = { Text("Primary text") },
-                    supportingText = { Text("Secondary text") }
+                    overlineContent = { Text("OVERLINE") },
+                    headlineContent = { Text("Primary text") },
+                    supportingContent = { Text("Secondary text") }
                 )
             }
             .assertHeightIsEqualTo(expectedHeight)
@@ -126,7 +125,7 @@
 
     @Test
     fun listItem_oneLine_positioning_noIcon() {
-        val listItemHeight = ListTokens.ListItemContainerHeight
+        val listItemHeight = ListTokens.ListItemOneLineContainerHeight
         val expectedStartPadding = 16.dp
         val expectedEndPadding = 24.dp
 
@@ -138,7 +137,7 @@
         rule.setMaterialContent(lightColorScheme()) {
             Box {
                 ListItem(
-                    headlineText = {
+                    headlineContent = {
                         Text("Primary text", Modifier.saveLayout(textPosition, textSize))
                     },
                     trailingContent = {
@@ -173,7 +172,7 @@
 
     @Test
     fun listItem_oneLine_positioning_withIcon() {
-        val listItemHeight = ListTokens.ListItemContainerHeight
+        val listItemHeight = ListTokens.ListItemOneLineContainerHeight
         val expectedStartPadding = 16.dp
         val expectedTextStartPadding = 16.dp
 
@@ -184,7 +183,7 @@
         rule.setMaterialContent(lightColorScheme()) {
             Box {
                 ListItem(
-                    headlineText = {
+                    headlineContent = {
                         Text("Primary text", Modifier.saveLayout(textPosition, textSize))
                     },
                     leadingContent = {
@@ -231,13 +230,13 @@
         rule.setMaterialContent(lightColorScheme()) {
             Box {
                 ListItem(
-                    headlineText = {
+                    headlineContent = {
                         Text(
                             "Primary text",
                             Modifier.saveLayout(textPosition, textSize, textBaseline)
                         )
                     },
-                    supportingText = {
+                    supportingContent = {
                         Text(
                             "Secondary text",
                             Modifier.saveLayout(
@@ -287,13 +286,13 @@
         rule.setMaterialContent(lightColorScheme()) {
             Box {
                 ListItem(
-                    headlineText = {
+                    headlineContent = {
                         Text(
                             "Primary text",
                             Modifier.saveLayout(textPosition, textSize, textBaseline)
                         )
                     },
-                    supportingText = {
+                    supportingContent = {
                         Text(
                             "Secondary text",
                             Modifier.saveLayout(
@@ -344,13 +343,13 @@
         rule.setMaterialContent(lightColorScheme()) {
             Box {
                 ListItem(
-                    headlineText = {
+                    headlineContent = {
                         Text(
                             "Primary text",
                             Modifier.saveLayout(textPosition, textSize, textBaseline)
                         )
                     },
-                    supportingText = {
+                    supportingContent = {
                         Text(
                             "Very long supporting text which will span two lines",
                             Modifier.saveLayout(
@@ -411,7 +410,7 @@
         rule.setMaterialContent(lightColorScheme()) {
             Box {
                 ListItem(
-                    overlineText = {
+                    overlineContent = {
                         Text(
                             "OVERLINE",
                             Modifier.saveLayout(
@@ -421,13 +420,13 @@
                             )
                         )
                     },
-                    headlineText = {
+                    headlineContent = {
                         Text(
                             "Primary text",
                             Modifier.saveLayout(textPosition, textSize, textBaseline)
                         )
                     },
-                    supportingText = {
+                    supportingContent = {
                         Text(
                             "Secondary text",
                             Modifier.saveLayout(
diff --git a/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/ModalBottomSheetTest.kt b/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/ModalBottomSheetTest.kt
index ed5213e..1d10ba0 100644
--- a/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/ModalBottomSheetTest.kt
+++ b/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/ModalBottomSheetTest.kt
@@ -39,6 +39,11 @@
 import androidx.compose.runtime.rememberCoroutineScope
 import androidx.compose.runtime.setValue
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
+import androidx.compose.ui.input.nestedscroll.NestedScrollDispatcher
+import androidx.compose.ui.input.nestedscroll.NestedScrollSource
+import androidx.compose.ui.input.nestedscroll.nestedScroll
 import androidx.compose.ui.layout.onSizeChanged
 import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.platform.LocalDensity
@@ -61,6 +66,7 @@
 import androidx.compose.ui.test.performTouchInput
 import androidx.compose.ui.test.swipeDown
 import androidx.compose.ui.test.swipeUp
+import androidx.compose.ui.unit.Velocity
 import androidx.compose.ui.unit.coerceAtMost
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.height
@@ -352,7 +358,7 @@
                 scope = rememberCoroutineScope()
                 LazyColumn {
                     items(amountOfItems) {
-                        ListItem(headlineText = { Text("$it") })
+                        ListItem(headlineContent = { Text("$it") })
                     }
                 }
             }
@@ -845,4 +851,51 @@
         rule.waitForIdle()
         assertThat(sheetState.currentValue).isEqualTo(SheetValue.Collapsed)
     }
+
+    @Test
+    fun modalBottomSheet_callsOnDismissRequest_onNestedScrollFling() {
+        var callCount by mutableStateOf(0)
+        val expectedCallCount = 1
+        val sheetState = SheetState(skipCollapsed = true)
+
+        val nestedScrollDispatcher = NestedScrollDispatcher()
+        val nestedScrollConnection = object : NestedScrollConnection {
+            // No-Op
+        }
+        lateinit var scope: CoroutineScope
+
+        rule.setContent {
+            scope = rememberCoroutineScope()
+            ModalBottomSheet(onDismissRequest = { callCount += 1 }, sheetState = sheetState) {
+                Column(
+                    Modifier
+                        .testTag(sheetTag)
+                        .nestedScroll(nestedScrollConnection, nestedScrollDispatcher)
+                ) {
+                    (0..50).forEach {
+                        Text(text = "$it")
+                    }
+                }
+            }
+        }
+
+        assertThat(sheetState.currentValue).isEqualTo(SheetValue.Expanded)
+        val scrollableContentHeight = rule.onNodeWithTag(sheetTag).fetchSemanticsNode().size.height
+        // Simulate a drag + fling
+        nestedScrollDispatcher.dispatchPostScroll(
+            consumed = Offset.Zero,
+            available = Offset(x = 0f, y = scrollableContentHeight / 2f),
+            source = NestedScrollSource.Drag
+        )
+        scope.launch {
+            nestedScrollDispatcher.dispatchPostFling(
+                consumed = Velocity.Zero,
+                available = Velocity(x = 0f, y = with(rule.density) { 200.dp.toPx() })
+            )
+        }
+
+        rule.waitForIdle()
+        assertThat(sheetState.isVisible).isFalse()
+        assertThat(callCount).isEqualTo(expectedCallCount)
+    }
 }
\ No newline at end of file
diff --git a/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/TimeInputScreenshotTest.kt b/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/TimeInputScreenshotTest.kt
index c2d40a5..1de6eaa 100644
--- a/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/TimeInputScreenshotTest.kt
+++ b/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/TimeInputScreenshotTest.kt
@@ -62,6 +62,23 @@
         rule.assertAgainstGolden("timeInput_12h_hourFocused_${scheme.name}")
     }
 
+    @Test
+    fun timeInput_24h_hourFocused() {
+        rule.setMaterialContent(scheme.colorScheme) {
+            Box(Modifier.testTag(TestTag)) {
+                TimeInput(
+                    state = rememberTimePickerState(
+                        initialHour = 22,
+                        initialMinute = 23,
+                        is24Hour = true,
+                    )
+                )
+            }
+        }
+
+        rule.assertAgainstGolden("timeInput_24h_hourFocused_${scheme.name}")
+    }
+
     private fun ComposeContentTestRule.assertAgainstGolden(goldenName: String) {
         this.onNodeWithTag(TestTag)
             .captureToImage()
diff --git a/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/TimePickerTest.kt b/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/TimePickerTest.kt
index 5de3ada..b26f237 100644
--- a/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/TimePickerTest.kt
+++ b/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/TimePickerTest.kt
@@ -44,6 +44,7 @@
 import androidx.compose.ui.test.isNotSelected
 import androidx.compose.ui.test.isSelectable
 import androidx.compose.ui.test.isSelected
+import androidx.compose.ui.test.junit4.StateRestorationTester
 import androidx.compose.ui.test.junit4.createComposeRule
 import androidx.compose.ui.test.onAllNodesWithContentDescription
 import androidx.compose.ui.test.onAllNodesWithText
@@ -72,7 +73,6 @@
 @OptIn(ExperimentalMaterial3Api::class)
 @MediumTest
 @RunWith(AndroidJUnit4::class)
-
 class TimePickerTest {
 
     @get:Rule
@@ -419,4 +419,98 @@
         // Value didn't change
         assertThat(state.hour).isEqualTo(22)
     }
-}
\ No newline at end of file
+
+    @Test
+    fun timeInput_24Hour_noAmPm_Toggle() {
+        val state = TimePickerState(initialHour = 22, initialMinute = 23, is24Hour = true)
+
+        rule.setMaterialContent(lightColorScheme()) {
+            TimeInput(state)
+        }
+
+        rule.onNodeWithText("PM").assertDoesNotExist()
+
+        rule.onNodeWithText("AM").assertDoesNotExist()
+    }
+
+    @Test
+    @OptIn(ExperimentalComposeUiApi::class, ExperimentalTestApi::class)
+    fun timeInput_24Hour_writeAfternoonHour() {
+        val state = TimePickerState(initialHour = 10, initialMinute = 23, is24Hour = true)
+
+        rule.setMaterialContent(lightColorScheme()) {
+            TimeInput(state)
+        }
+
+        rule.onNodeWithText("10")
+            .performKeyInput {
+                pressKey(Key.Two)
+                pressKey(Key.Two)
+            }
+
+        assertThat(state.hour).isEqualTo(22)
+    }
+
+    @Test
+    fun state_restoresTimePickerState() {
+        val restorationTester = StateRestorationTester(rule)
+        var state: TimePickerState?
+        restorationTester.setContent {
+            state = rememberTimePickerState(initialHour = 14, initialMinute = 54, is24Hour = true)
+        }
+
+        state = null
+
+        restorationTester.emulateSavedInstanceStateRestore()
+
+        rule.runOnIdle {
+            assertThat(state?.hour).isEqualTo(14)
+            assertThat(state?.minute).isEqualTo(54)
+            assertThat(state?.is24hour).isTrue()
+        }
+    }
+
+    @Test
+    fun clockFace_24Hour_everyValue() {
+        val state = TimePickerState(initialHour = 10, initialMinute = 23, is24Hour = true)
+
+        rule.setMaterialContent(lightColorScheme()) {
+            ClockFace(state, TimePickerDefaults.colors())
+        }
+
+        repeat(24) { number ->
+            rule.onNodeWithText(number.toString()).performClick()
+            rule.runOnIdle {
+                state.selection = Selection.Hour
+                assertThat(state.hour).isEqualTo(number)
+            }
+        }
+    }
+
+    @Test
+    fun clockFace_12Hour_everyValue() {
+        val state = TimePickerState(initialHour = 0, initialMinute = 0, is24Hour = false)
+
+        rule.setMaterialContent(lightColorScheme()) {
+            ClockFace(state, TimePickerDefaults.colors())
+        }
+
+        repeat(24) { number ->
+            if (number >= 12) {
+                state.isAfternoonToggle = true
+            }
+
+            val hour = when {
+                number == 0 -> 12
+                number > 12 -> number - 12
+                else -> number
+            }
+
+            rule.onNodeWithText("$hour").performClick()
+            rule.runOnIdle {
+                state.selection = Selection.Hour
+                assertThat(state.hour).isEqualTo(number)
+            }
+        }
+    }
+}
diff --git a/compose/material3/material3/src/androidMain/kotlin/androidx/compose/material3/Strings.android.kt b/compose/material3/material3/src/androidMain/kotlin/androidx/compose/material3/Strings.android.kt
index 42280fc..7ee55d4 100644
--- a/compose/material3/material3/src/androidMain/kotlin/androidx/compose/material3/Strings.android.kt
+++ b/compose/material3/material3/src/androidMain/kotlin/androidx/compose/material3/Strings.android.kt
@@ -144,6 +144,9 @@
         Strings.DateRangePickerScrollToShowPreviousMonth -> resources.getString(
             androidx.compose.material3.R.string.date_range_picker_scroll_to_previous_month
         )
+        Strings.DateRangePickerDayInRange -> resources.getString(
+            androidx.compose.material3.R.string.date_range_picker_day_in_range
+        )
         Strings.DateRangeInputTitle -> resources.getString(
             androidx.compose.material3.R.string.date_range_input_title
         )
@@ -173,6 +176,8 @@
             androidx.compose.material3.R.string.time_picker_hour)
         Strings.TimePickerMinute -> resources.getString(
             androidx.compose.material3.R.string.time_picker_minute)
+        Strings.TooltipPaneDescription -> resources.getString(
+            androidx.compose.material3.R.string.tooltip_pane_description)
         else -> ""
     }
 }
diff --git a/compose/material3/material3/src/androidMain/kotlin/androidx/compose/material3/TooltipPopup.android.kt b/compose/material3/material3/src/androidMain/kotlin/androidx/compose/material3/TooltipPopup.android.kt
new file mode 100644
index 0000000..99e10db
--- /dev/null
+++ b/compose/material3/material3/src/androidMain/kotlin/androidx/compose/material3/TooltipPopup.android.kt
@@ -0,0 +1,35 @@
+/*
+ * 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.compose.material3
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.window.Popup
+import androidx.compose.ui.window.PopupPositionProvider
+import androidx.compose.ui.window.PopupProperties
+
+@Composable
+@ExperimentalMaterial3Api
+internal actual fun TooltipPopup(
+    popupPositionProvider: PopupPositionProvider,
+    onDismissRequest: () -> Unit,
+    content: @Composable () -> Unit
+) = Popup(
+    popupPositionProvider = popupPositionProvider,
+    onDismissRequest = onDismissRequest,
+    content = content,
+    properties = PopupProperties(focusable = true)
+)
\ No newline at end of file
diff --git a/compose/material3/material3/src/androidMain/res/values/strings.xml b/compose/material3/material3/src/androidMain/res/values/strings.xml
index dae4ea2..ff12ee2 100644
--- a/compose/material3/material3/src/androidMain/res/values/strings.xml
+++ b/compose/material3/material3/src/androidMain/res/values/strings.xml
@@ -65,12 +65,15 @@
     <string name="date_range_picker_scroll_to_next_month">Scroll to show the next month</string>
     <!-- Spoken description for scrolling to the previous month -->
     <string name="date_range_picker_scroll_to_previous_month">Scroll to show the previous month</string>
+    <!-- Spoken description for a selected day that is within a range of selected days -->
+    <string name="date_range_picker_day_in_range">In range</string>
     <string name="date_range_input_title">Enter dates</string>
     <!--
     Describes an invalid date range input when a user enters a start or end date [CHAR_LIMIT=NONE]
     -->
     <string name="date_range_input_invalid_range_input">Invalid date range input</string>
     <!-- Spoken description of a tooltip -->
+    <string name="tooltip_pane_description">Tooltip</string>
     <string name="tooltip_long_press_label">Show tooltip</string>
     <!-- Suffix for time in 12-hour standard, after noon. [CHAR_LIMIT=2]" -->
     <string name="time_picker_pm">PM</string>
diff --git a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/DateInput.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/DateInput.kt
index 9118115..198d441 100644
--- a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/DateInput.kt
+++ b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/DateInput.kt
@@ -29,6 +29,8 @@
 import androidx.compose.runtime.saveable.rememberSaveable
 import androidx.compose.runtime.setValue
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.semantics.clearAndSetSemantics
+import androidx.compose.ui.semantics.contentDescription
 import androidx.compose.ui.semantics.error
 import androidx.compose.ui.semantics.semantics
 import androidx.compose.ui.text.AnnotatedString
@@ -69,10 +71,18 @@
             errorInvalidRangeInput = "" // Not used for a single date input
         )
     }
+    val pattern = dateInputFormat.patternWithDelimiters.uppercase()
+    val labelText = getString(string = Strings.DateInputLabel)
     DateInputTextField(
         modifier = Modifier
             .fillMaxWidth()
             .padding(InputTextFieldPadding),
+        label = {
+            Text(
+                labelText,
+                modifier = Modifier.semantics { contentDescription = "$labelText, $pattern" })
+        },
+        placeholder = { Text(pattern, modifier = Modifier.clearAndSetSemantics { }) },
         stateData = stateData,
         initialDate = stateData.selectedStartDate.value,
         onDateChanged = { date -> stateData.selectedStartDate.value = date },
@@ -87,6 +97,8 @@
 @Composable
 internal fun DateInputTextField(
     modifier: Modifier,
+    label: @Composable (() -> Unit)?,
+    placeholder: @Composable (() -> Unit)?,
     stateData: StateData,
     initialDate: CalendarDate?,
     onDateChanged: (CalendarDate?) -> Unit,
@@ -155,8 +167,8 @@
             .semantics {
                 if (errorText.value.isNotBlank()) error(errorText.value)
             },
-        label = { Text(getString(string = Strings.DateInputLabel)) },
-        placeholder = { Text(dateInputFormat.patternWithDelimiters.uppercase()) },
+        label = label,
+        placeholder = placeholder,
         supportingText = { if (errorText.value.isNotBlank()) Text(errorText.value) },
         isError = errorText.value.isNotBlank(),
         visualTransformation = DateVisualTransformation(dateInputFormat),
diff --git a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/DatePicker.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/DatePicker.kt
index 3974694..33da31c 100644
--- a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/DatePicker.kt
+++ b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/DatePicker.kt
@@ -909,11 +909,6 @@
     val totalMonthsInRange: Int
         get() = (yearRange.last - yearRange.first + 1) * 12
 
-    fun isInRange(date: Long): Boolean {
-        return date >= (selectedStartDate.value?.utcTimeMillis ?: Long.MAX_VALUE) &&
-            date <= (selectedEndDate.value?.utcTimeMillis ?: Long.MIN_VALUE)
-    }
-
     fun switchDisplayMode(displayMode: DisplayMode) {
         // Update the displayed month, if needed, and change the mode to a  date-picker.
         selectedStartDate.value?.let {
@@ -1353,10 +1348,6 @@
     dateFormatter: DatePickerFormatter,
     colors: DatePickerColors
 ) {
-    fun isInRange(date: Long): Boolean {
-        return rangeSelectionEnabled && stateData.isInRange(date)
-    }
-
     val rangeSelectionInfo: State<SelectedRangeInfo?> = remember(rangeSelectionEnabled) {
         derivedStateOf {
             if (rangeSelectionEnabled) {
@@ -1420,11 +1411,23 @@
                             val startDateSelected =
                                 dateInMillis == startSelection.value?.utcTimeMillis
                             val endDateSelected = dateInMillis == endSelection.value?.utcTimeMillis
+                            val inRange = remember(rangeSelectionEnabled, dateInMillis) {
+                                derivedStateOf {
+                                    with(stateData) {
+                                        rangeSelectionEnabled &&
+                                            dateInMillis >= (selectedStartDate.value?.utcTimeMillis
+                                            ?: Long.MAX_VALUE) &&
+                                            dateInMillis <= (selectedEndDate.value?.utcTimeMillis
+                                            ?: Long.MIN_VALUE)
+                                    }
+                                }
+                            }
                             val dayContentDescription = dayContentDescription(
                                 rangeSelectionEnabled = rangeSelectionEnabled,
                                 isToday = isToday,
                                 isStartDate = startDateSelected,
-                                isEndDate = endDateSelected
+                                isEndDate = endDateSelected,
+                                isInRange = inRange.value
                             )
                             Day(
                                 modifier = Modifier.semantics {
@@ -1441,9 +1444,7 @@
                                     dateValidator.invoke(dateInMillis)
                                 },
                                 today = isToday,
-                                inRange = remember(dateInMillis, startSelection, endSelection) {
-                                    isInRange(dateInMillis)
-                                },
+                                inRange = inRange.value,
                                 colors = colors
                             ) {
                                 val defaultLocale = defaultLocale()
@@ -1474,14 +1475,23 @@
     rangeSelectionEnabled: Boolean,
     isToday: Boolean,
     isStartDate: Boolean,
-    isEndDate: Boolean
+    isEndDate: Boolean,
+    isInRange: Boolean
 ): String? {
     val descriptionBuilder = StringBuilder()
     if (rangeSelectionEnabled) {
-        if (isStartDate) {
-            descriptionBuilder.append(getString(string = Strings.DateRangePickerStartHeadline))
-        } else if (isEndDate) {
-            descriptionBuilder.append(getString(string = Strings.DateRangePickerEndHeadline))
+        when {
+            isStartDate -> descriptionBuilder.append(
+                getString(string = Strings.DateRangePickerStartHeadline)
+            )
+
+            isEndDate -> descriptionBuilder.append(
+                getString(string = Strings.DateRangePickerEndHeadline)
+            )
+
+            isInRange -> descriptionBuilder.append(
+                getString(string = Strings.DateRangePickerDayInRange)
+            )
         }
     }
     if (isToday) {
diff --git a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/DateRangeInput.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/DateRangeInput.kt
index 19f4293..b699188 100644
--- a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/DateRangeInput.kt
+++ b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/DateRangeInput.kt
@@ -22,6 +22,9 @@
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.remember
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.semantics.clearAndSetSemantics
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
 import androidx.compose.ui.unit.dp
 
 @OptIn(ExperimentalMaterial3Api::class)
@@ -56,8 +59,17 @@
         modifier = Modifier.padding(paddingValues = InputTextFieldPadding),
         horizontalArrangement = Arrangement.spacedBy(TextFieldSpacing)
     ) {
+        val pattern = dateInputFormat.patternWithDelimiters.uppercase()
+        val startRangeText = getString(string = Strings.DateRangePickerStartHeadline)
         DateInputTextField(
             modifier = Modifier.weight(0.5f),
+            label = {
+                Text(startRangeText,
+                    modifier = Modifier.semantics {
+                        contentDescription = "$startRangeText, $pattern"
+                    })
+            },
+            placeholder = { Text(pattern, modifier = Modifier.clearAndSetSemantics { }) },
             stateData = stateData,
             initialDate = stateData.selectedStartDate.value,
             onDateChanged = { date -> stateData.selectedStartDate.value = date },
@@ -66,8 +78,16 @@
             dateInputFormat = dateInputFormat,
             locale = defaultLocale
         )
+        val endRangeText = getString(string = Strings.DateRangePickerEndHeadline)
         DateInputTextField(
             modifier = Modifier.weight(0.5f),
+            label = {
+                Text(endRangeText,
+                    modifier = Modifier.semantics {
+                        contentDescription = "$endRangeText, $pattern"
+                    })
+            },
+            placeholder = { Text(pattern, modifier = Modifier.clearAndSetSemantics { }) },
             stateData = stateData,
             initialDate = stateData.selectedEndDate.value,
             onDateChanged = { date -> stateData.selectedEndDate.value = date },
diff --git a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/ListItem.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/ListItem.kt
index 6179ee1..114fe42 100644
--- a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/ListItem.kt
+++ b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/ListItem.kt
@@ -16,6 +16,7 @@
 
 package androidx.compose.material3
 
+import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.PaddingValues
@@ -28,9 +29,11 @@
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.CompositionLocalProvider
 import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.ReadOnlyComposable
 import androidx.compose.runtime.State
 import androidx.compose.runtime.rememberUpdatedState
 import androidx.compose.ui.Alignment
+import androidx.compose.ui.Alignment.Companion.CenterVertically
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.Shape
@@ -46,9 +49,9 @@
  * ![Lists image](https://developer.android.com/images/reference/androidx/compose/material3/lists.png)
  *
  * This component can be used to achieve the list item templates existing in the spec. One-line list
- * items have a singular line of headline text. Two-line list items additionally have either
- * supporting or overline text. Three-line list items have either both supporting and overline text,
- * or extended (two-line) supporting text. For example:
+ * items have a singular line of headline content. Two-line list items additionally have either
+ * supporting or overline content. Three-line list items have either both supporting and overline
+ * content, or extended (two-line) supporting text. For example:
  * - one-line item
  * @sample androidx.compose.material3.samples.OneLineListItem
  * - two-line item
@@ -56,11 +59,11 @@
  * - three-line item
  * @sample androidx.compose.material3.samples.ThreeLineListItem
  *
- * @param headlineText the headline text of the list item
+ * @param headlineContent the headline content of the list item
  * @param modifier [Modifier] to be applied to the list item
- * @param overlineText the text displayed above the headline text
- * @param supportingText the supporting text of the list item
- * @param leadingContent the leading supporting visual of the list item
+ * @param overlineContent the content displayed above the headline content
+ * @param supportingContent the supporting content of the list item
+ * @param leadingContent the leading content of the list item
  * @param trailingContent the trailing meta text, icon, switch or checkbox
  * @param colors [ListItemColors] that will be used to resolve the background and content color for
  * this list item in different states. See [ListItemDefaults.colors]
@@ -69,192 +72,114 @@
  */
 @Composable
 fun ListItem(
-    headlineText: @Composable () -> Unit,
+    headlineContent: @Composable () -> Unit,
     modifier: Modifier = Modifier,
-    overlineText: @Composable (() -> Unit)? = null,
-    supportingText: @Composable (() -> Unit)? = null,
+    overlineContent: @Composable (() -> Unit)? = null,
+    supportingContent: @Composable (() -> Unit)? = null,
     leadingContent: @Composable (() -> Unit)? = null,
     trailingContent: @Composable (() -> Unit)? = null,
     colors: ListItemColors = ListItemDefaults.colors(),
     tonalElevation: Dp = ListItemDefaults.Elevation,
     shadowElevation: Dp = ListItemDefaults.Elevation,
 ) {
-    if (overlineText == null && supportingText == null) {
-        // One-Line List Item
-        ListItem(
-            modifier = modifier,
-            containerColor = colors.containerColor().value,
-            contentColor = colors.headlineColor(enabled = true).value,
-            tonalElevation = tonalElevation,
-            shadowElevation = shadowElevation,
-            minHeight = ListTokens.ListItemContainerHeight,
-            paddingValues = PaddingValues(ListItemHorizontalPadding, ListItemVerticalPadding)
-        ) {
-            if (leadingContent != null) {
-                leadingContent(
-                    leadingContent = leadingContent,
-                    contentColor = colors.leadingIconColor(enabled = true).value,
-                    topAlign = false
-                )()
-            }
-            Box(
-                Modifier
-                    .weight(1f)
-                    .align(Alignment.CenterVertically)
-            ) {
-                ProvideTextStyleFromToken(
-                    colors.headlineColor(enabled = true).value,
-                    ListTokens.ListItemLabelTextFont,
-                    headlineText
-                )
-            }
-            if (trailingContent != null) {
-                trailingContent(
-                    trailingContent = trailingContent,
-                    contentColor = colors.trailingIconColor(enabled = true).value,
-                    topAlign = false
-                )()
-            }
-        }
-    } else if (overlineText == null) {
-        // Two-Line List Item
-        ListItem(
-            modifier = modifier,
-            containerColor = colors.containerColor().value,
-            contentColor = colors.headlineColor(enabled = true).value,
-            tonalElevation = tonalElevation,
-            shadowElevation = shadowElevation,
-            minHeight = TwoLineListItemContainerHeight,
-            paddingValues = PaddingValues(ListItemHorizontalPadding, ListItemVerticalPadding)
-        ) {
-            if (leadingContent != null) {
-                leadingContent(
-                    leadingContent = leadingContent,
-                    contentColor = colors.leadingIconColor(enabled = true).value,
-                    topAlign = false
-                )()
-            }
-            Box(
-                Modifier
-                    .weight(1f)
-                    .align(Alignment.CenterVertically)
-            ) {
-                Column {
-                    ProvideTextStyleFromToken(
-                        colors.headlineColor(enabled = true).value,
-                        ListTokens.ListItemLabelTextFont,
-                        headlineText
-                    )
-                    ProvideTextStyleFromToken(
-                        colors.supportingColor().value,
-                        ListTokens.ListItemSupportingTextFont,
-                        supportingText!!
-                    )
-                }
-            }
-            if (trailingContent != null) {
-                trailingContent(
-                    trailingContent = trailingContent,
-                    contentColor = colors.trailingIconColor(enabled = true).value,
-                    topAlign = false
-                )()
-            }
-        }
-    } else if (supportingText == null) {
-        // Two-Line List Item
-        ListItem(
-            modifier = modifier,
-            containerColor = colors.containerColor().value,
-            contentColor = colors.headlineColor(enabled = true).value,
-            tonalElevation = tonalElevation,
-            shadowElevation = shadowElevation,
-            minHeight = TwoLineListItemContainerHeight,
-            paddingValues = PaddingValues(ListItemHorizontalPadding, ListItemVerticalPadding)
-        ) {
-            if (leadingContent != null) {
-                leadingContent(
-                    leadingContent = leadingContent,
-                    contentColor = colors.leadingIconColor(enabled = true).value,
-                    topAlign = false
-                )()
-            }
-            Box(
-                Modifier
-                    .weight(1f)
-                    .align(Alignment.CenterVertically)
-            ) {
-                Column {
-                    ProvideTextStyleFromToken(
-                        colors.overlineColor().value,
-                        ListTokens.ListItemOverlineFont,
-                        overlineText
-                    )
-                    ProvideTextStyleFromToken(
-                        colors.headlineColor(enabled = true).value,
-                        ListTokens.ListItemLabelTextFont,
-                        headlineText
-                    )
-                }
-            }
-            if (trailingContent != null) {
-                trailingContent(
-                    trailingContent = trailingContent,
-                    contentColor = colors.trailingIconColor(enabled = true).value,
-                    topAlign = false
-                )()
-            }
-        }
-    } else {
-        // Three-Line List Item
-        ListItem(
-            modifier = modifier,
-            containerColor = colors.containerColor().value,
-            contentColor = colors.headlineColor(enabled = true).value,
-            tonalElevation = tonalElevation,
-            shadowElevation = shadowElevation,
-            minHeight = ThreeLineListItemContainerHeight,
-            paddingValues = PaddingValues(
-                ListItemHorizontalPadding,
-                ListItemThreeLineVerticalPadding
+    val decoratedHeadlineContent: @Composable () -> Unit = {
+        ProvideTextStyleFromToken(
+            colors.headlineColor(enabled = true).value,
+            ListTokens.ListItemLabelTextFont,
+            headlineContent
+        )
+    }
+    val decoratedSupportingContent: @Composable (() -> Unit)? = supportingContent?.let {
+        @Composable {
+            ProvideTextStyleFromToken(
+                colors.supportingColor().value,
+                ListTokens.ListItemSupportingTextFont,
+                it
             )
+        }
+    }
+    val decoratedOverlineContent: @Composable (() -> Unit)? = overlineContent?.let {
+        @Composable {
+            ProvideTextStyleFromToken(
+                colors.overlineColor().value,
+                ListTokens.ListItemOverlineFont,
+                it
+            )
+        }
+    }
+
+    val listItemType = ListItemType.getListItemType(
+        hasOverline = decoratedOverlineContent != null,
+        hasSupporting = decoratedSupportingContent != null
+    )
+
+    val decoratedLeadingContent: @Composable (RowScope.() -> Unit)? = leadingContent?.let {
+        {
+            LeadingContent(
+                contentColor = colors.leadingIconColor(enabled = true).value,
+                topAlign = listItemType == ListItemType.ThreeLine,
+                content = it
+            )
+        }
+    }
+
+    val decoratedTrailingContent: @Composable (RowScope.() -> Unit)? = trailingContent?.let {
+        {
+            TrailingContent(
+                contentColor = colors.trailingIconColor(enabled = true).value,
+                topAlign = listItemType == ListItemType.ThreeLine,
+                content = it
+            )
+        }
+    }
+    val minHeight: Dp = when (listItemType) {
+        ListItemType.OneLine -> ListTokens.ListItemOneLineContainerHeight
+        ListItemType.TwoLine -> ListTokens.ListItemTwoLineContainerHeight
+        else -> ListTokens.ListItemThreeLineContainerHeight // 3
+    }
+    val outerPaddingValues =
+        PaddingValues(
+            horizontal = ListItemHorizontalPadding,
+            vertical = if (listItemType == ListItemType.ThreeLine)
+                ListItemThreeLineVerticalPadding else ListItemVerticalPadding
+        )
+    val contentPaddingValues = PaddingValues(
+        end = if (listItemType == ListItemType.ThreeLine) ContentEndPadding else 0.dp
+    )
+    val columnArrangement = if (listItemType == ListItemType.ThreeLine)
+        Arrangement.Top else Arrangement.Center
+    val boxAlignment = if (listItemType == ListItemType.ThreeLine)
+        Alignment.Top else CenterVertically
+
+    ListItem(
+        modifier = modifier,
+        containerColor = colors.containerColor().value,
+        contentColor = colors.headlineColor(enabled = true).value,
+        tonalElevation = tonalElevation,
+        shadowElevation = shadowElevation,
+        minHeight = minHeight,
+        paddingValues = outerPaddingValues
+    ) {
+        if (decoratedLeadingContent != null) {
+            decoratedLeadingContent()
+        }
+        Column(
+            modifier = Modifier
+                .weight(1f)
+                .padding(contentPaddingValues)
+                .align(boxAlignment),
+            verticalArrangement = columnArrangement
         ) {
-            if (leadingContent != null) {
-                leadingContent(
-                    leadingContent = leadingContent,
-                    contentColor = colors.leadingIconColor(enabled = true).value,
-                    topAlign = true
-                )()
+            if (decoratedOverlineContent != null) {
+                decoratedOverlineContent()
             }
-            Box(
-                Modifier
-                    .weight(1f)
-                    .padding(end = ContentEndPadding),
-            ) {
-                Column {
-                    ProvideTextStyleFromToken(
-                        colors.overlineColor().value,
-                        ListTokens.ListItemOverlineFont,
-                        overlineText
-                    )
-                    ProvideTextStyleFromToken(
-                        colors.headlineColor(enabled = true).value,
-                        ListTokens.ListItemLabelTextFont,
-                        headlineText
-                    )
-                    ProvideTextStyleFromToken(
-                        colors.supportingColor().value,
-                        ListTokens.ListItemSupportingTextFont,
-                        supportingText
-                    )
-                }
+            decoratedHeadlineContent()
+            if (decoratedSupportingContent != null) {
+                decoratedSupportingContent()
             }
-            if (trailingContent != null) {
-                trailingContent(
-                    trailingContent = trailingContent,
-                    contentColor = colors.trailingIconColor(enabled = true).value,
-                    topAlign = true
-                )()
-            }
+        }
+        if (decoratedTrailingContent != null) {
+            decoratedTrailingContent()
         }
     }
 }
@@ -305,61 +230,34 @@
 }
 
 @Composable
-private fun leadingContent(
-    leadingContent: @Composable (() -> Unit),
+private fun RowScope.LeadingContent(
     contentColor: Color,
     topAlign: Boolean,
-): @Composable RowScope.() -> Unit {
-    return {
-        CompositionLocalProvider(
-            LocalContentColor provides contentColor) {
-            if (topAlign) {
-                Box(Modifier
-                    .padding(end = LeadingContentEndPadding),
-                    contentAlignment = Alignment.TopStart
-                ) { leadingContent() }
-            } else {
-                Box(
-                    Modifier
-                        .align(Alignment.CenterVertically)
-                        .padding(end = LeadingContentEndPadding)
-                ) { leadingContent() }
-            }
-        }
+    content: @Composable () -> Unit,
+) = CompositionLocalProvider(LocalContentColor provides contentColor) {
+        Box(
+            Modifier
+                .padding(end = LeadingContentEndPadding)
+                .then(if (!topAlign) Modifier.align(CenterVertically) else Modifier),
+        ) { content() }
     }
-}
 
 @Composable
-private fun trailingContent(
-    trailingContent: @Composable (() -> Unit),
+private fun RowScope.TrailingContent(
     contentColor: Color,
     topAlign: Boolean,
-): @Composable RowScope.() -> Unit {
-    return {
-        if (topAlign) {
-            Box(Modifier
-                .padding(horizontal = TrailingHorizontalPadding),
-                contentAlignment = Alignment.TopStart
-            ) {
-                ProvideTextStyleFromToken(
-                    contentColor,
-                    ListTokens.ListItemTrailingSupportingTextFont,
-                    trailingContent
-                ) }
-        } else {
-            Box(
-                Modifier
-                    .align(Alignment.CenterVertically)
-                    .padding(horizontal = TrailingHorizontalPadding)
-            ) {
-                ProvideTextStyleFromToken(
-                    contentColor,
-                    ListTokens.ListItemTrailingSupportingTextFont,
-                    trailingContent
-                ) }
-        }
+    content: @Composable () -> Unit,
+) = Box(
+    Modifier
+        .padding(horizontal = TrailingHorizontalPadding)
+        .then(if (!topAlign) Modifier.align(CenterVertically) else Modifier),
+    ) {
+        ProvideTextStyleFromToken(
+            contentColor,
+            ListTokens.ListItemTrailingSupportingTextFont,
+            content
+        )
     }
-}
 
 /**
  * Contains the default values used by list items.
@@ -369,13 +267,19 @@
     val Elevation: Dp = ListTokens.ListItemContainerElevation
 
     /** The default shape of a list item */
-    val shape: Shape @Composable get() = ListTokens.ListItemContainerShape.toShape()
+    val shape: Shape
+        @Composable
+        @ReadOnlyComposable get() = ListTokens.ListItemContainerShape.toShape()
 
     /** The container color of a list item */
-    val containerColor: Color @Composable get() = ListTokens.ListItemContainerColor.toColor()
+    val containerColor: Color
+        @Composable
+        @ReadOnlyComposable get() = ListTokens.ListItemContainerColor.toColor()
 
     /** The content color of a list item */
-    val contentColor: Color @Composable get() = ListTokens.ListItemLabelTextColor.toColor()
+    val contentColor: Color
+        @Composable
+        @ReadOnlyComposable get() = ListTokens.ListItemLabelTextColor.toColor()
 
     /**
      * Creates a [ListItemColors] that represents the default container and content colors used in a
@@ -494,10 +398,37 @@
     }
 }
 
+/**
+ * Helper class to define list item type. Used for padding and sizing definition.
+ */
+@JvmInline
+private value class ListItemType private constructor(private val lines: Int) :
+    Comparable<ListItemType> {
+
+    override operator fun compareTo(other: ListItemType) = lines.compareTo(other.lines)
+
+    companion object {
+        /** One line list item */
+        val OneLine = ListItemType(1)
+
+        /** Two line list item */
+        val TwoLine = ListItemType(2)
+
+        /** Three line list item */
+        val ThreeLine = ListItemType(3)
+
+        internal fun getListItemType(hasOverline: Boolean, hasSupporting: Boolean): ListItemType {
+            return when {
+                hasOverline && hasSupporting -> ThreeLine
+                hasOverline || hasSupporting -> TwoLine
+                else -> OneLine
+            }
+        }
+    }
+}
+
 // Container related defaults
 // TODO: Make sure these values stay up to date until replaced with tokens.
-private val TwoLineListItemContainerHeight = 72.dp
-private val ThreeLineListItemContainerHeight = 88.dp
 private val ListItemVerticalPadding = 8.dp
 private val ListItemThreeLineVerticalPadding = 16.dp
 private val ListItemHorizontalPadding = 16.dp
diff --git a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/ModalBottomSheet.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/ModalBottomSheet.kt
index 0f93ced..0225ad9 100644
--- a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/ModalBottomSheet.kt
+++ b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/ModalBottomSheet.kt
@@ -144,10 +144,15 @@
                         )
                     }
                     .nestedScroll(
-                        remember(sheetState.swipeableState, Orientation.Vertical) {
+                        remember(sheetState) {
                             ConsumeSwipeWithinBottomSheetBoundsNestedScrollConnection(
-                                state = sheetState.swipeableState,
-                                orientation = Orientation.Vertical
+                                sheetState = sheetState,
+                                orientation = Orientation.Vertical,
+                                onFling = {
+                                    scope.launch { sheetState.settle(it) }.invokeOnCompletion {
+                                            if (!sheetState.isVisible) onDismissRequest()
+                                        }
+                                }
                             )
                         }
                     )
@@ -158,6 +163,13 @@
                         anchorChangeHandler = anchorChangeHandler,
                         screenHeight = fullHeight.toFloat(),
                         bottomPadding = systemBarHeight.toFloat(),
+                        onDragStopped = {
+                            scope
+                                .launch { sheetState.settle(it) }
+                                .invokeOnCompletion {
+                                    if (!sheetState.isVisible) onDismissRequest()
+                                }
+                        },
                     ),
                 shape = shape,
                 color = containerColor,
@@ -226,61 +238,63 @@
     anchorChangeHandler: AnchorChangeHandler<SheetValue>,
     screenHeight: Float,
     bottomPadding: Float,
+    onDragStopped: CoroutineScope.(velocity: Float) -> Unit,
 ) = draggable(
-    state = sheetState.swipeableState.draggableState,
-    orientation = Orientation.Vertical,
-    enabled = sheetState.isVisible,
-    startDragImmediately = sheetState.swipeableState.isAnimationRunning,
-    onDragStopped = { velocity ->
-        try {
-            sheetState.settle(velocity)
-        } finally {
-            if (!sheetState.isVisible) onDismissRequest()
-        }
-    }
-).swipeAnchors(
-    state = sheetState.swipeableState,
-    anchorChangeHandler = anchorChangeHandler,
-    possibleValues = setOf(Hidden, Collapsed, Expanded),
-) { value, sheetSize ->
-    when (value) {
-        Hidden -> screenHeight + bottomPadding
-        Collapsed -> when {
-            sheetSize.height < screenHeight / 2 -> null
-            sheetState.skipCollapsed -> null
-            else -> sheetSize.height / 2f
-        }
-        Expanded -> if (sheetSize.height != 0) {
-            max(0f, screenHeight - sheetSize.height)
-        } else null
-    }
-}.semantics {
-    if (sheetState.isVisible) {
-        dismiss {
-            if (sheetState.swipeableState.confirmValueChange(Hidden)) {
-                scope.launch { sheetState.hide() }.invokeOnCompletion {
-                    if (!sheetState.isVisible) { onDismissRequest() }
+            state = sheetState.swipeableState.draggableState,
+            orientation = Orientation.Vertical,
+            enabled = sheetState.isVisible,
+            startDragImmediately = sheetState.swipeableState.isAnimationRunning,
+            onDragStopped = onDragStopped
+        )
+        .swipeAnchors(
+            state = sheetState.swipeableState,
+            anchorChangeHandler = anchorChangeHandler,
+            possibleValues = setOf(Hidden, Collapsed, Expanded),
+        ) { value, sheetSize ->
+            when (value) {
+                Hidden -> screenHeight + bottomPadding
+                Collapsed -> when {
+                    sheetSize.height < screenHeight / 2 -> null
+                    sheetState.skipCollapsed -> null
+                    else -> sheetSize.height / 2f
                 }
-            }
-            true
-        }
-        if (sheetState.swipeableState.currentValue == Collapsed) {
-            expand {
-                if (sheetState.swipeableState.confirmValueChange(Expanded)) {
-                    scope.launch { sheetState.expand() }
-                }
-                true
-            }
-        } else if (sheetState.hasCollapsedState) {
-            collapse {
-                if (sheetState.swipeableState.confirmValueChange(Collapsed)) {
-                    scope.launch { sheetState.collapse() }
-                }
-                true
+
+                Expanded -> if (sheetSize.height != 0) {
+                    max(0f, screenHeight - sheetSize.height)
+                } else null
             }
         }
-    }
-}
+        .semantics {
+            if (sheetState.isVisible) {
+                dismiss {
+                    if (sheetState.swipeableState.confirmValueChange(Hidden)) {
+                        scope
+                            .launch { sheetState.hide() }
+                            .invokeOnCompletion {
+                                if (!sheetState.isVisible) {
+                                    onDismissRequest()
+                                }
+                            }
+                    }
+                    true
+                }
+                if (sheetState.swipeableState.currentValue == Collapsed) {
+                    expand {
+                        if (sheetState.swipeableState.confirmValueChange(Expanded)) {
+                            scope.launch { sheetState.expand() }
+                        }
+                        true
+                    }
+                } else if (sheetState.hasCollapsedState) {
+                    collapse {
+                        if (sheetState.swipeableState.confirmValueChange(Collapsed)) {
+                            scope.launch { sheetState.collapse() }
+                        }
+                        true
+                    }
+                }
+            }
+        }
 
 @ExperimentalMaterial3Api
 private fun ModalBottomSheetAnchorChangeHandler(
diff --git a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/Shapes.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/Shapes.kt
index 5ad72df..e1bbb61 100644
--- a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/Shapes.kt
+++ b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/Shapes.kt
@@ -23,6 +23,7 @@
 import androidx.compose.material3.tokens.ShapeTokens
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.ReadOnlyComposable
 import androidx.compose.runtime.staticCompositionLocalOf
 import androidx.compose.ui.graphics.Shape
 import androidx.compose.ui.graphics.RectangleShape
@@ -177,6 +178,7 @@
 
 /** Converts a shape token key to the local shape provided by the theme */
 @Composable
+@ReadOnlyComposable
 internal fun ShapeKeyTokens.toShape(): Shape {
     return MaterialTheme.shapes.fromToken(this)
 }
diff --git a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/SheetDefaults.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/SheetDefaults.kt
index 8149339..6c52719 100644
--- a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/SheetDefaults.kt
+++ b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/SheetDefaults.kt
@@ -270,13 +270,14 @@
 
 @OptIn(ExperimentalMaterial3Api::class)
 internal fun ConsumeSwipeWithinBottomSheetBoundsNestedScrollConnection(
-    state: SwipeableV2State<*>,
-    orientation: Orientation
+    sheetState: SheetState,
+    orientation: Orientation,
+    onFling: (velocity: Float) -> Unit
 ): NestedScrollConnection = object : NestedScrollConnection {
     override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
         val delta = available.toFloat()
         return if (delta < 0 && source == NestedScrollSource.Drag) {
-            state.dispatchRawDelta(delta).toOffset()
+            sheetState.swipeableState.dispatchRawDelta(delta).toOffset()
         } else {
             Offset.Zero
         }
@@ -288,7 +289,7 @@
         source: NestedScrollSource
     ): Offset {
         return if (source == NestedScrollSource.Drag) {
-            state.dispatchRawDelta(available.toFloat()).toOffset()
+            sheetState.swipeableState.dispatchRawDelta(available.toFloat()).toOffset()
         } else {
             Offset.Zero
         }
@@ -296,9 +297,9 @@
 
     override suspend fun onPreFling(available: Velocity): Velocity {
         val toFling = available.toFloat()
-        val currentOffset = state.requireOffset()
-        return if (toFling < 0 && currentOffset > state.minBound) {
-            state.settle(velocity = toFling)
+        val currentOffset = sheetState.requireOffset()
+        return if (toFling < 0 && currentOffset > sheetState.swipeableState.minBound) {
+            onFling(toFling)
             // since we go to the anchor with tween settling, consume all for the best UX
             available
         } else {
@@ -307,7 +308,7 @@
     }
 
     override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
-        state.settle(velocity = available.toFloat())
+        onFling(available.toFloat())
         return available
     }
 
diff --git a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/Strings.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/Strings.kt
index d62eefb..4d27113 100644
--- a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/Strings.kt
+++ b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/Strings.kt
@@ -23,63 +23,68 @@
 @Immutable
 @JvmInline
 internal value class Strings private constructor(
-    @Suppress("unused") private val value: Int
+    @Suppress("unused") private val value: Int = nextId()
 ) {
     companion object {
-        val NavigationMenu = Strings(0)
-        val CloseDrawer = Strings(1)
-        val CloseSheet = Strings(2)
-        val DefaultErrorMessage = Strings(3)
-        val ExposedDropdownMenu = Strings(4)
-        val SliderRangeStart = Strings(5)
-        val SliderRangeEnd = Strings(6)
-        val Dialog = Strings(7)
-        val MenuExpanded = Strings(8)
-        val MenuCollapsed = Strings(9)
-        val SnackbarDismiss = Strings(10)
-        val SearchBarSearch = Strings(11)
-        val SuggestionsAvailable = Strings(12)
-        val DatePickerTitle = Strings(13)
-        val DatePickerHeadline = Strings(14)
-        val DatePickerYearPickerPaneTitle = Strings(15)
-        val DatePickerSwitchToYearSelection = Strings(16)
-        val DatePickerSwitchToDaySelection = Strings(17)
-        val DatePickerSwitchToNextMonth = Strings(18)
-        val DatePickerSwitchToPreviousMonth = Strings(19)
-        val DatePickerNavigateToYearDescription = Strings(20)
-        val DatePickerHeadlineDescription = Strings(21)
-        val DatePickerNoSelectionDescription = Strings(22)
-        val DatePickerTodayDescription = Strings(23)
-        val DatePickerScrollToShowLaterYears = Strings(24)
-        val DatePickerScrollToShowEarlierYears = Strings(25)
-        val DateInputTitle = Strings(26)
-        val DateInputHeadline = Strings(27)
-        val DateInputLabel = Strings(28)
-        val DateInputHeadlineDescription = Strings(29)
-        val DateInputNoInputDescription = Strings(30)
-        val DateInputInvalidNotAllowed = Strings(31)
-        val DateInputInvalidForPattern = Strings(32)
-        val DateInputInvalidYearRange = Strings(33)
-        val DatePickerSwitchToCalendarMode = Strings(34)
-        val DatePickerSwitchToInputMode = Strings(35)
-        val DateRangePickerTitle = Strings(36)
-        val DateRangePickerStartHeadline = Strings(37)
-        val DateRangePickerEndHeadline = Strings(38)
-        val DateRangePickerScrollToShowNextMonth = Strings(39)
-        val DateRangePickerScrollToShowPreviousMonth = Strings(40)
-        val DateRangeInputTitle = Strings(41)
-        val DateRangeInputInvalidRangeInput = Strings(42)
-        val TooltipLongPressLabel = Strings(43)
-        val TimePickerAM = Strings(44)
-        val TimePickerPM = Strings(45)
-        val TimePickerPeriodToggle = Strings(46)
-        val TimePickerHourSelection = Strings(47)
-        val TimePickerMinuteSelection = Strings(48)
-        val TimePickerHourSuffix = Strings(49)
-        val TimePicker24HourSuffix = Strings(50)
-        val TimePickerMinuteSuffix = Strings(51)
-        val TimePickerHour = Strings(52)
-        val TimePickerMinute = Strings(53)
+        private var id = 0
+        private fun nextId() = id++
+
+        val NavigationMenu = Strings()
+        val CloseDrawer = Strings()
+        val CloseSheet = Strings()
+        val DefaultErrorMessage = Strings()
+        val ExposedDropdownMenu = Strings()
+        val SliderRangeStart = Strings()
+        val SliderRangeEnd = Strings()
+        val Dialog = Strings()
+        val MenuExpanded = Strings()
+        val MenuCollapsed = Strings()
+        val SnackbarDismiss = Strings()
+        val SearchBarSearch = Strings()
+        val SuggestionsAvailable = Strings()
+        val DatePickerTitle = Strings()
+        val DatePickerHeadline = Strings()
+        val DatePickerYearPickerPaneTitle = Strings()
+        val DatePickerSwitchToYearSelection = Strings()
+        val DatePickerSwitchToDaySelection = Strings()
+        val DatePickerSwitchToNextMonth = Strings()
+        val DatePickerSwitchToPreviousMonth = Strings()
+        val DatePickerNavigateToYearDescription = Strings()
+        val DatePickerHeadlineDescription = Strings()
+        val DatePickerNoSelectionDescription = Strings()
+        val DatePickerTodayDescription = Strings()
+        val DatePickerScrollToShowLaterYears = Strings()
+        val DatePickerScrollToShowEarlierYears = Strings()
+        val DateInputTitle = Strings()
+        val DateInputHeadline = Strings()
+        val DateInputLabel = Strings()
+        val DateInputHeadlineDescription = Strings()
+        val DateInputNoInputDescription = Strings()
+        val DateInputInvalidNotAllowed = Strings()
+        val DateInputInvalidForPattern = Strings()
+        val DateInputInvalidYearRange = Strings()
+        val DatePickerSwitchToCalendarMode = Strings()
+        val DatePickerSwitchToInputMode = Strings()
+        val DateRangePickerTitle = Strings()
+        val DateRangePickerStartHeadline = Strings()
+        val DateRangePickerEndHeadline = Strings()
+        val DateRangePickerScrollToShowNextMonth = Strings()
+        val DateRangePickerScrollToShowPreviousMonth = Strings()
+        val DateRangePickerDayInRange = Strings()
+        val DateRangeInputTitle = Strings()
+        val DateRangeInputInvalidRangeInput = Strings()
+        val TooltipLongPressLabel = Strings()
+        val TimePickerAM = Strings()
+        val TimePickerPM = Strings()
+        val TimePickerPeriodToggle = Strings()
+        val TimePickerHourSelection = Strings()
+        val TimePickerMinuteSelection = Strings()
+        val TimePickerHourSuffix = Strings()
+        val TimePicker24HourSuffix = Strings()
+        val TimePickerMinuteSuffix = Strings()
+        val TimePickerHour = Strings()
+        val TimePickerMinute = Strings()
+        val TooltipPaneDescription = Strings()
     }
 }
 
diff --git a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/TimePicker.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/TimePicker.kt
index b37cfa4..83f77d6 100644
--- a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/TimePicker.kt
+++ b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/TimePicker.kt
@@ -38,6 +38,8 @@
 import androidx.compose.foundation.layout.Spacer
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.size
 import androidx.compose.foundation.layout.width
 import androidx.compose.foundation.selection.selectableGroup
@@ -47,6 +49,7 @@
 import androidx.compose.foundation.text.KeyboardActions
 import androidx.compose.foundation.text.KeyboardOptions
 import androidx.compose.material3.tokens.MotionTokens
+import androidx.compose.material3.tokens.TimeInputTokens
 import androidx.compose.material3.tokens.TimePickerTokens
 import androidx.compose.material3.tokens.TimePickerTokens.ClockDialColor
 import androidx.compose.material3.tokens.TimePickerTokens.ClockDialContainerSize
@@ -204,10 +207,12 @@
         mutableStateOf(TextFieldValue(text = state.minute.toLocalString(2)))
     }
 
-    Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) {
+    Row(
+        modifier = modifier.padding(bottom = TimeInputBottomPadding),
+        verticalAlignment = Alignment.Top
+    ) {
         TimePickerTextField(
             modifier = Modifier
-                .size(TimeSelectorContainerWidth, TimeSelectorContainerHeight)
                 .onKeyEvent { event ->
                     // Zero == 48, Nine == 57
                     val switchFocus = event.utf16CodePoint in 48..57 &&
@@ -226,7 +231,7 @@
                     state = state,
                     value = newValue,
                     prevValue = hourValue,
-                    max = 12,
+                    max = if (state.is24hour) 23 else 12,
                 ) { hourValue = it }
             },
             state = state,
@@ -241,7 +246,6 @@
         DisplaySeparator()
         TimePickerTextField(
             modifier = Modifier
-                .size(TimeSelectorContainerWidth, TimeSelectorContainerHeight)
                 .onPreviewKeyEvent { event ->
                     // 0 == KEYCODE_DEL
                     val switchFocus = event.utf16CodePoint == 0 &&
@@ -274,8 +278,9 @@
             colors = colors,
         )
 
-        Spacer(modifier = Modifier.width(PeriodToggleMargin))
-        PeriodToggle(state = state, colors = colors)
+        if (!state.is24hour) {
+            PeriodToggle(state = state, colors = colors)
+        }
     }
 }
 
@@ -540,15 +545,13 @@
 
     internal var selection by mutableStateOf(Selection.Hour)
     internal var isAfternoonToggle by mutableStateOf(initialHour > 12 && !is24Hour)
-    internal var isInnerCircle by mutableStateOf(initialHour > 12 || initialHour == 0)
+    internal var isInnerCircle by mutableStateOf(initialHour >= 12)
 
-    private var hourAngle by mutableStateOf(RadiansPerHour * initialHour % 12 - FullCircle / 4)
-    private var minuteAngle by mutableStateOf(RadiansPerMinute * initialMinute - FullCircle / 4)
+    internal var hourAngle by mutableStateOf(RadiansPerHour * initialHour % 12 - FullCircle / 4)
+    internal var minuteAngle by mutableStateOf(RadiansPerMinute * initialMinute - FullCircle / 4)
 
     private val mutex = MutatorMutex()
-    private val isAfternoon by derivedStateOf {
-        (is24hour && isInnerCircle && hourAngle.toHour() != 0) || isAfternoonToggle
-    }
+    private val isAfternoon by derivedStateOf { is24hour && isInnerCircle || isAfternoonToggle }
 
     internal val currentAngle = Animatable(hourAngle)
 
@@ -557,6 +560,7 @@
     }
 
     internal fun setHour(hour: Int) {
+        isInnerCircle = hour > 12 || hour == 0
         hourAngle = RadiansPerHour * hour % 12 - FullCircle / 4
     }
 
@@ -634,8 +638,8 @@
         fun Saver(): Saver<TimePickerState, *> = Saver(
             save = {
                 listOf(
-                    it.minute,
                     it.hour,
+                    it.minute,
                     it.is24hour
                 )
             },
@@ -670,7 +674,6 @@
             colors = colors
         )
         if (!state.is24hour) {
-            Spacer(modifier = Modifier.width(PeriodToggleTopMargin))
             PeriodToggle(state, colors)
         }
     }
@@ -686,12 +689,12 @@
 
     val shape = PeriodSelectorContainerShape.toShape() as CornerBasedShape
     val contentDescription = getString(Strings.TimePickerPeriodToggle)
-    Column(
-        Modifier
-            .semantics { this.contentDescription = contentDescription }
-            .selectableGroup()
-            .size(PeriodSelectorVerticalContainerWidth, PeriodSelectorVerticalContainerHeight)
-            .border(border = borderStroke, shape = shape)
+    Column(Modifier
+        .padding(start = PeriodToggleMargin)
+        .semantics { this.contentDescription = contentDescription }
+        .selectableGroup()
+        .size(PeriodSelectorVerticalContainerWidth, PeriodSelectorVerticalContainerHeight)
+        .border(border = borderStroke, shape = shape)
     ) {
         ToggleItem(
             checked = !state.isAfternoonToggle,
@@ -826,7 +829,7 @@
 
 @OptIn(ExperimentalMaterial3Api::class)
 @Composable
-private fun ClockFace(state: TimePickerState, colors: TimePickerColors) {
+internal fun ClockFace(state: TimePickerState, colors: TimePickerColors) {
     Crossfade(
         modifier = Modifier
             .background(shape = CircleShape, color = colors.clockDialColor)
@@ -1069,7 +1072,7 @@
         if (newValue <= max) {
             if (selection == Selection.Hour) {
                 state.setHour(newValue)
-                if (newValue > 1) {
+                if (newValue > 1 && !state.is24hour) {
                     state.selection = Selection.Minute
                 }
             } else {
@@ -1156,13 +1159,18 @@
             }
         }
 
-        val label = if (selection == Selection.Hour) {
-            getString(Strings.TimePickerHour)
-        } else {
-            getString(Strings.TimePickerMinute)
-        }
-
-        Text(text = label)
+        Text(
+            modifier = Modifier.offset(y = SupportLabelTop),
+            text = getString(
+                if (selection == Selection.Hour) {
+                    Strings.TimePickerHour
+                } else {
+                    Strings.TimePickerMinute
+                }
+            ),
+            style = MaterialTheme.typography.fromToken(TimeInputTokens.TimeFieldSupportingTextFont)
+                .copy(color = TimeInputTokens.TimeFieldSupportingTextColor.toColor())
+        )
     }
 
     LaunchedEffect(state.selection) {
@@ -1240,7 +1248,7 @@
 
     if (start > PI && end < PI) {
         end += FullCircle
-    } else if (current < PI && new > PI) {
+    } else if (start < PI && end > PI) {
         start += FullCircle
     }
 
@@ -1279,9 +1287,10 @@
 private val InnerCircleRadius = 69.dp
 private val ClockDisplayBottomMargin = 36.dp
 private val ClockFaceBottomMargin = 24.dp
-private val PeriodToggleTopMargin = 12.dp
 private val DisplaySeparatorWidth = 24.dp
 
+private val SupportLabelTop = 7.dp
+private val TimeInputBottomPadding = 24.dp
 private val MaxDistance = 74.dp
 private val MinimumInteractiveSize = 48.dp
 private val Minutes = listOf(0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55)
diff --git a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/Tooltip.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/Tooltip.kt
index 6fb2ac6..ff8f7f7 100644
--- a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/Tooltip.kt
+++ b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/Tooltip.kt
@@ -24,7 +24,6 @@
 import androidx.compose.animation.core.updateTransition
 import androidx.compose.foundation.MutatePriority
 import androidx.compose.foundation.MutatorMutex
-import androidx.compose.foundation.focusable
 import androidx.compose.foundation.gestures.awaitEachGesture
 import androidx.compose.foundation.gestures.awaitFirstDown
 import androidx.compose.foundation.gestures.waitForUpOrCancellation
@@ -57,9 +56,8 @@
 import androidx.compose.ui.input.pointer.pointerInput
 import androidx.compose.ui.platform.LocalDensity
 import androidx.compose.ui.platform.debugInspectorInfo
-import androidx.compose.ui.semantics.LiveRegionMode
-import androidx.compose.ui.semantics.liveRegion
 import androidx.compose.ui.semantics.onLongClick
+import androidx.compose.ui.semantics.paneTitle
 import androidx.compose.ui.semantics.semantics
 import androidx.compose.ui.unit.Dp
 import androidx.compose.ui.unit.IntOffset
@@ -67,7 +65,6 @@
 import androidx.compose.ui.unit.IntSize
 import androidx.compose.ui.unit.LayoutDirection
 import androidx.compose.ui.unit.dp
-import androidx.compose.ui.window.Popup
 import androidx.compose.ui.window.PopupPositionProvider
 import kotlinx.coroutines.delay
 import kotlinx.coroutines.launch
@@ -249,7 +246,8 @@
     Box {
         val transition = updateTransition(tooltipState.isVisible, label = "Tooltip transition")
         if (transition.currentState || transition.targetState) {
-            Popup(
+            val tooltipPaneDescription = getString(Strings.TooltipPaneDescription)
+            TooltipPopup(
                 popupPositionProvider = tooltipPositionProvider,
                 onDismissRequest = {
                     if (tooltipState.isVisible) {
@@ -265,8 +263,7 @@
                             minHeight = TooltipMinHeight
                         )
                         .animateTooltip(transition)
-                        .focusable()
-                        .semantics { liveRegion = LiveRegionMode.Polite },
+                        .semantics { paneTitle = tooltipPaneDescription },
                     shape = shape,
                     color = containerColor,
                     shadowElevation = elevation,
@@ -426,121 +423,6 @@
     }
 }
 
-private class PlainTooltipPositionProvider(
-    val tooltipAnchorPadding: Int
-) : PopupPositionProvider {
-    override fun calculatePosition(
-        anchorBounds: IntRect,
-        windowSize: IntSize,
-        layoutDirection: LayoutDirection,
-        popupContentSize: IntSize
-    ): IntOffset {
-        val x = anchorBounds.left + (anchorBounds.width - popupContentSize.width) / 2
-
-        // Tooltip prefers to be above the anchor,
-        // but if this causes the tooltip to overlap with the anchor
-        // then we place it below the anchor
-        var y = anchorBounds.top - popupContentSize.height - tooltipAnchorPadding
-        if (y < 0)
-            y = anchorBounds.bottom + tooltipAnchorPadding
-        return IntOffset(x, y)
-    }
-}
-
-private data class RichTooltipPositionProvider(
-    val tooltipAnchorPadding: Int
-) : PopupPositionProvider {
-    override fun calculatePosition(
-        anchorBounds: IntRect,
-        windowSize: IntSize,
-        layoutDirection: LayoutDirection,
-        popupContentSize: IntSize
-    ): IntOffset {
-        var x = anchorBounds.right
-        // Try to shift it to the left of the anchor
-        // if the tooltip would collide with the right side of the screen
-        if (x + popupContentSize.width > windowSize.width) {
-            x = anchorBounds.left - popupContentSize.width
-            // Center if it'll also collide with the left side of the screen
-            if (x < 0) x = anchorBounds.left + (anchorBounds.width - popupContentSize.width) / 2
-        }
-
-        // Tooltip prefers to be above the anchor,
-        // but if this causes the tooltip to overlap with the anchor
-        // then we place it below the anchor
-        var y = anchorBounds.top - popupContentSize.height - tooltipAnchorPadding
-        if (y < 0)
-            y = anchorBounds.bottom + tooltipAnchorPadding
-        return IntOffset(x, y)
-    }
-}
-
-private fun Modifier.textVerticalPadding(
-    subheadExists: Boolean,
-    actionExists: Boolean
-): Modifier {
-    return if (!subheadExists && !actionExists) {
-        this.padding(vertical = PlainTooltipVerticalPadding)
-    } else {
-        this
-            .paddingFromBaseline(top = HeightFromSubheadToTextFirstLine)
-            .padding(bottom = TextBottomPadding)
-    }
-}
-
-private fun Modifier.animateTooltip(
-    transition: Transition<Boolean>
-): Modifier = composed(
-    inspectorInfo = debugInspectorInfo {
-        name = "animateTooltip"
-        properties["transition"] = transition
-    }
-) {
-    val scale by transition.animateFloat(
-        transitionSpec = {
-            if (false isTransitioningTo true) {
-                // show tooltip
-                tween(
-                    durationMillis = TooltipFadeInDuration,
-                    easing = LinearOutSlowInEasing
-                )
-            } else {
-                // dismiss tooltip
-                tween(
-                    durationMillis = TooltipFadeOutDuration,
-                    easing = LinearOutSlowInEasing
-                )
-            }
-        },
-        label = "tooltip transition: scaling"
-    ) { if (it) 1f else 0.8f }
-
-    val alpha by transition.animateFloat(
-        transitionSpec = {
-            if (false isTransitioningTo true) {
-                // show tooltip
-                tween(
-                    durationMillis = TooltipFadeInDuration,
-                    easing = LinearEasing
-                )
-            } else {
-                // dismiss tooltip
-                tween(
-                    durationMillis = TooltipFadeOutDuration,
-                    easing = LinearEasing
-                )
-            }
-        },
-        label = "tooltip transition: alpha"
-    ) { if (it) 1f else 0f }
-
-    this.graphicsLayer(
-        scaleX = scale,
-        scaleY = scale,
-        alpha = alpha
-    )
-}
-
 /**
  * Scope of [PlainTooltipBox] and RichTooltipBox
  */
@@ -555,34 +437,6 @@
 }
 
 /**
- * The state that is associated with an instance of a tooltip.
- * Each instance of tooltips should have its own [TooltipState] it
- * will be used to synchronize the tooltips shown via [TooltipSync].
- */
-@Stable
-@ExperimentalMaterial3Api
-internal sealed interface TooltipState {
-    /**
-     * [Boolean] that will be used to update the visibility
-     * state of the associated tooltip.
-     */
-    val isVisible: Boolean
-
-    /**
-     * Show the tooltip associated with the current [TooltipState].
-     * When this method is called all of the other tooltips currently
-     * being shown will dismiss.
-     */
-    suspend fun show()
-
-    /**
-     * Dismiss the tooltip associated with
-     * this [TooltipState] if it's currently being shown.
-     */
-    suspend fun dismiss()
-}
-
-/**
  * The [TooltipState] that should be used with [RichTooltipBox]
  */
 @Stable
@@ -661,6 +515,83 @@
 }
 
 /**
+ * The state that is associated with an instance of a tooltip.
+ * Each instance of tooltips should have its own [TooltipState] it
+ * will be used to synchronize the tooltips shown via [TooltipSync].
+ */
+@Stable
+@ExperimentalMaterial3Api
+internal sealed interface TooltipState {
+    /**
+     * [Boolean] that will be used to update the visibility
+     * state of the associated tooltip.
+     */
+    val isVisible: Boolean
+
+    /**
+     * Show the tooltip associated with the current [TooltipState].
+     * When this method is called all of the other tooltips currently
+     * being shown will dismiss.
+     */
+    suspend fun show()
+
+    /**
+     * Dismiss the tooltip associated with
+     * this [TooltipState] if it's currently being shown.
+     */
+    suspend fun dismiss()
+}
+
+private class PlainTooltipPositionProvider(
+    val tooltipAnchorPadding: Int
+) : PopupPositionProvider {
+    override fun calculatePosition(
+        anchorBounds: IntRect,
+        windowSize: IntSize,
+        layoutDirection: LayoutDirection,
+        popupContentSize: IntSize
+    ): IntOffset {
+        val x = anchorBounds.left + (anchorBounds.width - popupContentSize.width) / 2
+
+        // Tooltip prefers to be above the anchor,
+        // but if this causes the tooltip to overlap with the anchor
+        // then we place it below the anchor
+        var y = anchorBounds.top - popupContentSize.height - tooltipAnchorPadding
+        if (y < 0)
+            y = anchorBounds.bottom + tooltipAnchorPadding
+        return IntOffset(x, y)
+    }
+}
+
+private data class RichTooltipPositionProvider(
+    val tooltipAnchorPadding: Int
+) : PopupPositionProvider {
+    override fun calculatePosition(
+        anchorBounds: IntRect,
+        windowSize: IntSize,
+        layoutDirection: LayoutDirection,
+        popupContentSize: IntSize
+    ): IntOffset {
+        var x = anchorBounds.right
+        // Try to shift it to the left of the anchor
+        // if the tooltip would collide with the right side of the screen
+        if (x + popupContentSize.width > windowSize.width) {
+            x = anchorBounds.left - popupContentSize.width
+            // Center if it'll also collide with the left side of the screen
+            if (x < 0) x = anchorBounds.left + (anchorBounds.width - popupContentSize.width) / 2
+        }
+
+        // Tooltip prefers to be above the anchor,
+        // but if this causes the tooltip to overlap with the anchor
+        // then we place it below the anchor
+        var y = anchorBounds.top - popupContentSize.height - tooltipAnchorPadding
+        if (y < 0)
+            y = anchorBounds.bottom + tooltipAnchorPadding
+        return IntOffset(x, y)
+    }
+}
+
+/**
  * Object used to synchronize
  * multiple [TooltipState]s, ensuring that there will
  * only be one tooltip shown on the screen at any given time.
@@ -746,6 +677,78 @@
     }
 }
 
+private fun Modifier.textVerticalPadding(
+    subheadExists: Boolean,
+    actionExists: Boolean
+): Modifier {
+    return if (!subheadExists && !actionExists) {
+        this.padding(vertical = PlainTooltipVerticalPadding)
+    } else {
+        this
+            .paddingFromBaseline(top = HeightFromSubheadToTextFirstLine)
+            .padding(bottom = TextBottomPadding)
+    }
+}
+
+private fun Modifier.animateTooltip(
+    transition: Transition<Boolean>
+): Modifier = composed(
+    inspectorInfo = debugInspectorInfo {
+        name = "animateTooltip"
+        properties["transition"] = transition
+    }
+) {
+    val scale by transition.animateFloat(
+        transitionSpec = {
+            if (false isTransitioningTo true) {
+                // show tooltip
+                tween(
+                    durationMillis = TooltipFadeInDuration,
+                    easing = LinearOutSlowInEasing
+                )
+            } else {
+                // dismiss tooltip
+                tween(
+                    durationMillis = TooltipFadeOutDuration,
+                    easing = LinearOutSlowInEasing
+                )
+            }
+        },
+        label = "tooltip transition: scaling"
+    ) { if (it) 1f else 0.8f }
+
+    val alpha by transition.animateFloat(
+        transitionSpec = {
+            if (false isTransitioningTo true) {
+                // show tooltip
+                tween(
+                    durationMillis = TooltipFadeInDuration,
+                    easing = LinearEasing
+                )
+            } else {
+                // dismiss tooltip
+                tween(
+                    durationMillis = TooltipFadeOutDuration,
+                    easing = LinearEasing
+                )
+            }
+        },
+        label = "tooltip transition: alpha"
+    ) { if (it) 1f else 0f }
+
+    this.graphicsLayer(
+        scaleX = scale,
+        scaleY = scale,
+        alpha = alpha
+    )
+}
+
+internal expect fun TooltipPopup(
+    popupPositionProvider: PopupPositionProvider,
+    onDismissRequest: () -> Unit,
+    content: @Composable () -> Unit
+)
+
 private val TooltipAnchorPadding = 4.dp
 internal val TooltipMinHeight = 24.dp
 internal val TooltipMinWidth = 40.dp
diff --git a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/tokens/ListTokens.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/tokens/ListTokens.kt
index b5a7be1..b9be5b0 100644
--- a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/tokens/ListTokens.kt
+++ b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/tokens/ListTokens.kt
@@ -13,7 +13,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-// VERSION: v0_117
+// VERSION: v0_159
 // GENERATED CODE - DO NOT MODIFY BY HAND
 
 package androidx.compose.material3.tokens
@@ -22,14 +22,13 @@
 internal object ListTokens {
     val ListItemContainerColor = ColorSchemeKeyTokens.Surface
     val ListItemContainerElevation = ElevationTokens.Level0
-    val ListItemContainerHeight = 56.0.dp
     val ListItemContainerShape = ShapeKeyTokens.CornerNone
     val ListItemDisabledLabelTextColor = ColorSchemeKeyTokens.OnSurface
-    const val ListItemDisabledLabelTextOpacity = 0.3f
+    val ListItemDisabledLabelTextOpacity = 0.3f
     val ListItemDisabledLeadingIconColor = ColorSchemeKeyTokens.OnSurface
-    const val ListItemDisabledLeadingIconOpacity = 0.38f
+    val ListItemDisabledLeadingIconOpacity = 0.38f
     val ListItemDisabledTrailingIconColor = ColorSchemeKeyTokens.OnSurface
-    const val ListItemDisabledTrailingIconOpacity = 0.38f
+    val ListItemDisabledTrailingIconOpacity = 0.38f
     val ListItemDraggedContainerElevation = ElevationTokens.Level4
     val ListItemDraggedLabelTextColor = ColorSchemeKeyTokens.OnSurface
     val ListItemDraggedLeadingIconColor = ColorSchemeKeyTokens.OnSurfaceVariant
@@ -55,6 +54,7 @@
     val ListItemLeadingImageWidth = 56.0.dp
     val ListItemLeadingVideoShape = ShapeKeyTokens.CornerNone
     val ListItemLeadingVideoWidth = 100.0.dp
+    val ListItemOneLineContainerHeight = 56.0.dp
     val ListItemOverlineColor = ColorSchemeKeyTokens.OnSurfaceVariant
     val ListItemOverlineFont = TypographyKeyTokens.LabelSmall
     val ListItemPressedLabelTextColor = ColorSchemeKeyTokens.OnSurface
@@ -64,9 +64,11 @@
     val ListItemSmallLeadingVideoHeight = 56.0.dp
     val ListItemSupportingTextColor = ColorSchemeKeyTokens.OnSurfaceVariant
     val ListItemSupportingTextFont = TypographyKeyTokens.BodyMedium
+    val ListItemThreeLineContainerHeight = 88.0.dp
     val ListItemTrailingIconColor = ColorSchemeKeyTokens.OnSurfaceVariant
     val ListItemTrailingIconSize = 24.0.dp
     val ListItemTrailingSupportingTextColor = ColorSchemeKeyTokens.OnSurfaceVariant
     val ListItemTrailingSupportingTextFont = TypographyKeyTokens.LabelSmall
+    val ListItemTwoLineContainerHeight = 72.0.dp
     val ListItemUnselectedTrailingIconColor = ColorSchemeKeyTokens.OnSurface
 }
\ No newline at end of file
diff --git a/compose/material3/material3/src/desktopMain/kotlin/androidx/compose/material/Strings.desktop.kt b/compose/material3/material3/src/desktopMain/kotlin/androidx/compose/material/Strings.desktop.kt
index f0184f4..7a91661 100644
--- a/compose/material3/material3/src/desktopMain/kotlin/androidx/compose/material/Strings.desktop.kt
+++ b/compose/material3/material3/src/desktopMain/kotlin/androidx/compose/material/Strings.desktop.kt
@@ -66,6 +66,7 @@
         Strings.DateRangePickerEndHeadline -> "End date"
         Strings.DateRangePickerScrollToShowNextMonth -> "Scroll to show the next month"
         Strings.DateRangePickerScrollToShowPreviousMonth -> "Scroll to show the previous month"
+        Strings.DateRangePickerDayInRange -> "In range"
         Strings.DateRangeInputTitle -> "Enter dates"
         Strings.DateRangeInputInvalidRangeInput -> "Invalid date range input"
         Strings.TooltipLongPressLabel -> "Show tooltip"
@@ -79,6 +80,7 @@
         Strings.TimePicker24HourSuffix -> "%1$ hours"
         Strings.TimePickerMinute -> "Minute"
         Strings.TimePickerHour -> "Hour"
+        Strings.TooltipPaneDescription -> "Tooltip"
         else -> ""
     }
 }
diff --git a/compose/material3/material3/src/desktopMain/kotlin/androidx/compose/material/TooltipPopup.desktop.kt b/compose/material3/material3/src/desktopMain/kotlin/androidx/compose/material/TooltipPopup.desktop.kt
new file mode 100644
index 0000000..95a6f96
--- /dev/null
+++ b/compose/material3/material3/src/desktopMain/kotlin/androidx/compose/material/TooltipPopup.desktop.kt
@@ -0,0 +1,33 @@
+/*
+ * 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.compose.material3
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.window.Popup
+import androidx.compose.ui.window.PopupPositionProvider
+
+@Composable
+@ExperimentalMaterial3Api
+internal actual fun TooltipPopup(
+    popupPositionProvider: PopupPositionProvider,
+    onDismissRequest: () -> Unit,
+    content: @Composable () -> Unit
+) = Popup(
+    popupPositionProvider = popupPositionProvider,
+    onDismissRequest = onDismissRequest,
+    content = content
+)
\ No newline at end of file
diff --git a/compose/runtime/design/movable-content.md b/compose/runtime/design/movable-content.md
index 0e990b0..fdf3025 100644
--- a/compose/runtime/design/movable-content.md
+++ b/compose/runtime/design/movable-content.md
@@ -282,7 +282,7 @@
 #### Alternate (C) considered: Shared state
 
 With this option all the state of each invocation of the movable content is shared between all
-invocations. This, effectively, indirectly hoists the state objects in the movable conent for
+invocations. This, effectively, indirectly hoists the state objects in the movable content for
 use in multiple places in the composition.
 
 The nodes themselves can be shared as the applier would be notified when it is requested to add a
diff --git a/compose/ui/ui-graphics/src/androidMain/kotlin/androidx/compose/ui/graphics/AndroidPaint.android.kt b/compose/ui/ui-graphics/src/androidMain/kotlin/androidx/compose/ui/graphics/AndroidPaint.android.kt
index 84ef9b00..aee6ddb 100644
--- a/compose/ui/ui-graphics/src/androidMain/kotlin/androidx/compose/ui/graphics/AndroidPaint.android.kt
+++ b/compose/ui/ui-graphics/src/androidMain/kotlin/androidx/compose/ui/graphics/AndroidPaint.android.kt
@@ -60,8 +60,10 @@
     override var blendMode: BlendMode
         get() = _blendMode
         set(value) {
-            _blendMode = value
-            internalPaint.setNativeBlendMode(value)
+            if (_blendMode != value) {
+                _blendMode = value
+                internalPaint.setNativeBlendMode(value)
+            }
         }
 
     override var style: PaintingStyle
diff --git a/compose/ui/ui-graphics/src/commonMain/kotlin/androidx/compose/ui/graphics/Color.kt b/compose/ui/ui-graphics/src/commonMain/kotlin/androidx/compose/ui/graphics/Color.kt
index ac1ae252..ff000d5 100644
--- a/compose/ui/ui-graphics/src/commonMain/kotlin/androidx/compose/ui/graphics/Color.kt
+++ b/compose/ui/ui-graphics/src/commonMain/kotlin/androidx/compose/ui/graphics/Color.kt
@@ -636,19 +636,7 @@
 @Stable
 // @ColorInt
 fun Color.toArgb(): Int {
-    val colorSpace = colorSpace
-    if (colorSpace.isSrgb) {
-        return (this.value shr 32).toInt()
-    }
-
-    val color = getComponents()
-    // The transformation saturates the output
-    colorSpace.connect().transform(color)
-
-    return (color[3] * 255.0f + 0.5f).toInt() shl 24 or
-        ((color[0] * 255.0f + 0.5f).toInt() shl 16) or
-        ((color[1] * 255.0f + 0.5f).toInt() shl 8) or
-        (color[2] * 255.0f + 0.5f).toInt()
+    return (convert(ColorSpaces.Srgb).value shr 32).toInt()
 }
 
 /**
diff --git a/compose/ui/ui-test-junit4/src/androidMain/kotlin/androidx/compose/ui/test/ComposeUiTest.android.kt b/compose/ui/ui-test-junit4/src/androidMain/kotlin/androidx/compose/ui/test/ComposeUiTest.android.kt
index 7b88012..10e97fa 100644
--- a/compose/ui/ui-test-junit4/src/androidMain/kotlin/androidx/compose/ui/test/ComposeUiTest.android.kt
+++ b/compose/ui/ui-test-junit4/src/androidMain/kotlin/androidx/compose/ui/test/ComposeUiTest.android.kt
@@ -43,8 +43,8 @@
 import androidx.compose.ui.test.junit4.awaitComposeRoots
 import androidx.compose.ui.test.junit4.isOnUiThread
 import androidx.compose.ui.test.junit4.waitForComposeRoots
-import androidx.compose.ui.text.input.EditCommand
-import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.ExperimentalTextApi
+import androidx.compose.ui.text.input.TextInputForTests
 import androidx.compose.ui.unit.Density
 import androidx.test.core.app.ActivityScenario
 import androidx.test.core.app.ApplicationProvider
@@ -542,25 +542,14 @@
         override val mainClock: MainTestClock
             get() = mainClockImpl
 
-        override fun sendTextInputCommand(node: SemanticsNode, command: List<EditCommand>) {
+        @OptIn(ExperimentalTextApi::class)
+        override fun performTextInput(node: SemanticsNode, action: TextInputForTests.() -> Unit) {
             val owner = node.root as ViewRootForTest
 
             test.runOnIdle {
-                val textInputService = owner.getTextInputServiceOrDie()
-                val onEditCommand = textInputService.onEditCommand
-                    ?: throw IllegalStateException("No input session started. Missing a focus?")
-                onEditCommand(command)
-            }
-        }
-
-        override fun sendImeAction(node: SemanticsNode, actionSpecified: ImeAction) {
-            val owner = node.root as ViewRootForTest
-
-            test.runOnIdle {
-                val textInputService = owner.getTextInputServiceOrDie()
-                val onImeActionPerformed = textInputService.onImeActionPerformed
-                    ?: throw IllegalStateException("No input session started. Missing a focus?")
-                onImeActionPerformed.invoke(actionSpecified)
+                val textInput = owner.textInputForTests
+                    ?: error("No input session started. Missing a focus?")
+                action(textInput)
             }
         }
 
@@ -572,13 +561,6 @@
             waitForIdle(atLeastOneRootExpected)
             return composeRootRegistry.getRegisteredComposeRoots()
         }
-
-        private fun ViewRootForTest.getTextInputServiceOrDie(): TextInputServiceForTests {
-            return textInputService as? TextInputServiceForTests
-                ?: throw IllegalStateException(
-                    "Text input service wrapper not set up! Did you use ComposeTestRule?"
-                )
-        }
     }
 }
 
diff --git a/compose/ui/ui-test-junit4/src/commonMain/kotlin/androidx/compose/ui/test/junit4/TextInputServiceForTests.kt b/compose/ui/ui-test-junit4/src/commonMain/kotlin/androidx/compose/ui/test/junit4/TextInputServiceForTests.kt
index fcd3ca1..b029929 100644
--- a/compose/ui/ui-test-junit4/src/commonMain/kotlin/androidx/compose/ui/test/junit4/TextInputServiceForTests.kt
+++ b/compose/ui/ui-test-junit4/src/commonMain/kotlin/androidx/compose/ui/test/junit4/TextInputServiceForTests.kt
@@ -16,11 +16,14 @@
 
 package androidx.compose.ui.test.junit4
 
+import androidx.compose.ui.text.ExperimentalTextApi
+import androidx.compose.ui.text.input.CommitTextCommand
 import androidx.compose.ui.text.input.EditCommand
 import androidx.compose.ui.text.input.ImeAction
 import androidx.compose.ui.text.input.ImeOptions
 import androidx.compose.ui.text.input.PlatformTextInputService
 import androidx.compose.ui.text.input.TextFieldValue
+import androidx.compose.ui.text.input.TextInputForTests
 import androidx.compose.ui.text.input.TextInputService
 import androidx.compose.ui.text.input.TextInputSession
 
@@ -31,12 +34,18 @@
  * accept input from the IME. Here we grab that callback so we can fetch it commands the same
  * way IME would do.
  */
+@OptIn(ExperimentalTextApi::class)
 internal class TextInputServiceForTests(
     platformTextInputService: PlatformTextInputService
-) : TextInputService(platformTextInputService) {
+) : TextInputService(platformTextInputService), TextInputForTests {
 
-    var onEditCommand: ((List<EditCommand>) -> Unit)? = null
-    var onImeActionPerformed: ((ImeAction) -> Unit)? = null
+    private class Session(
+        var imeOptions: ImeOptions,
+        var onEditCommand: (List<EditCommand>) -> Unit,
+        var onImeActionPerformed: (ImeAction) -> Unit,
+    )
+
+    private var session: Session? = null
 
     override fun startInput(
         value: TextFieldValue,
@@ -44,8 +53,11 @@
         onEditCommand: (List<EditCommand>) -> Unit,
         onImeActionPerformed: (ImeAction) -> Unit
     ): TextInputSession {
-        this.onEditCommand = onEditCommand
-        this.onImeActionPerformed = onImeActionPerformed
+        session = Session(
+            imeOptions = imeOptions,
+            onEditCommand = onEditCommand,
+            onImeActionPerformed = onImeActionPerformed
+        )
         return super.startInput(
             value,
             imeOptions,
@@ -55,8 +67,29 @@
     }
 
     override fun stopInput(session: TextInputSession) {
-        this.onEditCommand = null
-        this.onImeActionPerformed = null
+        this.session = null
         super.stopInput(session)
     }
+
+    override fun inputTextForTest(text: String) {
+        performEditCommand(listOf(CommitTextCommand(text, 1)))
+    }
+
+    override fun submitTextForTest() {
+        with(requireSession()) {
+            if (imeOptions.imeAction == ImeAction.Default) {
+                throw AssertionError(
+                    "Failed to perform IME action as current node does not specify any."
+                )
+            }
+            onImeActionPerformed(imeOptions.imeAction)
+        }
+    }
+
+    private fun performEditCommand(commands: List<EditCommand>) {
+        requireSession().onEditCommand(commands)
+    }
+
+    private fun requireSession(): Session =
+        session ?: error("No input session started. Missing a focus?")
 }
\ No newline at end of file
diff --git a/compose/ui/ui-test-junit4/src/desktopMain/kotlin/androidx/compose/ui/test/ComposeUiTest.desktop.kt b/compose/ui/ui-test-junit4/src/desktopMain/kotlin/androidx/compose/ui/test/ComposeUiTest.desktop.kt
index 159e7d1..4592e71 100644
--- a/compose/ui/ui-test-junit4/src/desktopMain/kotlin/androidx/compose/ui/test/ComposeUiTest.desktop.kt
+++ b/compose/ui/ui-test-junit4/src/desktopMain/kotlin/androidx/compose/ui/test/ComposeUiTest.desktop.kt
@@ -26,18 +26,18 @@
 import androidx.compose.ui.test.junit4.MainTestClockImpl
 import androidx.compose.ui.test.junit4.UncaughtExceptionHandler
 import androidx.compose.ui.test.junit4.isOnUiThread
-import androidx.compose.ui.text.input.EditCommand
-import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.ExperimentalTextApi
+import androidx.compose.ui.text.input.TextInputForTests
 import androidx.compose.ui.unit.Constraints
 import androidx.compose.ui.unit.Density
 import kotlin.coroutines.CoroutineContext
 import kotlin.coroutines.EmptyCoroutineContext
+import kotlin.coroutines.cancellation.CancellationException
 import kotlinx.coroutines.ExperimentalCoroutinesApi
 import kotlinx.coroutines.test.TestScope
 import kotlinx.coroutines.test.UnconfinedTestDispatcher
 import kotlinx.coroutines.test.runTest
 import kotlinx.coroutines.yield
-import kotlin.coroutines.cancellation.CancellationException
 import org.jetbrains.skia.Surface
 
 @ExperimentalTestApi
@@ -200,11 +200,8 @@
     }
 
     private inner class DesktopTestOwner : TestOwner {
-        override fun sendTextInputCommand(node: SemanticsNode, command: List<EditCommand>) {
-            TODO()
-        }
-
-        override fun sendImeAction(node: SemanticsNode, actionSpecified: ImeAction) {
+        @OptIn(ExperimentalTextApi::class)
+        override fun performTextInput(node: SemanticsNode, action: TextInputForTests.() -> Unit) {
             TODO()
         }
 
diff --git a/compose/ui/ui-test/api/public_plus_experimental_1.4.0-beta02.txt b/compose/ui/ui-test/api/public_plus_experimental_1.4.0-beta02.txt
index 625ea2f..c2c1e14 100644
--- a/compose/ui/ui-test/api/public_plus_experimental_1.4.0-beta02.txt
+++ b/compose/ui/ui-test/api/public_plus_experimental_1.4.0-beta02.txt
@@ -434,9 +434,8 @@
   @androidx.compose.ui.test.InternalTestApi public interface TestOwner {
     method public androidx.compose.ui.test.MainTestClock getMainClock();
     method public java.util.Set<androidx.compose.ui.node.RootForTest> getRoots(boolean atLeastOneRootExpected);
+    method public void performTextInput(androidx.compose.ui.semantics.SemanticsNode node, kotlin.jvm.functions.Function1<? super androidx.compose.ui.text.input.TextInputForTests,kotlin.Unit> action);
     method public <T> T! runOnUiThread(kotlin.jvm.functions.Function0<? extends T> action);
-    method public void sendImeAction(androidx.compose.ui.semantics.SemanticsNode node, int actionSpecified);
-    method public void sendTextInputCommand(androidx.compose.ui.semantics.SemanticsNode node, java.util.List<? extends androidx.compose.ui.text.input.EditCommand> command);
     property public abstract androidx.compose.ui.test.MainTestClock mainClock;
   }
 
diff --git a/compose/ui/ui-test/api/public_plus_experimental_current.txt b/compose/ui/ui-test/api/public_plus_experimental_current.txt
index 625ea2f..c2c1e14 100644
--- a/compose/ui/ui-test/api/public_plus_experimental_current.txt
+++ b/compose/ui/ui-test/api/public_plus_experimental_current.txt
@@ -434,9 +434,8 @@
   @androidx.compose.ui.test.InternalTestApi public interface TestOwner {
     method public androidx.compose.ui.test.MainTestClock getMainClock();
     method public java.util.Set<androidx.compose.ui.node.RootForTest> getRoots(boolean atLeastOneRootExpected);
+    method public void performTextInput(androidx.compose.ui.semantics.SemanticsNode node, kotlin.jvm.functions.Function1<? super androidx.compose.ui.text.input.TextInputForTests,kotlin.Unit> action);
     method public <T> T! runOnUiThread(kotlin.jvm.functions.Function0<? extends T> action);
-    method public void sendImeAction(androidx.compose.ui.semantics.SemanticsNode node, int actionSpecified);
-    method public void sendTextInputCommand(androidx.compose.ui.semantics.SemanticsNode node, java.util.List<? extends androidx.compose.ui.text.input.EditCommand> command);
     property public abstract androidx.compose.ui.test.MainTestClock mainClock;
   }
 
diff --git a/compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/TestOwner.kt b/compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/TestOwner.kt
index 9f174da..da6b51a 100644
--- a/compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/TestOwner.kt
+++ b/compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/TestOwner.kt
@@ -19,8 +19,8 @@
 import androidx.compose.ui.node.RootForTest
 import androidx.compose.ui.semantics.SemanticsNode
 import androidx.compose.ui.semantics.getAllSemanticsNodes
-import androidx.compose.ui.text.input.EditCommand
-import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.ExperimentalTextApi
+import androidx.compose.ui.text.input.TextInputForTests
 
 /**
  * Provides necessary services to facilitate testing.
@@ -35,14 +35,11 @@
     val mainClock: MainTestClock
 
     /**
-     * Sends the given list of text commands to the given semantics node.
+     * Runs [action] on the main thread to perform text input via the [TextInputForTests] for the
+     * active text input service for the given semantics node.
      */
-    fun sendTextInputCommand(node: SemanticsNode, command: List<EditCommand>)
-
-    /**
-     * Sends the given IME action to the given semantics node.
-     */
-    fun sendImeAction(node: SemanticsNode, actionSpecified: ImeAction)
+    @OptIn(ExperimentalTextApi::class)
+    fun performTextInput(node: SemanticsNode, action: TextInputForTests.() -> Unit)
 
     /**
      * Runs the given [action] on the ui thread.
diff --git a/compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/TextActions.kt b/compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/TextActions.kt
index 85b231e..7a94d9f 100644
--- a/compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/TextActions.kt
+++ b/compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/TextActions.kt
@@ -16,19 +16,18 @@
 
 package androidx.compose.ui.test
 
-import androidx.compose.ui.semantics.SemanticsProperties
+import androidx.compose.ui.semantics.SemanticsActions
+import androidx.compose.ui.semantics.SemanticsNode
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.ExperimentalTextApi
 import androidx.compose.ui.text.TextRange
-import androidx.compose.ui.text.input.CommitTextCommand
-import androidx.compose.ui.text.input.DeleteAllCommand
-import androidx.compose.ui.text.input.EditCommand
-import androidx.compose.ui.text.input.ImeAction
-import androidx.compose.ui.text.input.SetSelectionCommand
+import androidx.compose.ui.text.input.TextInputForTests
 
 /**
  * Clears the text in this node in similar way to IME.
  */
 fun SemanticsNodeInteraction.performTextClearance() {
-    sendTextInputCommand(listOf(DeleteAllCommand()))
+    performTextReplacement("")
 }
 
 /**
@@ -36,8 +35,9 @@
  *
  * @param text Text to send.
  */
+@OptIn(ExperimentalTextApi::class)
 fun SemanticsNodeInteraction.performTextInput(text: String) {
-    sendTextInputCommand(listOf(CommitTextCommand(text, 1)))
+    performTextInput { inputTextForTest(text) }
 }
 
 /**
@@ -47,7 +47,12 @@
  */
 @ExperimentalTestApi
 fun SemanticsNodeInteraction.performTextInputSelection(selection: TextRange) {
-    sendTextInputCommand(listOf(SetSelectionCommand(selection.min, selection.max)))
+    getNodeAndFocus()
+    performSemanticsAction(SemanticsActions.SetSelection) {
+        // Pass true as the last parameter since this range is relative to the text before any
+        // VisualTransformation is applied.
+        it(selection.min, selection.max, true)
+    }
 }
 
 /**
@@ -58,7 +63,8 @@
  * @param text Text to send.
  */
 fun SemanticsNodeInteraction.performTextReplacement(text: String) {
-    sendTextInputCommand(listOf(DeleteAllCommand(), CommitTextCommand(text, 1)))
+    getNodeAndFocus()
+    performSemanticsAction(SemanticsActions.SetText) { it(AnnotatedString(text)) }
 }
 
 /**
@@ -70,34 +76,31 @@
  * @throws IllegalStateException if tne node did not establish input connection (e.g. is not
  * focused)
  */
+// TODO(b/269633506) Use SemanticsAction for this when available.
+@OptIn(ExperimentalTextApi::class)
 fun SemanticsNodeInteraction.performImeAction() {
-    val errorOnFail = "Failed to perform IME action."
-    val node = fetchSemanticsNode(errorOnFail)
-
-    assert(hasSetTextAction()) { errorOnFail }
-
-    val actionSpecified = node.config.getOrElse(SemanticsProperties.ImeAction) {
-        ImeAction.Default
+    val node = getNodeAndFocus("Failed to perform IME action.")
+    wrapAssertionErrorsWithNodeInfo(selector, node) {
+        @OptIn(InternalTestApi::class)
+        testContext.testOwner.performTextInput(node) {
+            submitTextForTest()
+        }
     }
-    if (actionSpecified == ImeAction.Default) {
-        throw AssertionError(
-            buildGeneralErrorMessage(
-                "Failed to perform IME action as current node does not specify any.", selector, node
-            )
-        )
-    }
-
-    if (!isFocused().matches(node)) {
-        // Get focus
-        performClick()
-    }
-
-    @OptIn(InternalTestApi::class)
-    testContext.testOwner.sendImeAction(node, actionSpecified)
 }
 
-internal fun SemanticsNodeInteraction.sendTextInputCommand(command: List<EditCommand>) {
-    val errorOnFail = "Failed to perform text input."
+@OptIn(ExperimentalTextApi::class)
+internal fun SemanticsNodeInteraction.performTextInput(action: TextInputForTests.() -> Unit) {
+    val node = getNodeAndFocus()
+
+    wrapAssertionErrorsWithNodeInfo(selector, node) {
+        @OptIn(InternalTestApi::class)
+        testContext.testOwner.performTextInput(node, action)
+    }
+}
+
+private fun SemanticsNodeInteraction.getNodeAndFocus(
+    errorOnFail: String = "Failed to perform text input."
+): SemanticsNode {
     val node = fetchSemanticsNode(errorOnFail)
     assert(hasSetTextAction()) { errorOnFail }
 
@@ -106,6 +109,29 @@
         performClick()
     }
 
-    @OptIn(InternalTestApi::class)
-    testContext.testOwner.sendTextInputCommand(node, command)
+    return node
 }
+
+private inline fun <R> wrapAssertionErrorsWithNodeInfo(
+    selector: SemanticsSelector,
+    node: SemanticsNode,
+    block: () -> R
+): R {
+    try {
+        return block()
+    } catch (e: AssertionError) {
+        throw ProxyAssertionError(e.message.orEmpty(), selector, node, e)
+    }
+}
+
+private class ProxyAssertionError(
+    message: String,
+    selector: SemanticsSelector,
+    node: SemanticsNode,
+    cause: Throwable
+) : AssertionError(buildGeneralErrorMessage(message, selector, node), cause) {
+    init {
+        // Duplicate the stack trace to make troubleshooting easier.
+        stackTrace = cause.stackTrace
+    }
+}
\ No newline at end of file
diff --git a/compose/ui/ui-text/api/public_plus_experimental_1.4.0-beta02.txt b/compose/ui/ui-text/api/public_plus_experimental_1.4.0-beta02.txt
index 9e33150..b604bf2 100644
--- a/compose/ui/ui-text/api/public_plus_experimental_1.4.0-beta02.txt
+++ b/compose/ui/ui-text/api/public_plus_experimental_1.4.0-beta02.txt
@@ -1083,6 +1083,41 @@
     property public final char mask;
   }
 
+  @androidx.compose.ui.text.ExperimentalTextApi public sealed interface PlatformTextInput {
+    method public void releaseInputFocus();
+    method public void requestInputFocus();
+  }
+
+  @androidx.compose.ui.text.ExperimentalTextApi public interface PlatformTextInputAdapter {
+    method public android.view.inputmethod.InputConnection? createInputConnection(android.view.inputmethod.EditorInfo outAttrs);
+    method public androidx.compose.ui.text.input.TextInputForTests? getInputForTests();
+    method public default void onDisposed();
+    property public abstract androidx.compose.ui.text.input.TextInputForTests? inputForTests;
+  }
+
+  @androidx.compose.runtime.Immutable @androidx.compose.ui.text.ExperimentalTextApi public fun interface PlatformTextInputPlugin<T extends androidx.compose.ui.text.input.PlatformTextInputAdapter> {
+    method public T createAdapter(androidx.compose.ui.text.input.PlatformTextInput platformTextInput, android.view.View view);
+  }
+
+  @androidx.compose.runtime.Stable @androidx.compose.ui.text.ExperimentalTextApi public sealed interface PlatformTextInputPluginRegistry {
+    method @androidx.compose.runtime.Composable public <T extends androidx.compose.ui.text.input.PlatformTextInputAdapter> T rememberAdapter(androidx.compose.ui.text.input.PlatformTextInputPlugin<T> plugin);
+  }
+
+  @androidx.compose.ui.text.InternalTextApi public final class PlatformTextInputPluginRegistryImpl implements androidx.compose.ui.text.input.PlatformTextInputPluginRegistry {
+    ctor public PlatformTextInputPluginRegistryImpl(kotlin.jvm.functions.Function2<? super androidx.compose.ui.text.input.PlatformTextInputPlugin<?>,? super androidx.compose.ui.text.input.PlatformTextInput,? extends androidx.compose.ui.text.input.PlatformTextInputAdapter> factory);
+    method public androidx.compose.ui.text.input.PlatformTextInputAdapter? getFocusedAdapter();
+    method @androidx.compose.ui.text.InternalTextApi public <T extends androidx.compose.ui.text.input.PlatformTextInputAdapter> androidx.compose.ui.text.input.PlatformTextInputPluginRegistryImpl.AdapterHandle<T> getOrCreateAdapter(androidx.compose.ui.text.input.PlatformTextInputPlugin<T> plugin);
+    method @androidx.compose.runtime.Composable public <T extends androidx.compose.ui.text.input.PlatformTextInputAdapter> T rememberAdapter(androidx.compose.ui.text.input.PlatformTextInputPlugin<T> plugin);
+    property public final androidx.compose.ui.text.input.PlatformTextInputAdapter? focusedAdapter;
+  }
+
+  @androidx.compose.ui.text.InternalTextApi public static final class PlatformTextInputPluginRegistryImpl.AdapterHandle<T extends androidx.compose.ui.text.input.PlatformTextInputAdapter> {
+    ctor public PlatformTextInputPluginRegistryImpl.AdapterHandle(T adapter, kotlin.jvm.functions.Function0<java.lang.Boolean> onDispose);
+    method public boolean dispose();
+    method public T getAdapter();
+    property public final T adapter;
+  }
+
   public interface PlatformTextInputService {
     method public void hideSoftwareKeyboard();
     method public default void notifyFocusedRect(androidx.compose.ui.geometry.Rect rect);
@@ -1149,6 +1184,11 @@
     method public static androidx.compose.ui.text.AnnotatedString getTextBeforeSelection(androidx.compose.ui.text.input.TextFieldValue, int maxChars);
   }
 
+  @androidx.compose.ui.text.ExperimentalTextApi public interface TextInputForTests {
+    method public void inputTextForTest(String text);
+    method @androidx.compose.ui.text.ExperimentalTextApi public void submitTextForTest();
+  }
+
   public class TextInputService {
     ctor public TextInputService(androidx.compose.ui.text.input.PlatformTextInputService platformTextInputService);
     method @Deprecated public final void hideSoftwareKeyboard();
diff --git a/compose/ui/ui-text/api/public_plus_experimental_current.txt b/compose/ui/ui-text/api/public_plus_experimental_current.txt
index 9e33150..b604bf2 100644
--- a/compose/ui/ui-text/api/public_plus_experimental_current.txt
+++ b/compose/ui/ui-text/api/public_plus_experimental_current.txt
@@ -1083,6 +1083,41 @@
     property public final char mask;
   }
 
+  @androidx.compose.ui.text.ExperimentalTextApi public sealed interface PlatformTextInput {
+    method public void releaseInputFocus();
+    method public void requestInputFocus();
+  }
+
+  @androidx.compose.ui.text.ExperimentalTextApi public interface PlatformTextInputAdapter {
+    method public android.view.inputmethod.InputConnection? createInputConnection(android.view.inputmethod.EditorInfo outAttrs);
+    method public androidx.compose.ui.text.input.TextInputForTests? getInputForTests();
+    method public default void onDisposed();
+    property public abstract androidx.compose.ui.text.input.TextInputForTests? inputForTests;
+  }
+
+  @androidx.compose.runtime.Immutable @androidx.compose.ui.text.ExperimentalTextApi public fun interface PlatformTextInputPlugin<T extends androidx.compose.ui.text.input.PlatformTextInputAdapter> {
+    method public T createAdapter(androidx.compose.ui.text.input.PlatformTextInput platformTextInput, android.view.View view);
+  }
+
+  @androidx.compose.runtime.Stable @androidx.compose.ui.text.ExperimentalTextApi public sealed interface PlatformTextInputPluginRegistry {
+    method @androidx.compose.runtime.Composable public <T extends androidx.compose.ui.text.input.PlatformTextInputAdapter> T rememberAdapter(androidx.compose.ui.text.input.PlatformTextInputPlugin<T> plugin);
+  }
+
+  @androidx.compose.ui.text.InternalTextApi public final class PlatformTextInputPluginRegistryImpl implements androidx.compose.ui.text.input.PlatformTextInputPluginRegistry {
+    ctor public PlatformTextInputPluginRegistryImpl(kotlin.jvm.functions.Function2<? super androidx.compose.ui.text.input.PlatformTextInputPlugin<?>,? super androidx.compose.ui.text.input.PlatformTextInput,? extends androidx.compose.ui.text.input.PlatformTextInputAdapter> factory);
+    method public androidx.compose.ui.text.input.PlatformTextInputAdapter? getFocusedAdapter();
+    method @androidx.compose.ui.text.InternalTextApi public <T extends androidx.compose.ui.text.input.PlatformTextInputAdapter> androidx.compose.ui.text.input.PlatformTextInputPluginRegistryImpl.AdapterHandle<T> getOrCreateAdapter(androidx.compose.ui.text.input.PlatformTextInputPlugin<T> plugin);
+    method @androidx.compose.runtime.Composable public <T extends androidx.compose.ui.text.input.PlatformTextInputAdapter> T rememberAdapter(androidx.compose.ui.text.input.PlatformTextInputPlugin<T> plugin);
+    property public final androidx.compose.ui.text.input.PlatformTextInputAdapter? focusedAdapter;
+  }
+
+  @androidx.compose.ui.text.InternalTextApi public static final class PlatformTextInputPluginRegistryImpl.AdapterHandle<T extends androidx.compose.ui.text.input.PlatformTextInputAdapter> {
+    ctor public PlatformTextInputPluginRegistryImpl.AdapterHandle(T adapter, kotlin.jvm.functions.Function0<java.lang.Boolean> onDispose);
+    method public boolean dispose();
+    method public T getAdapter();
+    property public final T adapter;
+  }
+
   public interface PlatformTextInputService {
     method public void hideSoftwareKeyboard();
     method public default void notifyFocusedRect(androidx.compose.ui.geometry.Rect rect);
@@ -1149,6 +1184,11 @@
     method public static androidx.compose.ui.text.AnnotatedString getTextBeforeSelection(androidx.compose.ui.text.input.TextFieldValue, int maxChars);
   }
 
+  @androidx.compose.ui.text.ExperimentalTextApi public interface TextInputForTests {
+    method public void inputTextForTest(String text);
+    method @androidx.compose.ui.text.ExperimentalTextApi public void submitTextForTest();
+  }
+
   public class TextInputService {
     ctor public TextInputService(androidx.compose.ui.text.input.PlatformTextInputService platformTextInputService);
     method @Deprecated public final void hideSoftwareKeyboard();
diff --git a/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/input/PlatformTextInputAdapter.android.kt b/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/input/PlatformTextInputAdapter.android.kt
new file mode 100644
index 0000000..118fedc
--- /dev/null
+++ b/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/input/PlatformTextInputAdapter.android.kt
@@ -0,0 +1,99 @@
+/*
+ * 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.compose.ui.text.input
+
+import android.view.View
+import android.view.inputmethod.EditorInfo
+import android.view.inputmethod.InputConnection
+import androidx.compose.runtime.Immutable
+import androidx.compose.ui.text.ExperimentalTextApi
+
+/**
+ * Defines a plugin to the Compose text input system. Instances of this interface should be
+ * stateless singleton `object`s. They act as both:
+ *  - factories to create instances of [PlatformTextInputAdapter] that know how to talk to
+ *  platform-specific IME APIs, as well as
+ *  - keys into the cache of adapter instances managed by the [PlatformTextInputPluginRegistry].
+ *
+ * To register a plugin with the system, call [PlatformTextInputPluginRegistry.rememberAdapter]
+ * from a composable, probably an implementation of a text field. [createAdapter] will be called
+ * if necessary to instantiate the adapter.
+ *
+ * Implementations are intended to be used only by your text editor implementation, and probably not
+ * exposed as public API.
+ */
+@ExperimentalTextApi
+@Immutable
+actual fun interface PlatformTextInputPlugin<T : PlatformTextInputAdapter> {
+    /**
+     * Creates a new instance of a [PlatformTextInputAdapter] hosted by [view].
+     *
+     * The [PlatformTextInputAdapter] implementation should call
+     * [PlatformTextInput.requestInputFocus] when it's ready to start processing text input in order
+     * to become the active delegate for the platform's IME APIs, and
+     * [PlatformTextInput.releaseInputFocus] to notify the platform that it is no longer processing
+     * input.
+     */
+    fun createAdapter(platformTextInput: PlatformTextInput, view: View): T
+}
+
+/**
+ * An adapter for platform-specific IME APIs to implement a text editor. Instances of this interface
+ * should be created by implementing a singleton [PlatformTextInputPlugin] `object` and passing it
+ * to [PlatformTextInputPluginRegistry.rememberAdapter]. Instances will be created lazily and cached
+ * as long as they are used at least once in a given composition. This allows implementations to
+ * coordinate state between different text fields.
+ *
+ * Implementations of this interface are expected to:
+ * - Call [PlatformTextInput.requestInputFocus] on the [PlatformTextInput] passed to the adapter's
+ *  [PlatformTextInputPlugin] when they are ready to begin processing text input. Platform APIs will
+ *  not be delegated to an adapter unless it holds input focus.
+ * - Implement [createInputConnection] to create an [InputConnection] that talks to the IME.
+ * - Return a [TextInputForTests] instance from [inputForTests] that implements text operations
+ *  defined by the Compose UI testing framework.
+ * - Optionally implement [onDisposed] to clean up any resources when the adapter is no longer used
+ *  in the composition and will be removed from the [PlatformTextInputPluginRegistry]'s cache.
+ *
+ * Implementations are intended to be used only by your text editor implementation, and probably not
+ * exposed as public API. Your adapter can define whatever internal API it needs to communicate with
+ * the rest of your text editor code.
+ */
+@ExperimentalTextApi
+actual interface PlatformTextInputAdapter {
+    // TODO(b/267235947) When fleshing out the desktop actual, we might want to pull some of these
+    //  members up into the expect interface (e.g. maybe inputForTests).
+
+    /**
+     * The [TextInputForTests] used to inject text editing commands by the testing framework.
+     * This should only be called from tests, never in production.
+     */
+    val inputForTests: TextInputForTests?
+
+    /** Delegate for [View.onCreateInputConnection]. */
+    fun createInputConnection(outAttrs: EditorInfo): InputConnection?
+
+    /**
+     * Called when this adapter is not remembered by any composables is removed from the
+     * [PlatformTextInputPluginRegistry].
+     */
+    fun onDisposed() {}
+}
+
+@OptIn(ExperimentalTextApi::class)
+internal actual fun PlatformTextInputAdapter.dispose() {
+    onDisposed()
+}
\ No newline at end of file
diff --git a/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/platform/AndroidTextPaint.android.kt b/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/platform/AndroidTextPaint.android.kt
index 8fd09a9..e515f24 100644
--- a/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/platform/AndroidTextPaint.android.kt
+++ b/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/platform/AndroidTextPaint.android.kt
@@ -80,7 +80,6 @@
     }
 
     fun setColor(color: Color) {
-        color.toArgb()
         if (color.isSpecified) {
             composePaint.color = color
             composePaint.shader = null
diff --git a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/ParagraphStyle.kt b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/ParagraphStyle.kt
index f84ba8f..f97de62 100644
--- a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/ParagraphStyle.kt
+++ b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/ParagraphStyle.kt
@@ -77,6 +77,12 @@
     val textMotion: TextMotion? = null
 ) {
 
+    // these public nullable parameters box - do it now (init) not during every paragraph resolution
+    // for future value(int) parameters please avoid boxing by defining Unspecified
+    internal val textAlignOrDefault: TextAlign = textAlign ?: TextAlign.Start
+    internal val lineBreakOrDefault: LineBreak = lineBreak ?: LineBreak.Simple
+    internal val hyphensOrDefault: Hyphens = hyphens ?: Hyphens.None
+
     @Deprecated(
         "ParagraphStyle constructors that do not take new stable parameters " +
             "like LineHeightStyle, LineBreak, Hyphens are deprecated. Please use the new stable " +
@@ -434,13 +440,13 @@
     style: ParagraphStyle,
     direction: LayoutDirection
 ) = ParagraphStyle(
-    textAlign = style.textAlign ?: TextAlign.Start,
+    textAlign = style.textAlignOrDefault,
     textDirection = resolveTextDirection(direction, style.textDirection),
     lineHeight = if (style.lineHeight.isUnspecified) DefaultLineHeight else style.lineHeight,
     textIndent = style.textIndent ?: TextIndent.None,
     platformStyle = style.platformStyle,
     lineHeightStyle = style.lineHeightStyle,
-    lineBreak = style.lineBreak ?: LineBreak.Simple,
-    hyphens = style.hyphens ?: Hyphens.None,
+    lineBreak = style.lineBreakOrDefault,
+    hyphens = style.hyphensOrDefault,
     textMotion = style.textMotion ?: TextMotion.Static
 )
diff --git a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/input/PlatformTextInputAdapter.kt b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/input/PlatformTextInputAdapter.kt
new file mode 100644
index 0000000..c3f5c92
--- /dev/null
+++ b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/input/PlatformTextInputAdapter.kt
@@ -0,0 +1,335 @@
+/*
+ * 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.compose.ui.text.input
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateMapOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.text.ExperimentalTextApi
+import androidx.compose.ui.text.InternalTextApi
+import androidx.compose.ui.util.fastForEach
+import kotlinx.coroutines.NonCancellable
+import kotlinx.coroutines.launch
+
+private const val DEBUG = false
+
+/** See kdoc on actual interfaces. */
+@ExperimentalTextApi
+@Immutable
+expect interface PlatformTextInputPlugin<T : PlatformTextInputAdapter>
+
+/** See kdoc on actual interfaces. */
+@ExperimentalTextApi
+expect interface PlatformTextInputAdapter
+
+/**
+ * Calls the [PlatformTextInputAdapter]'s onDisposed method. This is done through a proxy method
+ * because expect interfaces aren't allowed to have default implementations.
+ */
+@OptIn(ExperimentalTextApi::class)
+internal expect fun PlatformTextInputAdapter.dispose()
+
+/**
+ * Represents the text input plugin system to instances of [PlatformTextInputAdapter], and provides
+ * methods that allow adapters to interact with it. Instances are passed to
+ * [PlatformTextInputPlugin.createAdapter].
+ */
+@ExperimentalTextApi
+sealed interface PlatformTextInput {
+    /**
+     * Requests that the platform input be connected to this receiver until either:
+     * - [releaseInputFocus] is called, or
+     * - another adapter calls [requestInputFocus].
+     */
+    fun requestInputFocus()
+
+    /**
+     * If this adapter currently holds input focus, tells the platform that it is no longer handling
+     * input. If this adapter does not hold input focus, does nothing.
+     */
+    fun releaseInputFocus()
+}
+
+/**
+ * The entry point to the text input plugin API.
+ *
+ * This is a low-level API for code that talks directly to the platform input method framework
+ * (i.e. `InputMethodManager`). Higher-level text input APIs in the Foundation library are more
+ * appropriate for most cases. _**The only time a new service type and adapter should be defined is
+ * if you are building your own text input system from scratch, including your own `TextField`
+ * composables.**_
+ *
+ * It is expected that the total number of adapters for a given registry will be only one in most
+ * cases, or two in rare cases where an entirely separate text input system – from platform IME
+ * client to `TextField` composables – is being used in the same composition.
+ *
+ * See [rememberAdapter] for more information.
+ */
+// Implementation note: this is separated as a sealed interface + impl pair to avoid exposing
+// @InternalTextApi members to code reading LocalPlatformTextInputAdapterProvider.
+@ExperimentalTextApi
+@Stable
+sealed interface PlatformTextInputPluginRegistry {
+    /**
+     * Returns an instance of the [PlatformTextInputAdapter] type [T] specified by [plugin].
+     *
+     * The returned adapter instance will be shared by all callers of this method passing the same
+     * [plugin] object. The adapter will be created when the first call is made, then cached and
+     * returned by every subsequent call in the same or subsequent compositions. When there are no
+     * longer any calls to this method for a given plugin, the adapter instance will be
+     * [disposed][PlatformTextInputAdapter.onDisposed].
+     *
+     * @param T The type of [PlatformTextInputAdapter] that [plugin] creates.
+     * @param plugin The factory for adapters and the key into the cache of those adapters.
+     */
+    @Composable
+    fun <T : PlatformTextInputAdapter> rememberAdapter(plugin: PlatformTextInputPlugin<T>): T
+}
+
+/**
+ * Implementation of [PlatformTextInputPluginRegistry] that manages a map of adapters to cached
+ * services and allows retrieval of the first active one.
+ *
+ * @param factory A platform-specific function that invokes the appropriate `create` method on
+ * a [PlatformTextInputAdapter] to return a service.
+ */
+// This doesn't violate the EndsWithImpl rule because it's not public API.
+@Suppress("EndsWithImpl")
+@OptIn(ExperimentalTextApi::class)
+@InternalTextApi
+class PlatformTextInputPluginRegistryImpl(
+    private val factory: (
+        PlatformTextInputPlugin<*>,
+        PlatformTextInput
+    ) -> PlatformTextInputAdapter
+) : PlatformTextInputPluginRegistry {
+
+    /**
+     * This must be a snapshot state object because it gets modified by [getOrCreateAdapter] which
+     * may be called from composition.
+     */
+    private val adaptersByPlugin =
+        mutableStateMapOf<PlatformTextInputPlugin<*>, AdapterWithRefCount<*>>()
+
+    private var adaptersMayNeedDisposal = false
+
+    /**
+     * The plugin of the last adapter whose [PlatformTextInput] called
+     * [PlatformTextInput.requestInputFocus] and hasn't called
+     * [PlatformTextInput.releaseInputFocus] yet.
+     *
+     * Not backed by snapshot state – input focus management happens from UI focus events, not from
+     * composition.
+     */
+    private var focusedPlugin: PlatformTextInputPlugin<*>? = null
+
+    /**
+     * Returns the currently-focused adapter, or null if no adapter is focused.
+     *
+     * An adapter can request input focus by calling [PlatformTextInput.requestInputFocus]. It will
+     * keep input focus until either:
+     *  1. It calls [PlatformTextInput.releaseInputFocus], or
+     *  2. Another adapter calls [PlatformTextInput.requestInputFocus].
+     */
+    val focusedAdapter: PlatformTextInputAdapter?
+        get() = adaptersByPlugin[focusedPlugin]?.adapter
+            .also { if (DEBUG) println("Found focused PlatformTextInputAdapter: $it") }
+
+    @Suppress("UnnecessaryOptInAnnotation")
+    @OptIn(ExperimentalTextApi::class)
+    @Composable
+    override fun <T : PlatformTextInputAdapter> rememberAdapter(
+        plugin: PlatformTextInputPlugin<T>
+    ): T {
+        // The AdapterHandle is a RememberObserver, so it will automatically get callbacks for
+        // disposal.
+        val adapterHandle = remember(plugin) { getOrCreateAdapter(plugin) }
+
+        // If this composition is discarded, the refcount increment won't be applied, and any newly
+        // instantiated adapter won't be added to the map, so we only need to handle actual
+        // disposal.
+        val scope = rememberCoroutineScope()
+        DisposableEffect(adapterHandle) {
+            onDispose {
+                if (DEBUG) println("Disposing PlatformTextInputAdapter handle")
+                // Dispose returning true means that the adapter's refcount may have reached zero,
+                // so we should confirm that after all effects have been ran and if necessary
+                // actually dispose it.
+                if (adapterHandle.dispose()) {
+                    // We need to wait to check for tombstoned adapters until after all effects'
+                    // onDisposes have been ran. New coroutines will only be resumed after all
+                    // effects have been ran and changes applied. However, because the coroutine
+                    // scope will also be cancelled by this function being removed from composition,
+                    // we need to launch with the NonCancellable job to ensure it runs.
+                    // Note that dispose() returning true only means that there _may_ be a
+                    // tombstoned adapter. So this may launch a coroutine that no-ops, but that
+                    // should be relatively rare (only when multiple text fields are removed and
+                    // added in the same composition), and is cheap enough that it's not worth the
+                    // extra bookkeeping to avoid.
+                    scope.launch(NonCancellable) { disposeTombstonedAdapters() }
+                }
+            }
+        }
+        return adapterHandle.adapter
+    }
+
+    /**
+     * Returns the text input service [T] managed by [plugin].
+     *
+     * The first time this method is called for a given [PlatformTextInputAdapter], the adapter's
+     * platform-specific factory method is called to instantiate the service. The service is then
+     * added to the cache and returned. Subsequent calls passing the same adapter, over the entire
+     * lifetime of this service provider, will return the same service instance. It is expected that
+     * adapters will be singleton objects, and in most apps there will only ever be one or maybe
+     * two input adapters in use, since each input adapter represents an entire text input
+     * subsystem.
+     *
+     * @param T The type of the [PlatformTextInputAdapter] that [plugin] creates.
+     * @param plugin The factory for service objects and the key into the cache of those objects.
+     */
+    @Suppress("UnnecessaryOptInAnnotation")
+    @OptIn(ExperimentalTextApi::class)
+    @InternalTextApi
+    fun <T : PlatformTextInputAdapter> getOrCreateAdapter(
+        plugin: PlatformTextInputPlugin<T>
+    ): AdapterHandle<T> {
+        if (DEBUG) println("Getting PlatformTextInputAdapter for plugin $plugin")
+        @Suppress("UNCHECKED_CAST")
+        val adapterWithRefCount = (adaptersByPlugin[plugin] as AdapterWithRefCount<T>?)
+            ?: instantiateAdapter(plugin)
+        adapterWithRefCount.incrementRefCount()
+        return AdapterHandle(adapterWithRefCount.adapter, onDispose = {
+            adapterWithRefCount.decrementRefCount()
+        })
+    }
+
+    /**
+     * Cleans up any [PlatformTextInputAdapter] instances that have a 0 refcount.
+     *
+     * Should only be called after the composition is finished, to ensure that adapters won't be
+     * reused later during the same composition.
+     */
+    private fun disposeTombstonedAdapters() {
+        if (DEBUG) println(
+            "Composition applied, checking for tombstoned PlatformTextInputAdapters…"
+        )
+        // This method may be called multiple times for the same frame, but we only need the first
+        // one to do the work.
+        if (adaptersMayNeedDisposal) {
+            adaptersMayNeedDisposal = false
+            val toDispose = adaptersByPlugin.entries.filter { it.value.isRefCountZero }
+            toDispose.fastForEach { (plugin, adapter) ->
+                if (DEBUG) println(
+                    "Disposing PlatformTextInputAdapter. " +
+                        "plugin=$plugin, adapter=$adapter"
+                )
+                if (focusedPlugin == plugin) {
+                    focusedPlugin = null
+                }
+                adaptersByPlugin -= plugin
+                adapter.adapter.dispose()
+            }
+        }
+    }
+
+    private fun <T : PlatformTextInputAdapter> instantiateAdapter(
+        plugin: PlatformTextInputPlugin<T>
+    ): AdapterWithRefCount<T> {
+        val input = AdapterInput(plugin)
+
+        @Suppress("UNCHECKED_CAST")
+        val newAdapter = this.factory(plugin, input) as T
+        val withRefCount = AdapterWithRefCount(newAdapter)
+        adaptersByPlugin[plugin] = withRefCount
+        if (DEBUG) println(
+            "Instantiated new PlatformTextInputAdapter. " +
+                "plugin=$plugin, adapter=$newAdapter"
+        )
+        return withRefCount
+    }
+
+    @InternalTextApi
+    class AdapterHandle<T : PlatformTextInputAdapter>(
+        val adapter: T,
+        private val onDispose: () -> Boolean
+    ) {
+        private var disposed = false
+
+        fun dispose(): Boolean {
+            check(!disposed) { "AdapterHandle already disposed" }
+            disposed = true
+            return onDispose()
+        }
+    }
+
+    private inner class AdapterWithRefCount<T : PlatformTextInputAdapter>(val adapter: T) {
+        /**
+         * This is backed by a MutableState because it is incremented in [getOrCreateAdapter] which
+         * can be called directly from a composition, inside a [remember] block.
+         */
+        private var refCount by mutableStateOf(0)
+
+        val isRefCountZero get() = refCount == 0
+
+        fun incrementRefCount() {
+            refCount++
+            if (DEBUG) println(
+                "Incremented PlatformTextInputAdapter refcount: $refCount (adapter=$adapter)"
+            )
+        }
+
+        fun decrementRefCount(): Boolean {
+            refCount--
+            if (DEBUG) println(
+                "Decremented PlatformTextInputAdapter refcount: $refCount (adapter=$adapter)"
+            )
+            check(refCount >= 0) {
+                "AdapterWithRefCount.decrementRefCount called too many times (refCount=$refCount)"
+            }
+            // Defer actual disposal until after the composition is finished, in case it goes from
+            // 0 back to 1 later during the same composition pass.
+            if (refCount == 0) {
+                adaptersMayNeedDisposal = true
+                return true
+            }
+            return false
+        }
+    }
+
+    private inner class AdapterInput(
+        private val plugin: PlatformTextInputPlugin<*>
+    ) : PlatformTextInput {
+        override fun requestInputFocus() {
+            if (DEBUG) println("PlatformTextInputAdapter requested input focus. plugin=$plugin")
+            focusedPlugin = plugin
+        }
+
+        override fun releaseInputFocus() {
+            if (DEBUG) println("PlatformTextInputAdapter released input focus. plugin=$plugin")
+            if (focusedPlugin == plugin) {
+                focusedPlugin = null
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/input/TextInputForTests.kt b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/input/TextInputForTests.kt
new file mode 100644
index 0000000..955d161
--- /dev/null
+++ b/compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/input/TextInputForTests.kt
@@ -0,0 +1,56 @@
+/*
+ * 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.compose.ui.text.input
+
+import androidx.compose.ui.text.ExperimentalTextApi
+
+/**
+ * Defines additional operations that can be performed on text editors by UI tests that aren't
+ * available as semantics actions. Tests call these methods indirectly, by the various `perform*`
+ * extension functions on `SemanticsNodeInteraction`. [PlatformTextInputAdapter]s implement support
+ * for these operations by returning instances of this interface from
+ * [PlatformTextInputAdapter.inputForTests].
+ *
+ * Implementations of this interface should perform the requested operations at the lowest level
+ * as possible (as close to the system calls as possible) to exercise as much of the production code
+ * as possible. E.g. they should not operate directly on the text buffer, but emulate the calls
+ * the platform would make if the user were performing these operations via the IME.
+ */
+// If new methods need to be added to this interface to support additional testing APIs, they should
+// be given default implementations that throw UnsupportedOperationExceptions. This is not a concern
+// for backwards compatibility because it simply means that tests may not use new perform* methods
+// on older implementations that haven't linked against the newer version of Compose.
+@ExperimentalTextApi
+interface TextInputForTests {
+
+    /**
+     * Sends the given text to this node in similar way to IME. The text should be inserted at the
+     * current cursor position.
+     *
+     * @param text Text to send.
+     */
+    fun inputTextForTest(text: String)
+
+    /**
+     * Performs the submit action configured on the current node, if any.
+     *
+     * On Android, this is the IME action.
+     */
+    // TODO(b/269633168, b/269633506) Remove and implement using semantics instead.
+    @ExperimentalTextApi
+    fun submitTextForTest()
+}
\ No newline at end of file
diff --git a/compose/ui/ui-text/src/desktopMain/kotlin/androidx/compose/ui/text/input/PlatformTextInputAdapter.desktop.kt b/compose/ui/ui-text/src/desktopMain/kotlin/androidx/compose/ui/text/input/PlatformTextInputAdapter.desktop.kt
new file mode 100644
index 0000000..a86bef0
--- /dev/null
+++ b/compose/ui/ui-text/src/desktopMain/kotlin/androidx/compose/ui/text/input/PlatformTextInputAdapter.desktop.kt
@@ -0,0 +1,36 @@
+/*
+ * 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.compose.ui.text.input
+
+import androidx.compose.runtime.Immutable
+import androidx.compose.ui.text.ExperimentalTextApi
+
+// TODO(b/267235947) Flesh this out, document it, and wire it up when ready to integrate new text
+//  field with desktop.
+@ExperimentalTextApi
+@Immutable
+actual interface PlatformTextInputPlugin<T : PlatformTextInputAdapter>
+
+// TODO(b/267235947) Flesh this out, document it, and wire it up when ready to integrate new text
+//  field with desktop.
+@ExperimentalTextApi
+actual interface PlatformTextInputAdapter
+
+@OptIn(ExperimentalTextApi::class)
+internal actual fun PlatformTextInputAdapter.dispose() {
+    // TODO(b/267235947)
+}
\ No newline at end of file
diff --git a/compose/ui/ui-text/src/test/java/androidx/compose/ui/text/TextSpanParagraphStyleTest.kt b/compose/ui/ui-text/src/test/java/androidx/compose/ui/text/TextSpanParagraphStyleTest.kt
index 1cf59fc..2599fb6 100644
--- a/compose/ui/ui-text/src/test/java/androidx/compose/ui/text/TextSpanParagraphStyleTest.kt
+++ b/compose/ui/ui-text/src/test/java/androidx/compose/ui/text/TextSpanParagraphStyleTest.kt
@@ -66,10 +66,10 @@
     @Test
     fun paragraphStyle_is_covered_by_TextStyle() {
         val paragraphStyleProperties = memberProperties(ParagraphStyle::class).filter {
-            it.name != "platformStyle"
+            !it.isKnownUnmatchedProperty()
         }
         val textStyleProperties = memberProperties(TextStyle::class).filter {
-            it.name != "platformStyle"
+            !it.isKnownUnmatchedProperty()
         }
         assertThat(textStyleProperties).containsAtLeastElementsIn(paragraphStyleProperties)
     }
@@ -77,14 +77,27 @@
     @Test
     fun paragraphStyle_properties_is_covered_by_TextStyle() {
         val paragraphStyleProperties = memberProperties(ParagraphStyle::class).filter {
-            it.name != "platformStyle"
+            !it.isKnownUnmatchedProperty()
         }
         val textStyleProperties = memberProperties(TextStyle::class).filter {
-            it.name != "platformStyle"
+            !it.isKnownUnmatchedProperty()
         }
         assertThat(textStyleProperties).containsAtLeastElementsIn(paragraphStyleProperties)
     }
 
+    /**
+     * These properties are known to not have an exact mach
+     */
+    private fun Property.isKnownUnmatchedProperty(): Boolean {
+        return name in listOf(
+            "platformStyle",
+            // these are for boxing optimizations
+            "hyphensOrDefault",
+            "lineBreakOrDefault",
+            "textAlignOrDefault"
+        )
+    }
+
     @Test
     fun textStyle_covered_by_ParagraphStyle_and_SpanStyle() {
         val spanStyleParameters = allConstructorParams(SpanStyle::class).filter {
diff --git a/compose/ui/ui-tooling/build.gradle b/compose/ui/ui-tooling/build.gradle
index da290b3..fa9904b 100644
--- a/compose/ui/ui-tooling/build.gradle
+++ b/compose/ui/ui-tooling/build.gradle
@@ -38,7 +38,6 @@
         api(project(":compose:ui:ui"))
         api(project(":compose:ui:ui-tooling-preview"))
         api(project(":compose:ui:ui-tooling-data"))
-        implementation("androidx.lifecycle:lifecycle-viewmodel:2.6.0-rc01")
         implementation("androidx.savedstate:savedstate-ktx:1.2.0")
         implementation("androidx.compose.material:material:1.0.0")
         implementation("androidx.activity:activity-compose:1.7.0-beta02")
@@ -87,7 +86,6 @@
                 api("androidx.annotation:annotation:1.1.0")
                 implementation(project(":compose:animation:animation"))
                 implementation("androidx.savedstate:savedstate-ktx:1.2.0")
-
                 implementation(project(":compose:material:material"))
                 implementation("androidx.activity:activity-compose:1.7.0-beta02")
                 implementation("androidx.lifecycle:lifecycle-common:2.6.0-rc01")
@@ -105,13 +103,6 @@
 
             androidAndroidTest.dependencies {
                 implementation(project(":compose:ui:ui-test-junit4"))
-                // old version of common-java8 conflicts with newer version, because both have
-                // DefaultLifecycleEventObserver.
-                // Outside of androidx this is resolved via constraint added to lifecycle-common,
-                // but it doesn't work in androidx.
-                // See aosp/1804059
-                implementation("androidx.lifecycle:lifecycle-common-java8:2.6.0-rc01")
-                implementation("androidx.lifecycle:lifecycle-viewmodel-savedstate:2.6.0-rc01")
 
                 implementation(libs.junit)
                 implementation(libs.testRunner)
diff --git a/compose/ui/ui/api/public_plus_experimental_1.4.0-beta02.txt b/compose/ui/ui/api/public_plus_experimental_1.4.0-beta02.txt
index 5462832..5b4af1e 100644
--- a/compose/ui/ui/api/public_plus_experimental_1.4.0-beta02.txt
+++ b/compose/ui/ui/api/public_plus_experimental_1.4.0-beta02.txt
@@ -2528,11 +2528,13 @@
   public interface RootForTest {
     method public androidx.compose.ui.unit.Density getDensity();
     method public androidx.compose.ui.semantics.SemanticsOwner getSemanticsOwner();
+    method @androidx.compose.ui.text.ExperimentalTextApi public default androidx.compose.ui.text.input.TextInputForTests? getTextInputForTests();
     method public androidx.compose.ui.text.input.TextInputService getTextInputService();
     method @androidx.compose.ui.ExperimentalComposeUiApi public default void measureAndLayoutForTest();
     method public boolean sendKeyEvent(android.view.KeyEvent keyEvent);
     property public abstract androidx.compose.ui.unit.Density density;
     property public abstract androidx.compose.ui.semantics.SemanticsOwner semanticsOwner;
+    property @androidx.compose.ui.text.ExperimentalTextApi public default androidx.compose.ui.text.input.TextInputForTests? textInputForTests;
     property public abstract androidx.compose.ui.text.input.TextInputService textInputService;
   }
 
@@ -2656,6 +2658,7 @@
     method public static androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.ui.hapticfeedback.HapticFeedback> getLocalHapticFeedback();
     method public static androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.ui.input.InputModeManager> getLocalInputModeManager();
     method public static androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.ui.unit.LayoutDirection> getLocalLayoutDirection();
+    method @androidx.compose.ui.text.ExperimentalTextApi public static androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.ui.text.input.PlatformTextInputPluginRegistry> getLocalPlatformTextInputPluginRegistry();
     method public static androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.ui.text.input.TextInputService> getLocalTextInputService();
     method public static androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.ui.platform.TextToolbar> getLocalTextToolbar();
     method public static androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.ui.platform.UriHandler> getLocalUriHandler();
@@ -2671,6 +2674,7 @@
     property public static final androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.ui.hapticfeedback.HapticFeedback> LocalHapticFeedback;
     property public static final androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.ui.input.InputModeManager> LocalInputModeManager;
     property public static final androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.ui.unit.LayoutDirection> LocalLayoutDirection;
+    property @androidx.compose.ui.text.ExperimentalTextApi public static final androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.ui.text.input.PlatformTextInputPluginRegistry> LocalPlatformTextInputPluginRegistry;
     property public static final androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.ui.text.input.TextInputService> LocalTextInputService;
     property public static final androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.ui.platform.TextToolbar> LocalTextToolbar;
     property public static final androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.ui.platform.UriHandler> LocalUriHandler;
diff --git a/compose/ui/ui/api/public_plus_experimental_current.txt b/compose/ui/ui/api/public_plus_experimental_current.txt
index 5462832..5b4af1e 100644
--- a/compose/ui/ui/api/public_plus_experimental_current.txt
+++ b/compose/ui/ui/api/public_plus_experimental_current.txt
@@ -2528,11 +2528,13 @@
   public interface RootForTest {
     method public androidx.compose.ui.unit.Density getDensity();
     method public androidx.compose.ui.semantics.SemanticsOwner getSemanticsOwner();
+    method @androidx.compose.ui.text.ExperimentalTextApi public default androidx.compose.ui.text.input.TextInputForTests? getTextInputForTests();
     method public androidx.compose.ui.text.input.TextInputService getTextInputService();
     method @androidx.compose.ui.ExperimentalComposeUiApi public default void measureAndLayoutForTest();
     method public boolean sendKeyEvent(android.view.KeyEvent keyEvent);
     property public abstract androidx.compose.ui.unit.Density density;
     property public abstract androidx.compose.ui.semantics.SemanticsOwner semanticsOwner;
+    property @androidx.compose.ui.text.ExperimentalTextApi public default androidx.compose.ui.text.input.TextInputForTests? textInputForTests;
     property public abstract androidx.compose.ui.text.input.TextInputService textInputService;
   }
 
@@ -2656,6 +2658,7 @@
     method public static androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.ui.hapticfeedback.HapticFeedback> getLocalHapticFeedback();
     method public static androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.ui.input.InputModeManager> getLocalInputModeManager();
     method public static androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.ui.unit.LayoutDirection> getLocalLayoutDirection();
+    method @androidx.compose.ui.text.ExperimentalTextApi public static androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.ui.text.input.PlatformTextInputPluginRegistry> getLocalPlatformTextInputPluginRegistry();
     method public static androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.ui.text.input.TextInputService> getLocalTextInputService();
     method public static androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.ui.platform.TextToolbar> getLocalTextToolbar();
     method public static androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.ui.platform.UriHandler> getLocalUriHandler();
@@ -2671,6 +2674,7 @@
     property public static final androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.ui.hapticfeedback.HapticFeedback> LocalHapticFeedback;
     property public static final androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.ui.input.InputModeManager> LocalInputModeManager;
     property public static final androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.ui.unit.LayoutDirection> LocalLayoutDirection;
+    property @androidx.compose.ui.text.ExperimentalTextApi public static final androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.ui.text.input.PlatformTextInputPluginRegistry> LocalPlatformTextInputPluginRegistry;
     property public static final androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.ui.text.input.TextInputService> LocalTextInputService;
     property public static final androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.ui.platform.TextToolbar> LocalTextToolbar;
     property public static final androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.ui.platform.UriHandler> LocalUriHandler;
diff --git a/compose/ui/ui/build.gradle b/compose/ui/ui/build.gradle
index 25bfc78..5dd55a9 100644
--- a/compose/ui/ui/build.gradle
+++ b/compose/ui/ui/build.gradle
@@ -76,13 +76,11 @@
         compileOnly(libs.kotlinReflect)
         testImplementation(libs.kotlinReflect)
 
-        implementation("androidx.activity:activity:1.7.0-beta02")
-        implementation("androidx.activity:activity-ktx:1.5.1")
+        implementation("androidx.activity:activity-ktx:1.7.0-beta02")
         implementation("androidx.core:core:1.9.0")
         implementation('androidx.collection:collection:1.0.0')
         implementation("androidx.customview:customview-poolingcontainer:1.0.0")
         implementation("androidx.savedstate:savedstate-ktx:1.2.0")
-        implementation("androidx.lifecycle:lifecycle-common-java8:2.5.1")
         implementation("androidx.lifecycle:lifecycle-runtime:2.6.0-rc01")
         implementation("androidx.lifecycle:lifecycle-viewmodel:2.6.0-rc01")
         implementation("androidx.profileinstaller:profileinstaller:1.2.1")
@@ -105,6 +103,7 @@
         androidTestImplementation(libs.testExtJunitKtx)
         androidTestImplementation(libs.testUiautomator)
         androidTestImplementation(libs.kotlinCoroutinesTest)
+        androidTestImplementation(libs.kotlinTest)
         androidTestImplementation(libs.espressoCore)
         androidTestImplementation(libs.bundles.espressoContrib)
         androidTestImplementation(libs.junit)
@@ -122,10 +121,10 @@
         androidTestImplementation(project(":compose:ui:ui-test-junit4"))
         androidTestImplementation(project(":internal-testutils-runtime"))
         androidTestImplementation(project(":test:screenshot:screenshot"))
-        androidTestImplementation("androidx.lifecycle:lifecycle-runtime-testing:2.5.1")
+        androidTestImplementation("androidx.lifecycle:lifecycle-runtime-testing:2.6.0-rc01")
         androidTestImplementation("androidx.recyclerview:recyclerview:1.3.0-alpha02")
         androidTestImplementation("androidx.core:core-ktx:1.9.0")
-        androidTestImplementation("androidx.activity:activity-compose:1.5.1")
+        androidTestImplementation("androidx.activity:activity-compose:1.7.0-beta02")
         androidTestImplementation("androidx.appcompat:appcompat:1.3.0")
         androidTestImplementation("androidx.fragment:fragment:1.3.0")
 
@@ -174,12 +173,11 @@
                 implementation("androidx.autofill:autofill:1.0.0")
                 implementation(libs.kotlinCoroutinesAndroid)
 
-                implementation("androidx.activity:activity:1.7.0-beta02")
+                implementation("androidx.activity:activity-ktx:1.7.0-beta02")
                 implementation("androidx.core:core:1.9.0")
                 implementation('androidx.collection:collection:1.0.0')
                 implementation("androidx.customview:customview-poolingcontainer:1.0.0")
                 implementation("androidx.savedstate:savedstate-ktx:1.2.0")
-                implementation("androidx.lifecycle:lifecycle-common-java8:2.5.1")
                 implementation("androidx.lifecycle:lifecycle-runtime:2.6.0-rc01")
                 implementation("androidx.lifecycle:lifecycle-viewmodel:2.6.0-rc01")
                 implementation("androidx.emoji2:emoji2:1.2.0")
@@ -232,6 +230,7 @@
                 implementation(libs.testRunner)
                 implementation(libs.testExtJunitKtx)
                 implementation(libs.kotlinCoroutinesTest)
+                implementation(libs.kotlinTest)
                 implementation(libs.espressoCore)
                 implementation(libs.bundles.espressoContrib)
                 implementation(libs.junit)
@@ -249,10 +248,10 @@
                 implementation(project(":compose:ui:ui-test-junit4"))
                 implementation(project(":internal-testutils-runtime"))
                 implementation(project(":test:screenshot:screenshot"))
-                implementation("androidx.lifecycle:lifecycle-runtime-testing:2.5.1")
+                implementation("androidx.lifecycle:lifecycle-runtime-testing:2.6.0-rc01")
                 implementation("androidx.recyclerview:recyclerview:1.3.0-alpha02")
                 implementation("androidx.core:core-ktx:1.2.0")
-                implementation("androidx.activity:activity-compose:1.5.1")
+                implementation("androidx.activity:activity-compose:1.7.0-beta02")
                 implementation("androidx.lifecycle:lifecycle-common:2.6.0-rc01")
             }
 
diff --git a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/input/TextInputServiceAndroidEmojiTest.kt b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/input/TextInputServiceAndroidEmojiTest.kt
index 118d446..336f579 100644
--- a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/input/TextInputServiceAndroidEmojiTest.kt
+++ b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/input/TextInputServiceAndroidEmojiTest.kt
@@ -52,8 +52,11 @@
         val inputMethodManager = mock<InputMethodManager>()
         // Choreographer must be retrieved on main thread.
         val choreographer = Espresso.onIdle { Choreographer.getInstance() }
-        val textInputService =
-            TextInputServiceAndroid(view, inputMethodManager, choreographer.asExecutor())
+        val textInputService = TextInputServiceAndroid(
+            view,
+            inputMethodManager,
+            inputCommandProcessorExecutor = choreographer.asExecutor()
+        )
 
         textInputService.startInput(TextFieldValue(""), ImeOptions.Default, {}, {})
 
diff --git a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/input/TextInputServiceAndroidOnStateUpdateTest.kt b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/input/TextInputServiceAndroidOnStateUpdateTest.kt
index 047d3ee..ecb1f4e 100644
--- a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/input/TextInputServiceAndroidOnStateUpdateTest.kt
+++ b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/input/TextInputServiceAndroidOnStateUpdateTest.kt
@@ -56,8 +56,11 @@
         inputMethodManager = mock()
         // Choreographer must be retrieved on main thread.
         val choreographer = Espresso.onIdle { Choreographer.getInstance() }
-        textInputService =
-            TextInputServiceAndroid(view, inputMethodManager, choreographer.asExecutor())
+        textInputService = TextInputServiceAndroid(
+                view,
+                inputMethodManager,
+                inputCommandProcessorExecutor = choreographer.asExecutor()
+            )
         textInputService.startInput(
             value = TextFieldValue(""),
             imeOptions = ImeOptions.Default,
diff --git a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/input/pointer/HitPathTrackerTest.kt b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/input/pointer/HitPathTrackerTest.kt
index 272cf37..fe60095 100644
--- a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/input/pointer/HitPathTrackerTest.kt
+++ b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/input/pointer/HitPathTrackerTest.kt
@@ -57,8 +57,10 @@
 import androidx.compose.ui.platform.TextToolbar
 import androidx.compose.ui.platform.ViewConfiguration
 import androidx.compose.ui.platform.WindowInfo
+import androidx.compose.ui.text.ExperimentalTextApi
 import androidx.compose.ui.text.font.Font
 import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.input.PlatformTextInputPluginRegistry
 import androidx.compose.ui.text.input.TextInputService
 import androidx.compose.ui.unit.Constraints
 import androidx.compose.ui.unit.Density
@@ -3642,6 +3644,9 @@
         get() = Density(1f)
     override val textInputService: TextInputService
         get() = TODO("Not yet implemented")
+    @OptIn(ExperimentalTextApi::class)
+    override val platformTextInputPluginRegistry: PlatformTextInputPluginRegistry
+        get() = TODO("Not yet implemented")
     override val pointerIconService: PointerIconService
         get() = TODO("Not yet implemented")
     override val focusOwner: FocusOwner
diff --git a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/input/pointer/PointerInputEventProcessorTest.kt b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/input/pointer/PointerInputEventProcessorTest.kt
index c56ae6e..fbdd74e 100644
--- a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/input/pointer/PointerInputEventProcessorTest.kt
+++ b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/input/pointer/PointerInputEventProcessorTest.kt
@@ -48,8 +48,10 @@
 import androidx.compose.ui.platform.TextToolbar
 import androidx.compose.ui.platform.ViewConfiguration
 import androidx.compose.ui.platform.WindowInfo
+import androidx.compose.ui.text.ExperimentalTextApi
 import androidx.compose.ui.text.font.Font
 import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.input.PlatformTextInputPluginRegistry
 import androidx.compose.ui.text.input.TextInputService
 import androidx.compose.ui.unit.Constraints
 import androidx.compose.ui.unit.Density
@@ -3305,6 +3307,9 @@
         get() = Density(1f)
     override val textInputService: TextInputService
         get() = TODO("Not yet implemented")
+    @OptIn(ExperimentalTextApi::class)
+    override val platformTextInputPluginRegistry: PlatformTextInputPluginRegistry
+        get() = TODO("Not yet implemented")
     override val pointerIconService: PointerIconService
         get() = TODO("Not yet implemented")
     override val focusOwner: FocusOwner
diff --git a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/layout/Helpers.kt b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/layout/Helpers.kt
index 0d06482..6aca24e 100644
--- a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/layout/Helpers.kt
+++ b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/layout/Helpers.kt
@@ -47,8 +47,10 @@
 import androidx.compose.ui.platform.TextToolbar
 import androidx.compose.ui.platform.ViewConfiguration
 import androidx.compose.ui.platform.WindowInfo
+import androidx.compose.ui.text.ExperimentalTextApi
 import androidx.compose.ui.text.font.Font
 import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.input.PlatformTextInputPluginRegistry
 import androidx.compose.ui.text.input.TextInputService
 import androidx.compose.ui.unit.Constraints
 import androidx.compose.ui.unit.Density
@@ -151,6 +153,9 @@
         get() = TODO("Not yet implemented")
     override val textInputService: TextInputService
         get() = TODO("Not yet implemented")
+    @OptIn(ExperimentalTextApi::class)
+    override val platformTextInputPluginRegistry: PlatformTextInputPluginRegistry
+        get() = TODO("Not yet implemented")
     override val pointerIconService: PointerIconService
         get() = TODO("Not yet implemented")
     override val focusOwner: FocusOwner
diff --git a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/text/input/PlatformTextInputAdapterRegistryTest.kt b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/text/input/PlatformTextInputAdapterRegistryTest.kt
new file mode 100644
index 0000000..f9504dd
--- /dev/null
+++ b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/text/input/PlatformTextInputAdapterRegistryTest.kt
@@ -0,0 +1,337 @@
+/*
+ * 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.compose.ui.text.input
+
+import android.view.View
+import android.view.inputmethod.EditorInfo
+import android.view.inputmethod.InputConnection
+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.LocalView
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.text.ExperimentalTextApi
+import androidx.compose.ui.text.InternalTextApi
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.google.common.truth.Truth.assertThat
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/**
+ * This tests the behavior of the [PlatformTextInputPluginRegistryImpl] class and its interaction
+ * with adapter factories and composition.
+ *
+ * It does *not* test platform-specific behavior or integration with composition hosts.
+ */
+@OptIn(InternalTextApi::class, ExperimentalTextApi::class)
+@SmallTest
+@RunWith(AndroidJUnit4::class)
+class PlatformTextInputAdapterRegistryTest {
+
+    @get:Rule
+    val rule = createComposeRule()
+
+    private lateinit var registry: PlatformTextInputPluginRegistryImpl
+
+    @Test
+    fun rememberAdapter_sharesInstance_inSameComposition() {
+        lateinit var adapter1: TestAdapter
+        lateinit var adapter2: TestAdapter
+        setContent {
+            adapter1 = registry.rememberAdapter(TestPlugin)
+            adapter2 = registry.rememberAdapter(TestPlugin)
+        }
+
+        rule.runOnIdle {
+            assertThat(adapter1).isSameInstanceAs(adapter2)
+            assertThat(adapter1.isDisposed).isFalse()
+        }
+    }
+
+    @Test
+    fun rememberAdapter_sharesInstance_whenRemovedAndAddedInSameComposition() {
+        var branch by mutableStateOf(false)
+        var adapter1: TestAdapter? = null
+        var adapter2: TestAdapter? = null
+        setContent {
+            if (branch) {
+                adapter1 = registry.rememberAdapter(TestPlugin)
+            } else {
+                adapter2 = registry.rememberAdapter(TestPlugin)
+            }
+        }
+
+        rule.runOnIdle {
+            assertThat(adapter2).isNotNull()
+            branch = true
+        }
+
+        rule.runOnIdle {
+            assertThat(adapter1).isNotNull()
+            assertThat(adapter1).isSameInstanceAs(adapter2)
+            assertThat(adapter1!!.isDisposed).isFalse()
+        }
+    }
+
+    @Test
+    fun rememberAdapter_disposesInstance_whenRemoved() {
+        var createAdapter by mutableStateOf(true)
+        lateinit var adapter: TestAdapter
+        setContent {
+            if (createAdapter) {
+                adapter = registry.rememberAdapter(TestPlugin)
+            }
+        }
+
+        rule.runOnIdle {
+            assertThat(adapter.isDisposed).isFalse()
+            createAdapter = false
+        }
+
+        rule.runOnIdle {
+            assertThat(adapter.isDisposed).isTrue()
+        }
+    }
+
+    @Test
+    fun rememberAdapter_newInstance_whenFullyRemoved() {
+        var branch by mutableStateOf(0)
+        var adapter1: TestAdapter? = null
+        var adapter2: TestAdapter? = null
+        setContent {
+            when (branch) {
+                0 -> adapter1 = registry.rememberAdapter(TestPlugin)
+
+                1 -> {
+                    // Let the adapter be disposed.
+                }
+
+                2 -> adapter2 = registry.rememberAdapter(TestPlugin)
+            }
+        }
+
+        rule.runOnIdle {
+            assertThat(adapter1).isNotNull()
+            branch = 1
+        }
+
+        rule.runOnIdle {
+            assertThat(adapter1).isNotNull()
+            branch = 2
+        }
+
+        rule.runOnIdle {
+            assertThat(adapter2).isNotNull()
+            assertThat(adapter1).isNotSameInstanceAs(adapter2)
+            assertThat(adapter1!!.isDisposed).isTrue()
+            assertThat(adapter2!!.isDisposed).isFalse()
+        }
+    }
+
+    @Test
+    fun multipleFactories() {
+        lateinit var adapter1: TestAdapter
+        lateinit var adapter2: TestAdapter
+        var createAdapter1 by mutableStateOf(true)
+        var createAdapter2 by mutableStateOf(true)
+        setContent {
+            if (createAdapter1) {
+                adapter1 = registry.rememberAdapter(TestPlugin)
+            }
+            if (createAdapter2) {
+                adapter2 = registry.rememberAdapter(AlternatePlugin)
+            }
+        }
+
+        rule.runOnIdle {
+            assertThat(adapter1).isNotSameInstanceAs(adapter2)
+            assertThat(adapter1.isDisposed).isFalse()
+            assertThat(adapter2.isDisposed).isFalse()
+            createAdapter1 = false
+        }
+
+        rule.runOnIdle {
+            assertThat(adapter1.isDisposed).isTrue()
+            assertThat(adapter2.isDisposed).isFalse()
+            createAdapter1 = true
+            createAdapter2 = false
+        }
+
+        rule.runOnIdle {
+            assertThat(adapter1.isDisposed).isFalse()
+            assertThat(adapter2.isDisposed).isTrue()
+        }
+    }
+
+    @Test
+    fun initialFocus() {
+        setContent {
+            registry.rememberAdapter(TestPlugin)
+            registry.rememberAdapter(AlternatePlugin)
+        }
+
+        rule.runOnIdle {
+            assertThat(registry.focusedAdapter).isNull()
+        }
+    }
+
+    @Test
+    fun requestInitialFocus() {
+        lateinit var adapter1: TestAdapter
+        lateinit var adapter2: TestAdapter
+        setContent {
+            adapter1 = registry.rememberAdapter(TestPlugin)
+            adapter2 = registry.rememberAdapter(AlternatePlugin)
+        }
+
+        rule.runOnIdle {
+            adapter1.context.requestInputFocus()
+            assertThat(registry.focusedAdapter).isSameInstanceAs(adapter1)
+            adapter2.context.requestInputFocus()
+
+            assertThat(registry.focusedAdapter).isSameInstanceAs(adapter2)
+        }
+    }
+
+    @Test
+    fun requestFocusTransfer() {
+        lateinit var adapter1: TestAdapter
+        lateinit var adapter2: TestAdapter
+        setContent {
+            adapter1 = registry.rememberAdapter(TestPlugin)
+            adapter2 = registry.rememberAdapter(AlternatePlugin)
+        }
+
+        rule.runOnIdle {
+            adapter1.context.requestInputFocus()
+            adapter2.context.requestInputFocus()
+
+            assertThat(registry.focusedAdapter).isSameInstanceAs(adapter2)
+        }
+    }
+
+    @Test
+    fun releaseFocus_whileHeld() {
+        lateinit var adapter1: TestAdapter
+        setContent {
+            adapter1 = registry.rememberAdapter(TestPlugin)
+            registry.rememberAdapter(AlternatePlugin)
+        }
+
+        rule.runOnIdle {
+            adapter1.context.requestInputFocus()
+            adapter1.context.releaseInputFocus()
+
+            assertThat(registry.focusedAdapter).isNull()
+        }
+    }
+
+    @Test
+    fun releaseFocus_whileNotHeld() {
+        lateinit var adapter1: TestAdapter
+        lateinit var adapter2: TestAdapter
+        setContent {
+            adapter1 = registry.rememberAdapter(TestPlugin)
+            adapter2 = registry.rememberAdapter(AlternatePlugin)
+        }
+
+        rule.runOnIdle {
+            adapter1.context.requestInputFocus()
+            adapter2.context.requestInputFocus()
+            // Should be a noop because adapter1 already lost focus.
+            adapter1.context.releaseInputFocus()
+
+            assertThat(registry.focusedAdapter).isSameInstanceAs(adapter2)
+        }
+    }
+
+    @Test
+    fun focusReleased_whenDisposed() {
+        lateinit var adapter1: TestAdapter
+        var createAdapter1 by mutableStateOf(true)
+        setContent {
+            if (createAdapter1) {
+                adapter1 = registry.rememberAdapter(TestPlugin)
+            }
+            registry.rememberAdapter(AlternatePlugin)
+        }
+
+        rule.runOnIdle {
+            adapter1.context.requestInputFocus()
+        }
+        rule.runOnIdle {
+            assertThat(registry.focusedAdapter).isSameInstanceAs(adapter1)
+            createAdapter1 = false
+        }
+
+        rule.runOnIdle {
+            assertThat(registry.focusedAdapter).isNull()
+        }
+    }
+
+    private fun setContent(content: @Composable () -> Unit) {
+        rule.setContent {
+            val view = LocalView.current
+            registry = remember {
+                PlatformTextInputPluginRegistryImpl { factory, context ->
+                    factory.createAdapter(context, view)
+                }
+            }
+            content()
+        }
+    }
+
+    private object TestPlugin : PlatformTextInputPlugin<TestAdapter> {
+        override fun createAdapter(
+            platformTextInput: PlatformTextInput,
+            view: View
+        ): TestAdapter = TestAdapter(platformTextInput)
+    }
+
+    private object AlternatePlugin : PlatformTextInputPlugin<TestAdapter> {
+        override fun createAdapter(
+            platformTextInput: PlatformTextInput,
+            view: View
+        ): TestAdapter = TestAdapter(platformTextInput)
+    }
+
+    private class TestAdapter(val context: PlatformTextInput) : PlatformTextInputAdapter {
+        var isDisposed: Boolean = false
+            private set
+
+        override val inputForTests: TextInputForTests = NoopInputForTests
+
+        override fun createInputConnection(outAttrs: EditorInfo): InputConnection? {
+            // Not testing android stuff in this test.
+            TODO("Not implemented for test")
+        }
+
+        override fun onDisposed() {
+            check(!isDisposed) { "TestAdapter already disposed" }
+            isDisposed = true
+        }
+    }
+
+    private object NoopInputForTests : TextInputForTests {
+        override fun inputTextForTest(text: String) = TODO("Not implemented for test")
+        override fun submitTextForTest() = TODO("Not implemented for test")
+    }
+}
\ No newline at end of file
diff --git a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/text/input/PlatformTextInputEditTextIntegrationTest.kt b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/text/input/PlatformTextInputEditTextIntegrationTest.kt
new file mode 100644
index 0000000..ba35eee
--- /dev/null
+++ b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/text/input/PlatformTextInputEditTextIntegrationTest.kt
@@ -0,0 +1,235 @@
+/*
+ * 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.compose.ui.text.input
+
+import android.content.Context
+import android.graphics.Rect
+import android.view.KeyEvent
+import android.view.View
+import android.view.inputmethod.EditorInfo
+import android.view.inputmethod.InputConnection
+import android.widget.EditText
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalPlatformTextInputPluginRegistry
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.semantics.setSelection
+import androidx.compose.ui.semantics.setText
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.performImeAction
+import androidx.compose.ui.test.performTextClearance
+import androidx.compose.ui.test.performTextInput
+import androidx.compose.ui.test.performTextInputSelection
+import androidx.compose.ui.test.performTextReplacement
+import androidx.compose.ui.text.ExperimentalTextApi
+import androidx.compose.ui.text.TextRange
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.google.common.truth.Truth.assertThat
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+private const val Tag = "field"
+private const val ExpectedActionCode = 42
+
+/**
+ * This test exercises the use case of an [EditText] embedded in a composition using the text input
+ * plugin system to wire into Compose's testing framework.
+ */
+@OptIn(ExperimentalTextApi::class, ExperimentalTestApi::class)
+@SmallTest
+@RunWith(AndroidJUnit4::class)
+class PlatformTextInputEditTextIntegrationTest {
+
+    @get:Rule
+    val rule = createComposeRule()
+    private lateinit var editText: EditText
+
+    @Test
+    fun inputText() {
+        setContentAndFocusField()
+
+        rule.onNodeWithTag(Tag).performTextInput("hello")
+        rule.onNodeWithTag(Tag).performTextInput(" world")
+
+        rule.runOnIdle {
+            assertThat(editText.text.toString()).isEqualTo("hello world")
+        }
+    }
+
+    @Test
+    fun clearText() {
+        setContentAndFocusField()
+
+        rule.runOnIdle {
+            editText.setText("hello")
+        }
+
+        rule.onNodeWithTag(Tag).performTextClearance()
+
+        rule.runOnIdle {
+            assertThat(editText.text.toString()).isEmpty()
+        }
+    }
+
+    @Test
+    fun replaceText() {
+        setContentAndFocusField()
+
+        rule.runOnIdle {
+            editText.setText("hello")
+        }
+
+        rule.onNodeWithTag(Tag).performTextReplacement("world")
+
+        rule.runOnIdle {
+            assertThat(editText.text.toString()).isEqualTo("world")
+        }
+    }
+
+    @Test
+    fun textSelection() {
+        setContentAndFocusField()
+
+        rule.runOnIdle {
+            editText.setText("hello")
+        }
+
+        rule.onNodeWithTag(Tag).performTextInputSelection(TextRange(1, 3))
+
+        rule.runOnIdle {
+            assertThat(editText.text.toString()).isEqualTo("hello")
+            assertThat(editText.selectionStart).isEqualTo(1)
+            assertThat(editText.selectionEnd).isEqualTo(3)
+        }
+    }
+
+    @Test
+    fun textSubmit() {
+        var recordedActionCode: Int = -1
+        var recordedKeyEvent: KeyEvent? = null
+        setContentAndFocusField()
+
+        rule.runOnIdle {
+            editText.setOnEditorActionListener { _, actionCode, keyEvent ->
+                recordedActionCode = actionCode
+                recordedKeyEvent = keyEvent
+                true
+            }
+        }
+
+        rule.onNodeWithTag(Tag).performImeAction()
+
+        rule.runOnIdle {
+            assertThat(recordedActionCode).isEqualTo(ExpectedActionCode)
+            assertThat(recordedKeyEvent).isNull()
+        }
+    }
+
+    private fun setContentAndFocusField() {
+        rule.setContent {
+            TestTextField(Modifier.testTag(Tag))
+        }
+
+        // Focus the field.
+        rule.onNodeWithTag(Tag).performClick()
+        rule.runOnIdle { assertThat(editText.isFocused).isTrue() }
+    }
+
+    @Composable
+    private fun TestTextField(modifier: Modifier = Modifier) {
+        val adapter = LocalPlatformTextInputPluginRegistry.current
+            .rememberAdapter(TestPlugin)
+
+        AndroidView(
+            modifier = modifier.semantics {
+                // Required for the semantics actions to recognize this node as a text editor.
+                setText { text ->
+                    adapter.editText?.also {
+                        it.setText(text.text)
+                        return@setText true
+                    }
+                    return@setText false
+                }
+                setSelection { start, end, _ ->
+                    adapter.editText?.also {
+                        it.setSelection(start, end)
+                        return@setSelection true
+                    }
+                    return@setSelection false
+                }
+            },
+            factory = { context ->
+                EditTextWrapper(context, adapter)
+                    .also { editText = it }
+            }
+        )
+    }
+
+    private class EditTextWrapper(
+        context: Context,
+        private val adapter: TestAdapter
+    ) : EditText(context), TextInputForTests {
+
+        override fun onFocusChanged(
+            focused: Boolean,
+            direction: Int,
+            previouslyFocusedRect: Rect?
+        ) {
+            super.onFocusChanged(focused, direction, previouslyFocusedRect)
+
+            // Doesn't interact with the actual compose focus system, it only tells the input
+            // plugin registry to delegate test input commands to this adapter.
+            if (focused) {
+                adapter.editText = this
+                adapter.context.requestInputFocus()
+            } else {
+                adapter.context.releaseInputFocus()
+                adapter.editText = null
+            }
+        }
+
+        override fun inputTextForTest(text: String) {
+            this.text.append(text)
+        }
+
+        override fun submitTextForTest() {
+            onEditorAction(ExpectedActionCode)
+        }
+    }
+
+    private object TestPlugin : PlatformTextInputPlugin<TestAdapter> {
+        override fun createAdapter(
+            platformTextInput: PlatformTextInput,
+            view: View
+        ): TestAdapter = TestAdapter(platformTextInput)
+    }
+
+    private class TestAdapter(
+        val context: PlatformTextInput,
+    ) : PlatformTextInputAdapter {
+        var editText: EditTextWrapper? = null
+        override val inputForTests: TextInputForTests? get() = editText
+        override fun createInputConnection(outAttrs: EditorInfo): InputConnection? = null
+    }
+}
\ No newline at end of file
diff --git a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/text/input/PlatformTextInputTestIntegrationTest.kt b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/text/input/PlatformTextInputTestIntegrationTest.kt
new file mode 100644
index 0000000..9c14a20
--- /dev/null
+++ b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/text/input/PlatformTextInputTestIntegrationTest.kt
@@ -0,0 +1,181 @@
+/*
+ * 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.compose.ui.text.input
+
+import android.view.View
+import android.view.inputmethod.EditorInfo
+import android.view.inputmethod.InputConnection
+import androidx.compose.foundation.focusable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.size
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.platform.LocalPlatformTextInputPluginRegistry
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.semantics.SemanticsActions
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.semantics.setText
+import androidx.compose.ui.test.SemanticsNodeInteraction
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performImeAction
+import androidx.compose.ui.test.performSemanticsAction
+import androidx.compose.ui.test.performTextInput
+import androidx.compose.ui.text.ExperimentalTextApi
+import androidx.compose.ui.unit.dp
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.google.common.truth.Truth.assertThat
+import kotlin.test.assertFailsWith
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/**
+ * This test only tests that the [SemanticsNodeInteraction] extension functions related to text
+ * input get sent to the [PlatformTextInputAdapter]'s [TextInputForTests].
+ *
+ * It does *not* test integration with Android's text input system or platform-specific code.
+ */
+@OptIn(ExperimentalTextApi::class)
+@SmallTest
+@RunWith(AndroidJUnit4::class)
+class PlatformTextInputTestIntegrationTest {
+
+    @get:Rule
+    val rule = createComposeRule()
+
+    @Test
+    fun whenNoFieldFocused() {
+        val testCommands = mutableListOf<String>()
+        rule.setContent {
+            TestTextField(testCommands, Modifier.testTag("field"))
+        }
+
+        val error = assertFailsWith<IllegalStateException> {
+            rule.onNodeWithTag("field").performTextInput("hello")
+        }
+        assertThat(error).hasMessageThat().isEqualTo("No input session started. Missing a focus?")
+    }
+
+    @Test
+    fun sendsAllTestCommandsToFocusedAdapter() {
+        val testCommands = mutableListOf<String>()
+        rule.setContent {
+            TestTextField(testCommands, Modifier.testTag("field"))
+        }
+
+        with(rule.onNodeWithTag("field")) {
+            performSemanticsAction(SemanticsActions.RequestFocus)
+
+            performTextInput("hello")
+            performImeAction()
+        }
+
+        rule.runOnIdle {
+            assertThat(testCommands).containsExactly(
+                "input(hello)",
+                "submit",
+            ).inOrder()
+        }
+    }
+
+    @Test
+    fun handlesFocusChange() {
+        val testCommands1 = mutableListOf<String>()
+        val testCommands2 = mutableListOf<String>()
+        rule.setContent {
+            TestTextField(testCommands1, Modifier.testTag("field1"))
+            TestTextField(testCommands2, Modifier.testTag("field2"))
+        }
+
+        with(rule.onNodeWithTag("field1")) {
+            performSemanticsAction(SemanticsActions.RequestFocus)
+            performTextInput("hello")
+        }
+        with(rule.onNodeWithTag("field2")) {
+            performSemanticsAction(SemanticsActions.RequestFocus)
+            performTextInput("world")
+        }
+
+        rule.runOnIdle {
+            assertThat(testCommands1).containsExactly("input(hello)")
+            assertThat(testCommands2).containsExactly("input(world)")
+        }
+    }
+
+    @Composable
+    private fun TestTextField(
+        testCommands: MutableList<String>,
+        modifier: Modifier = Modifier
+    ) {
+        val adapter = LocalPlatformTextInputPluginRegistry.current
+            .rememberAdapter(TestPlugin)
+
+        Box(
+            modifier
+                .size(1.dp)
+                .onFocusChanged {
+                    if (it.isFocused) {
+                        adapter.startInput(testCommands)
+                    } else {
+                        adapter.endInput()
+                    }
+                }
+                .focusable()
+                .semantics {
+                    setText { true }
+                }
+        )
+    }
+
+    private object TestPlugin : PlatformTextInputPlugin<TestAdapter> {
+        override fun createAdapter(
+            platformTextInput: PlatformTextInput,
+            view: View
+        ): TestAdapter = TestAdapter(platformTextInput)
+    }
+
+    private class TestAdapter(
+        private val context: PlatformTextInput,
+    ) : PlatformTextInputAdapter, TextInputForTests {
+        private var testCommands: MutableList<String>? = null
+
+        fun startInput(testCommands: MutableList<String>) {
+            this.testCommands = testCommands
+            context.requestInputFocus()
+        }
+
+        fun endInput() {
+            context.releaseInputFocus()
+            this.testCommands = null
+        }
+
+        override val inputForTests get() = this
+
+        override fun createInputConnection(outAttrs: EditorInfo): InputConnection? = null
+
+        override fun inputTextForTest(text: String) {
+            testCommands!! += "input($text)"
+        }
+
+        override fun submitTextForTest() {
+            testCommands!! += "submit"
+        }
+    }
+}
\ No newline at end of file
diff --git a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/text/input/PlatformTextInputViewIntegrationTest.kt b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/text/input/PlatformTextInputViewIntegrationTest.kt
new file mode 100644
index 0000000..5ef1140
--- /dev/null
+++ b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/text/input/PlatformTextInputViewIntegrationTest.kt
@@ -0,0 +1,156 @@
+/*
+ * 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.compose.ui.text.input
+
+import android.view.View
+import android.view.inputmethod.BaseInputConnection
+import android.view.inputmethod.EditorInfo
+import android.view.inputmethod.InputConnection
+import androidx.compose.ui.platform.AndroidComposeView
+import androidx.compose.ui.platform.LocalPlatformTextInputPluginRegistry
+import androidx.compose.ui.platform.LocalView
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.text.ExperimentalTextApi
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.google.common.truth.Truth.assertThat
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@OptIn(ExperimentalTextApi::class)
+@SmallTest
+@RunWith(AndroidJUnit4::class)
+class PlatformTextInputViewIntegrationTest {
+
+    @get:Rule
+    val rule = createComposeRule()
+
+    private lateinit var hostView: AndroidComposeView
+    private lateinit var adapter1: TestAdapter
+    private lateinit var adapter2: TestAdapter
+
+    @Test
+    fun hostViewIsPassedToFactory() {
+        setupContent()
+        rule.runOnIdle {
+            assertThat(adapter1.view).isSameInstanceAs(hostView)
+            assertThat(adapter2.view).isSameInstanceAs(hostView)
+        }
+    }
+
+    @Test
+    fun checkIsTextEditor_whenNoAdapterFocused() {
+        setupContent()
+        rule.runOnIdle {
+            assertThat(hostView.onCheckIsTextEditor()).isFalse()
+        }
+    }
+
+    @Test
+    fun checkIsTextEditor_whenAdapterFocused() {
+        setupContent()
+        rule.runOnIdle {
+            adapter2.context.requestInputFocus()
+            assertThat(hostView.onCheckIsTextEditor()).isTrue()
+
+            adapter2.context.releaseInputFocus()
+            assertThat(hostView.onCheckIsTextEditor()).isFalse()
+        }
+    }
+
+    @Test
+    fun createInputConnection_whenNoAdapterFocused() {
+        setupContent()
+        rule.runOnIdle {
+            val editorInfo = EditorInfo()
+            assertThat(hostView.onCreateInputConnection(editorInfo)).isNull()
+        }
+    }
+
+    @Test
+    fun createInputConnection_goesToFocusedAdapter() {
+        setupContent()
+        rule.runOnIdle {
+            adapter2.context.requestInputFocus()
+
+            val editorInfo = EditorInfo()
+            val connection1 = hostView.onCreateInputConnection(editorInfo)
+            assertThat(connection1).isSameInstanceAs(adapter2.inputConnection)
+            assertThat(editorInfo.actionLabel).isEqualTo(adapter2.actionLabel)
+
+            adapter1.context.requestInputFocus()
+
+            val connection2 = hostView.onCreateInputConnection(editorInfo)
+            assertThat(connection2).isNotSameInstanceAs(connection1)
+            assertThat(connection2).isSameInstanceAs(adapter1.inputConnection)
+        }
+    }
+
+    private fun setupContent() {
+        rule.setContent {
+            hostView = LocalView.current as AndroidComposeView
+            val adapterProvider = LocalPlatformTextInputPluginRegistry.current
+            adapter1 = adapterProvider.rememberAdapter(TestPlugin)
+            adapter2 = adapterProvider.rememberAdapter(AlternatePlugin)
+        }
+    }
+
+    private object TestPlugin : PlatformTextInputPlugin<TestAdapter> {
+        override fun createAdapter(
+            platformTextInput: PlatformTextInput,
+            view: View
+        ): TestAdapter = TestAdapter(platformTextInput, view)
+    }
+
+    private object AlternatePlugin : PlatformTextInputPlugin<TestAdapter> {
+        override fun createAdapter(
+            platformTextInput: PlatformTextInput,
+            view: View
+        ): TestAdapter = TestAdapter(platformTextInput, view)
+    }
+
+    private class TestAdapter(
+        val context: PlatformTextInput,
+        val view: View
+    ) : PlatformTextInputAdapter {
+        var isDisposed: Boolean = false
+            private set
+
+        val actionLabel = "test connection!"
+        val inputConnection = TestInputConnection(view)
+
+        override val inputForTests: TextInputForTests = NoopInputForTests
+
+        override fun createInputConnection(outAttrs: EditorInfo): InputConnection {
+            outAttrs.actionLabel = actionLabel
+            return inputConnection
+        }
+
+        override fun onDisposed() {
+            check(!isDisposed) { "TestAdapter already disposed" }
+            isDisposed = true
+        }
+    }
+
+    private class TestInputConnection(view: View) : BaseInputConnection(view, false)
+
+    private object NoopInputForTests : TextInputForTests {
+        override fun inputTextForTest(text: String) = TODO("Not implemented for test")
+        override fun submitTextForTest() = TODO("Not implemented for test")
+    }
+}
\ No newline at end of file
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 26442ef..57e73ce 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
@@ -128,12 +128,16 @@
 import androidx.compose.ui.semantics.SemanticsNode
 import androidx.compose.ui.semantics.SemanticsOwner
 import androidx.compose.ui.semantics.outerSemantics
+import androidx.compose.ui.text.ExperimentalTextApi
+import androidx.compose.ui.text.InternalTextApi
 import androidx.compose.ui.text.font.Font
 import androidx.compose.ui.text.font.FontFamily
 import androidx.compose.ui.text.font.createFontFamilyResolver
+import androidx.compose.ui.text.input.AndroidTextInputServicePlugin
+import androidx.compose.ui.text.input.PlatformTextInputPluginRegistryImpl
 import androidx.compose.ui.text.input.PlatformTextInputService
+import androidx.compose.ui.text.input.TextInputForTests
 import androidx.compose.ui.text.input.TextInputService
-import androidx.compose.ui.text.input.TextInputServiceAndroid
 import androidx.compose.ui.unit.Constraints
 import androidx.compose.ui.unit.Density
 import androidx.compose.ui.unit.IntOffset
@@ -158,7 +162,7 @@
 import kotlin.math.roundToInt
 
 @SuppressLint("ViewConstructor", "VisibleForTests")
-@OptIn(ExperimentalComposeUiApi::class)
+@OptIn(ExperimentalComposeUiApi::class, InternalTextApi::class, ExperimentalTextApi::class)
 internal class AndroidComposeView(context: Context) :
     ViewGroup(context), Owner, ViewRootForTest, PositionCalculator, DefaultLifecycleObserver {
 
@@ -365,10 +369,29 @@
         _inputModeManager.inputMode = if (touchMode) Touch else Keyboard
     }
 
-    private val textInputServiceAndroid = TextInputServiceAndroid(this)
+    /**
+     * This is the root of the text input system's state. It currently holds the default
+     * [textInputService], but may be used to provide alternative text input systems. All text input
+     * methods this class implements from [View] should delegate to the active service.
+     */
+    override val platformTextInputPluginRegistry =
+        PlatformTextInputPluginRegistryImpl { factory, platformTextInput ->
+            factory.createAdapter(platformTextInput, view = this)
+        }
 
-    @OptIn(InternalComposeUiApi::class)
-    override val textInputService = textInputServiceFactory(textInputServiceAndroid)
+    /**
+     * The default text input service. The service is wired up through
+     * [platformTextInputPluginRegistry], this class only knows about it to support
+     * [LocalTextInputService].
+     */
+    // We never call dispose() on the returned AdapterHandle because the adapter will live as long
+    // as this view, and this view also owns the registry, so they'll all be gc'd together.
+    override val textInputService = platformTextInputPluginRegistry.getOrCreateAdapter(
+        AndroidTextInputServicePlugin
+    ).adapter.service
+
+    override val textInputForTests: TextInputForTests?
+        get() = platformTextInputPluginRegistry.focusedAdapter?.inputForTests
 
     @Deprecated(
         "fontLoader is deprecated, use fontFamilyResolver",
@@ -1501,10 +1524,11 @@
         viewToWindowMatrix.invertTo(windowToViewMatrix)
     }
 
-    override fun onCheckIsTextEditor(): Boolean = textInputServiceAndroid.isEditorFocused()
+    override fun onCheckIsTextEditor(): Boolean =
+        platformTextInputPluginRegistry.focusedAdapter != null
 
     override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection? =
-        textInputServiceAndroid.createInputConnection(outAttrs)
+        platformTextInputPluginRegistry.focusedAdapter?.createInputConnection(outAttrs)
 
     override fun calculateLocalPosition(positionInWindow: Offset): Offset {
         recalculateWindowPosition()
diff --git a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/AndroidComposeViewAccessibilityDelegateCompat.android.kt b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/AndroidComposeViewAccessibilityDelegateCompat.android.kt
index d0c2a7d..95f7c7e 100644
--- a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/AndroidComposeViewAccessibilityDelegateCompat.android.kt
+++ b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/AndroidComposeViewAccessibilityDelegateCompat.android.kt
@@ -166,6 +166,7 @@
 
     override fun toString(): String = "$_start..<$_endExclusive"
 }
+
 internal operator fun Float.rangeUntil(that: Float): OpenEndRange<Float> =
     OpenEndFloatRange(this, that)
 
@@ -201,12 +202,15 @@
          * text length to 100K characters - 200KB.
          */
         const val ParcelSafeTextLength = 100000
+
         /**
          * The undefined cursor position.
          */
         const val AccessibilityCursorPositionUndefined = -1
+
         // 20 is taken from AbsSeekbar.java.
         const val AccessibilitySliderStepsCount = 20
+
         /**
          * Delay before dispatching a recurring accessibility event in milliseconds.
          * This delay guarantees that a recurring event will be send at most once
@@ -275,6 +279,7 @@
                 emptyList()
             }
         }
+
     @VisibleForTesting
     internal val touchExplorationStateListener: TouchExplorationStateChangeListener =
         TouchExplorationStateChangeListener {
@@ -285,6 +290,7 @@
     private var enabledServices = accessibilityManager.getEnabledAccessibilityServiceList(
         AccessibilityServiceInfo.FEEDBACK_ALL_MASK
     )
+
     /**
      * True if any accessibility service enabled in the system, except the UIAutomator (as it
      * doesn't appear in the list of enabled services)
@@ -304,7 +310,7 @@
      */
     private val isTouchExplorationEnabled
         get() = accessibilityForceEnabledForTesting ||
-                accessibilityManager.isEnabled && accessibilityManager.isTouchExplorationEnabled
+            accessibilityManager.isEnabled && accessibilityManager.isTouchExplorationEnabled
 
     private val handler = Handler(Looper.getMainLooper())
     private var nodeProvider: AccessibilityNodeProviderCompat =
@@ -317,6 +323,7 @@
     private var actionIdToLabel = SparseArrayCompat<SparseArrayCompat<CharSequence>>()
     private var labelToActionId = SparseArrayCompat<Map<CharSequence, Int>>()
     private var accessibilityCursorPosition = AccessibilityCursorPositionUndefined
+
     // We hold this node id to reset the [accessibilityCursorPosition] to undefined when
     // traversal with granularity switches to the next node
     private var previousTraversedNode: Int? = null
@@ -399,6 +406,7 @@
                     touchExplorationStateListener
                 )
             }
+
             override fun onViewDetachedFromWindow(view: View) {
                 handler.removeCallbacks(semanticsChangeChecker)
 
@@ -516,7 +524,7 @@
         layoutIsRtl: Boolean,
     ): Comparator<SemanticsNode> {
         // First compare the coordinates LTR
-        var comparator = compareBy<SemanticsNode> (
+        var comparator = compareBy<SemanticsNode>(
             { it.layoutNode.coordinates.boundsInWindow().left },
             { it.layoutNode.coordinates.boundsInWindow().top },
             { it.layoutNode.coordinates.boundsInWindow().bottom },
@@ -690,12 +698,12 @@
     ) {
         val isUnmergedLeafNode =
             !semanticsNode.isFake &&
-            semanticsNode.replacedChildren.isEmpty() &&
-            semanticsNode.layoutNode.findClosestParentNode {
-                it.outerSemantics
-                    ?.collapsedSemanticsConfiguration()
-                    ?.isMergingSemanticsOfDescendants == true
-            } == null
+                semanticsNode.replacedChildren.isEmpty() &&
+                semanticsNode.layoutNode.findClosestParentNode {
+                    it.outerSemantics
+                        ?.collapsedSemanticsConfiguration()
+                        ?.isMergingSemanticsOfDescendants == true
+                } == null
 
         // set classname
         info.className = ClassName
@@ -780,6 +788,7 @@
                         info.stateDescription = view.context.resources.getString(R.string.on)
                     }
                 }
+
                 ToggleableState.Off -> {
                     info.isChecked = false
                     // Unfortunately, talkback has a bug of using "not checked", so we set state
@@ -788,6 +797,7 @@
                         info.stateDescription = view.context.resources.getString(R.string.off)
                     }
                 }
+
                 ToggleableState.Indeterminate -> {
                     if (info.stateDescription == null) {
                         info.stateDescription =
@@ -833,8 +843,9 @@
             var current: SemanticsNode? = semanticsNode
             while (current != null) {
                 if (current.unmergedConfig.contains(
-                    SemanticsPropertiesAndroid.TestTagsAsResourceId
-                )) {
+                        SemanticsPropertiesAndroid.TestTagsAsResourceId
+                    )
+                ) {
                     testTagsAsResourceId = current.unmergedConfig.get(
                         SemanticsPropertiesAndroid.TestTagsAsResourceId
                     )
@@ -976,8 +987,8 @@
             info.addAction(AccessibilityNodeInfoCompat.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY)
             info.movementGranularities =
                 AccessibilityNodeInfoCompat.MOVEMENT_GRANULARITY_CHARACTER or
-                AccessibilityNodeInfoCompat.MOVEMENT_GRANULARITY_WORD or
-                AccessibilityNodeInfoCompat.MOVEMENT_GRANULARITY_PARAGRAPH
+                    AccessibilityNodeInfoCompat.MOVEMENT_GRANULARITY_WORD or
+                    AccessibilityNodeInfoCompat.MOVEMENT_GRANULARITY_PARAGRAPH
             // We only traverse the text when contentDescription is not set.
             val contentDescription = semanticsNode.unmergedConfig.getOrNull(
                 SemanticsProperties.ContentDescription
@@ -1077,6 +1088,7 @@
         fun ScrollAxisRange.canScrollForward(): Boolean {
             return value() < maxValue() && !reverseScrolling || value() > 0f && reverseScrolling
         }
+
         // Will the scrollable scroll when ACTION_SCROLL_BACKWARD is performed?
         fun ScrollAxisRange.canScrollBackward(): Boolean {
             return value() > 0f && !reverseScrolling || value() < maxValue() && reverseScrolling
@@ -1229,18 +1241,20 @@
 
         info.isScreenReaderFocusable =
             semanticsNode.unmergedConfig.isMergingSemanticsOfDescendants ||
-            isUnmergedLeafNode && isSpeakingNode
+                isUnmergedLeafNode && isSpeakingNode
 
         if (idToBeforeMap[virtualViewId] != null) {
             idToBeforeMap[virtualViewId]?.let { info.setTraversalBefore(view, it) }
             addExtraDataToAccessibilityNodeInfoHelper(
-                virtualViewId, info.unwrap(), EXTRA_DATA_TEST_TRAVERSALBEFORE_VAL, null)
+                virtualViewId, info.unwrap(), EXTRA_DATA_TEST_TRAVERSALBEFORE_VAL, null
+            )
         }
 
         if (idToAfterMap[virtualViewId] != null) {
             idToAfterMap[virtualViewId]?.let { info.setTraversalAfter(view, it) }
             addExtraDataToAccessibilityNodeInfoHelper(
-                virtualViewId, info.unwrap(), EXTRA_DATA_TEST_TRAVERSALAFTER_VAL, null)
+                virtualViewId, info.unwrap(), EXTRA_DATA_TEST_TRAVERSALAFTER_VAL, null
+            )
         }
     }
 
@@ -1454,8 +1468,10 @@
         when (action) {
             AccessibilityNodeInfoCompat.ACTION_ACCESSIBILITY_FOCUS ->
                 return requestAccessibilityFocus(virtualViewId)
+
             AccessibilityNodeInfoCompat.ACTION_CLEAR_ACCESSIBILITY_FOCUS ->
                 return clearAccessibilityFocus(virtualViewId)
+
             AccessibilityNodeInfoCompat.ACTION_NEXT_AT_MOVEMENT_GRANULARITY,
             AccessibilityNodeInfoCompat.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY -> {
                 if (arguments != null) {
@@ -1473,6 +1489,7 @@
                 }
                 return false
             }
+
             AccessibilityNodeInfoCompat.ACTION_SET_SELECTION -> {
                 val start = arguments?.getInt(
                     AccessibilityNodeInfoCompat.ACTION_ARGUMENT_SELECTION_START_INT, -1
@@ -1492,6 +1509,7 @@
                 }
                 return success
             }
+
             AccessibilityNodeInfoCompat.ACTION_COPY -> {
                 return node.unmergedConfig.getOrNull(
                     SemanticsActions.CopyText
@@ -1511,10 +1529,12 @@
                 sendEventForVirtualView(virtualViewId, AccessibilityEvent.TYPE_VIEW_CLICKED)
                 return result ?: false
             }
+
             AccessibilityNodeInfoCompat.ACTION_LONG_CLICK -> {
                 return node.unmergedConfig.getOrNull(SemanticsActions.OnLongClick)?.action?.invoke()
                     ?: false
             }
+
             AccessibilityNodeInfoCompat.ACTION_SCROLL_FORWARD,
             AccessibilityNodeInfoCompat.ACTION_SCROLL_BACKWARD,
             android.R.id.accessibilityActionScrollDown,
@@ -1597,22 +1617,27 @@
 
                 return false
             }
+
             android.R.id.accessibilityActionPageUp -> {
                 val pageAction = node.unmergedConfig.getOrNull(SemanticsActions.PageUp)
                 return pageAction?.action?.invoke() ?: false
             }
+
             android.R.id.accessibilityActionPageDown -> {
                 val pageAction = node.unmergedConfig.getOrNull(SemanticsActions.PageDown)
                 return pageAction?.action?.invoke() ?: false
             }
+
             android.R.id.accessibilityActionPageLeft -> {
                 val pageAction = node.unmergedConfig.getOrNull(SemanticsActions.PageLeft)
                 return pageAction?.action?.invoke() ?: false
             }
+
             android.R.id.accessibilityActionPageRight -> {
                 val pageAction = node.unmergedConfig.getOrNull(SemanticsActions.PageRight)
                 return pageAction?.action?.invoke() ?: false
             }
+
             android.R.id.accessibilityActionSetProgress -> {
                 if (arguments == null || !arguments.containsKey(
                         AccessibilityNodeInfoCompat.ACTION_ARGUMENT_PROGRESS_VALUE
@@ -1624,10 +1649,12 @@
                     arguments.getFloat(AccessibilityNodeInfoCompat.ACTION_ARGUMENT_PROGRESS_VALUE)
                 ) ?: false
             }
+
             AccessibilityNodeInfoCompat.ACTION_FOCUS -> {
                 return node.unmergedConfig.getOrNull(SemanticsActions.RequestFocus)
                     ?.action?.invoke() ?: false
             }
+
             AccessibilityNodeInfoCompat.ACTION_CLEAR_FOCUS -> {
                 return if (node.unmergedConfig.getOrNull(SemanticsProperties.Focused) == true) {
                     view.focusOwner.clearFocus()
@@ -1636,6 +1663,7 @@
                     false
                 }
             }
+
             AccessibilityNodeInfoCompat.ACTION_SET_TEXT -> {
                 val text = arguments?.getString(
                     AccessibilityNodeInfoCompat.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE
@@ -1643,31 +1671,37 @@
                 return node.unmergedConfig.getOrNull(SemanticsActions.SetText)
                     ?.action?.invoke(AnnotatedString(text ?: "")) ?: false
             }
+
             AccessibilityNodeInfoCompat.ACTION_PASTE -> {
                 return node.unmergedConfig.getOrNull(
                     SemanticsActions.PasteText
                 )?.action?.invoke() ?: false
             }
+
             AccessibilityNodeInfoCompat.ACTION_CUT -> {
                 return node.unmergedConfig.getOrNull(
                     SemanticsActions.CutText
                 )?.action?.invoke() ?: false
             }
+
             AccessibilityNodeInfoCompat.ACTION_EXPAND -> {
                 return node.unmergedConfig.getOrNull(
                     SemanticsActions.Expand
                 )?.action?.invoke() ?: false
             }
+
             AccessibilityNodeInfoCompat.ACTION_COLLAPSE -> {
                 return node.unmergedConfig.getOrNull(
                     SemanticsActions.Collapse
                 )?.action?.invoke() ?: false
             }
+
             AccessibilityNodeInfoCompat.ACTION_DISMISS -> {
                 return node.unmergedConfig.getOrNull(
                     SemanticsActions.Dismiss
                 )?.action?.invoke() ?: false
             }
+
             android.R.id.accessibilityActionShowOnScreen -> {
                 // TODO(b/190865803): Consider scrolling nested containers instead of only the first one.
                 var scrollableAncestor: SemanticsNode? = node.parent
@@ -1861,17 +1895,20 @@
                 updateHoveredVirtualView(virtualViewId)
                 return if (virtualViewId == InvalidId) handled else true
             }
+
             MotionEvent.ACTION_HOVER_EXIT -> {
                 return when {
                     hoveredVirtualViewId != InvalidId -> {
                         updateHoveredVirtualView(InvalidId)
                         true
                     }
+
                     else -> {
                         view.androidViewsHandler.dispatchGenericMotionEvent(event)
                     }
                 }
             }
+
             else -> {
                 return false
             }
@@ -2172,6 +2209,7 @@
                             )
                         }
                     }
+
                     SemanticsProperties.StateDescription, SemanticsProperties.ToggleableState -> {
                         sendEventForVirtualView(
                             semanticsNodeIdToAccessibilityVirtualNodeId(id),
@@ -2187,6 +2225,7 @@
                             AccessibilityEventCompat.CONTENT_CHANGE_TYPE_UNDEFINED
                         )
                     }
+
                     SemanticsProperties.ProgressBarRangeInfo -> {
                         sendEventForVirtualView(
                             semanticsNodeIdToAccessibilityVirtualNodeId(id),
@@ -2202,6 +2241,7 @@
                             AccessibilityEventCompat.CONTENT_CHANGE_TYPE_UNDEFINED
                         )
                     }
+
                     SemanticsProperties.Selected -> {
                         // The assumption is among widgets using SemanticsProperties.Selected, only
                         // Tab is using AccessibilityNodeInfo#isSelected, and all others are using
@@ -2249,6 +2289,7 @@
                             )
                         }
                     }
+
                     SemanticsProperties.ContentDescription -> {
                         sendEventForVirtualView(
                             semanticsNodeIdToAccessibilityVirtualNodeId(id),
@@ -2257,6 +2298,7 @@
                             entry.value as List<String>
                         )
                     }
+
                     SemanticsProperties.EditableText -> {
                         if (newNode.isTextField) {
 
@@ -2358,6 +2400,7 @@
                         sendEvent(event)
                         sendPendingTextTraversedAtGranularityEvent(newNode.id)
                     }
+
                     SemanticsProperties.HorizontalScrollAxisRange,
                     SemanticsProperties.VerticalScrollAxisRange -> {
                         // TODO(yingleiw): Add throttling for scroll/state events.
@@ -2372,6 +2415,7 @@
                         )
                         sendScrollEventIfNeeded(scope)
                     }
+
                     SemanticsProperties.Focused -> {
                         if (entry.value as Boolean) {
                             sendEvent(
@@ -2390,6 +2434,7 @@
                             AccessibilityEvent.CONTENT_CHANGE_TYPE_UNDEFINED
                         )
                     }
+
                     CustomActions -> {
                         val actions = newNode.unmergedConfig[CustomActions]
                         val oldActions = oldNode.unmergedConfig.getOrNull(CustomActions)
@@ -2753,16 +2798,19 @@
                 )
                 iterator.initialize(text)
             }
+
             AccessibilityNodeInfoCompat.MOVEMENT_GRANULARITY_WORD -> {
                 iterator = AccessibilityIterators.WordTextSegmentIterator.getInstance(
                     view.context.resources.configuration.locale
                 )
                 iterator.initialize(text)
             }
+
             AccessibilityNodeInfoCompat.MOVEMENT_GRANULARITY_PARAGRAPH -> {
                 iterator = AccessibilityIterators.ParagraphTextSegmentIterator.getInstance()
                 iterator.initialize(text)
             }
+
             AccessibilityNodeInfoCompat.MOVEMENT_GRANULARITY_LINE,
             AccessibilityNodeInfoCompat.MOVEMENT_GRANULARITY_PAGE -> {
                 // Line and page granularity are only for static text or text field.
@@ -2789,6 +2837,7 @@
                     iterator.initialize(text, textLayoutResult, node)
                 }
             }
+
             else -> return null
         }
         return iterator
@@ -2827,8 +2876,8 @@
     inner class MyNodeProvider : AccessibilityNodeProvider() {
         override fun createAccessibilityNodeInfo(virtualViewId: Int):
             AccessibilityNodeInfo? {
-                return createNodeInfo(virtualViewId)
-            }
+            return createNodeInfo(virtualViewId)
+        }
 
         override fun performAction(
             virtualViewId: Int,
@@ -2943,8 +2992,9 @@
 private val SemanticsNode.isTextField get() = this.unmergedConfig.contains(SemanticsActions.SetText)
 private val SemanticsNode.isRtl get() = layoutInfo.layoutDirection == LayoutDirection.Rtl
 private val SemanticsNode.isContainer get() = config.getOrNull(SemanticsProperties.IsContainer)
-private val SemanticsNode.hasCollectionInfo get() =
-    config.contains(SemanticsProperties.CollectionInfo)
+private val SemanticsNode.hasCollectionInfo
+    get() =
+        config.contains(SemanticsProperties.CollectionInfo)
 private val SemanticsNode.isScrollable get() = config.contains(SemanticsActions.ScrollBy)
 
 private val SemanticsNode.semanticsNodeIsStructurallySignificant: Boolean
@@ -2954,7 +3004,8 @@
         if (this.isContainer == false) {
             return false
         } else if (this.isContainer == true ||
-            this.hasCollectionInfo || this.isScrollable) {
+            this.hasCollectionInfo || this.isScrollable
+        ) {
             return true
         }
         return false
@@ -2974,8 +3025,8 @@
     }
     return ancestor != null &&
         ancestor.outerSemantics
-        ?.collapsedSemanticsConfiguration()
-        ?.getOrNull(SemanticsProperties.Focused) != true
+            ?.collapsedSemanticsConfiguration()
+            ?.getOrNull(SemanticsProperties.Focused) != true
 }
 
 private fun AccessibilityAction<*>.accessibilityEquals(other: Any?): Boolean {
@@ -3002,8 +3053,8 @@
  * completely covered by siblings drawn on top of it will be pruned. Return the results in a
  * map.
  */
-internal fun SemanticsOwner
-.getAllUncoveredSemanticsNodesToMap(): Map<Int, SemanticsNodeWithAdjustedBounds> {
+internal fun SemanticsOwner.getAllUncoveredSemanticsNodesToMap():
+    Map<Int, SemanticsNodeWithAdjustedBounds> {
     val root = unmergedRootSemanticsNode
     val nodes = mutableMapOf<Int, SemanticsNodeWithAdjustedBounds>()
     if (!root.layoutNode.isPlaced || !root.layoutNode.isAttached) {
diff --git a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/text/input/AndroidTextInputServicePlugin.kt b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/text/input/AndroidTextInputServicePlugin.kt
new file mode 100644
index 0000000..1d2be4b0
--- /dev/null
+++ b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/text/input/AndroidTextInputServicePlugin.kt
@@ -0,0 +1,67 @@
+/*
+ * 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.
+ */
+
+@file:OptIn(ExperimentalTextApi::class)
+
+package androidx.compose.ui.text.input
+
+import android.view.View
+import android.view.inputmethod.EditorInfo
+import android.view.inputmethod.InputConnection
+import androidx.compose.ui.InternalComposeUiApi
+import androidx.compose.ui.platform.textInputServiceFactory
+import androidx.compose.ui.text.ExperimentalTextApi
+import androidx.compose.ui.text.input.AndroidTextInputServicePlugin.Adapter
+
+/**
+ * The [PlatformTextInputAdapter] that is responsible for creating [TextInputService]s and bridging
+ * from Android APIs to [TextInputService] APIs.
+ *
+ * If some of this code seems unnecessarily complex, it's because this layer was introduced after
+ * the rest of the text system was built in order to allow us to move the entirety of the text
+ * implementation to a different module. The original [TextInputService] infrastructure was adapted
+ * as-is.
+ *
+ * For example, this object uses both the [TextInputService] and the platform-specific
+ * [TextInputServiceAndroid] as the "service" object because the android-specific APIs it needs to
+ * delegate to are only available on the latter, but it needs to have access to the former as well
+ * to support [PlatformTextInputAdapter.inputForTests].
+ */
+internal object AndroidTextInputServicePlugin : PlatformTextInputPlugin<Adapter> {
+
+    @OptIn(InternalComposeUiApi::class)
+    override fun createAdapter(platformTextInput: PlatformTextInput, view: View): Adapter {
+        val platformService = TextInputServiceAndroid(view, platformTextInput)
+        // This indirection is used for tests (see testInput above). This could be cleaned up now
+        // that both halves live in the same class, but not worth the refactoring given the text
+        // field api rewrite.
+        val service = textInputServiceFactory(platformService)
+        return Adapter(service, platformService)
+    }
+
+    class Adapter(
+        val service: TextInputService,
+        private val androidService: TextInputServiceAndroid
+    ) : PlatformTextInputAdapter {
+
+        override val inputForTests: TextInputForTests
+            get() = service as? TextInputForTests
+                ?: error("Text input service wrapper not set up! Did you use ComposeTestRule?")
+
+        override fun createInputConnection(outAttrs: EditorInfo): InputConnection =
+            androidService.createInputConnection(outAttrs)
+    }
+}
\ No newline at end of file
diff --git a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/text/input/TextInputServiceAndroid.android.kt b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/text/input/TextInputServiceAndroid.android.kt
index fd63e3f0..411909b 100644
--- a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/text/input/TextInputServiceAndroid.android.kt
+++ b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/text/input/TextInputServiceAndroid.android.kt
@@ -14,6 +14,8 @@
  * limitations under the License.
  */
 
+@file:OptIn(ExperimentalTextApi::class)
+
 package androidx.compose.ui.text.input
 
 import android.graphics.Rect as AndroidRect
@@ -27,6 +29,7 @@
 import android.view.inputmethod.InputConnection
 import androidx.compose.runtime.collection.mutableVectorOf
 import androidx.compose.ui.geometry.Rect
+import androidx.compose.ui.text.ExperimentalTextApi
 import androidx.compose.ui.text.TextRange
 import androidx.compose.ui.text.input.TextInputServiceAndroid.TextInputCommand.HideKeyboard
 import androidx.compose.ui.text.input.TextInputServiceAndroid.TextInputCommand.ShowKeyboard
@@ -49,6 +52,7 @@
 internal class TextInputServiceAndroid(
     val view: View,
     private val inputMethodManager: InputMethodManager,
+    private val platformTextInput: PlatformTextInput? = null,
     private val inputCommandProcessorExecutor: Executor = Choreographer.getInstance().asExecutor(),
 ) : PlatformTextInputService {
 
@@ -64,12 +68,6 @@
     }
 
     /**
-     * True if the currently editable composable has connected. This is used to tell the platform
-     * when it asks if the compose view is a text editor.
-     */
-    private var editorHasFocus = false
-
-    /**
      *  The following three observers are set when the editable composable has initiated the input
      *  session
      */
@@ -103,7 +101,11 @@
     private val textInputCommandQueue = mutableVectorOf<TextInputCommand>()
     private var frameCallback: Runnable? = null
 
-    internal constructor(view: View) : this(view, InputMethodManagerImpl(view))
+    constructor(view: View, context: PlatformTextInput? = null) : this(
+        view,
+        InputMethodManagerImpl(view),
+        context
+    )
 
     init {
         if (DEBUG) {
@@ -114,11 +116,7 @@
     /**
      * Creates new input connection.
      */
-    fun createInputConnection(outAttrs: EditorInfo): InputConnection? {
-        if (!editorHasFocus) {
-            return null
-        }
-
+    fun createInputConnection(outAttrs: EditorInfo): InputConnection {
         outAttrs.update(imeOptions, state)
         outAttrs.updateWithEmojiCompat()
 
@@ -155,11 +153,6 @@
         }
     }
 
-    /**
-     * Returns true if some editable component is focused.
-     */
-    fun isEditorFocused(): Boolean = editorHasFocus
-
     override fun startInput(
         value: TextFieldValue,
         imeOptions: ImeOptions,
@@ -170,7 +163,7 @@
             Log.d(TAG, "$DEBUG_CLASS.startInput")
         }
 
-        editorHasFocus = true
+        platformTextInput?.requestInputFocus()
         state = value
         this.imeOptions = imeOptions
         this.onEditCommand = onEditCommand
@@ -184,7 +177,7 @@
     override fun stopInput() {
         if (DEBUG) Log.d(TAG, "$DEBUG_CLASS.stopInput")
 
-        editorHasFocus = false
+        platformTextInput?.releaseInputFocus()
         onEditCommand = {}
         onImeActionPerformed = {}
         focusedRect = null
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/Owner.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/Owner.kt
index 9a7c79c..671f19c 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/Owner.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/Owner.kt
@@ -33,8 +33,10 @@
 import androidx.compose.ui.platform.TextToolbar
 import androidx.compose.ui.platform.ViewConfiguration
 import androidx.compose.ui.platform.WindowInfo
+import androidx.compose.ui.text.ExperimentalTextApi
 import androidx.compose.ui.text.font.Font
 import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.input.PlatformTextInputPluginRegistry
 import androidx.compose.ui.text.input.TextInputService
 import androidx.compose.ui.unit.Constraints
 import androidx.compose.ui.unit.Density
@@ -109,6 +111,9 @@
 
     val textInputService: TextInputService
 
+    @OptIn(ExperimentalTextApi::class)
+    val platformTextInputPluginRegistry: PlatformTextInputPluginRegistry
+
     val pointerIconService: PointerIconService
 
     /**
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/RootForTest.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/RootForTest.kt
index e009d51..4d8b129 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/RootForTest.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/RootForTest.kt
@@ -19,6 +19,8 @@
 import androidx.compose.ui.ExperimentalComposeUiApi
 import androidx.compose.ui.input.key.KeyEvent
 import androidx.compose.ui.semantics.SemanticsOwner
+import androidx.compose.ui.text.ExperimentalTextApi
+import androidx.compose.ui.text.input.TextInputForTests
 import androidx.compose.ui.text.input.TextInputService
 import androidx.compose.ui.unit.Density
 
@@ -43,6 +45,15 @@
     val textInputService: TextInputService
 
     /**
+     * The [TextInputForTests] for the active text service in this root, or null if no text service
+     * is actively handling an input session.
+     */
+    @Suppress("OPT_IN_MARKER_ON_WRONG_TARGET")
+    @ExperimentalTextApi
+    @get:ExperimentalTextApi
+    val textInputForTests: TextInputForTests? get() = null
+
+    /**
      * Send this [KeyEvent] to the focused component in this [Owner].
      *
      * @return true if the event was consumed. False otherwise.
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/platform/CompositionLocals.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/platform/CompositionLocals.kt
index 158a357..35bacae 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/platform/CompositionLocals.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/platform/CompositionLocals.kt
@@ -29,8 +29,10 @@
 import androidx.compose.ui.input.pointer.PointerIconService
 import androidx.compose.ui.layout.Layout
 import androidx.compose.ui.node.Owner
+import androidx.compose.ui.text.ExperimentalTextApi
 import androidx.compose.ui.text.font.Font
 import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.input.PlatformTextInputPluginRegistry
 import androidx.compose.ui.text.input.TextInputService
 import androidx.compose.ui.unit.Density
 import androidx.compose.ui.unit.LayoutDirection
@@ -136,6 +138,20 @@
 val LocalTextInputService = staticCompositionLocalOf<TextInputService?> { null }
 
 /**
+ * The CompositionLocal to provide platform text input services.
+ *
+ * This is a low-level API for code that talks directly to the platform input method framework.
+ * Higher-level text input APIs in the Foundation library are more appropriate for most cases.
+ */
+@Suppress("OPT_IN_MARKER_ON_WRONG_TARGET")
+@ExperimentalTextApi
+@get:ExperimentalTextApi
+val LocalPlatformTextInputPluginRegistry =
+    staticCompositionLocalOf<PlatformTextInputPluginRegistry> {
+        error("No PlatformTextInputServiceProvider provided")
+    }
+
+/**
  * The CompositionLocal to provide text-related toolbar.
  */
 val LocalTextToolbar = staticCompositionLocalOf<TextToolbar> {
@@ -167,6 +183,7 @@
     null
 }
 
+@OptIn(ExperimentalTextApi::class)
 @ExperimentalComposeUiApi
 @Composable
 internal fun ProvideCommonCompositionLocals(
@@ -188,6 +205,7 @@
         LocalInputModeManager provides owner.inputModeManager,
         LocalLayoutDirection provides owner.layoutDirection,
         LocalTextInputService provides owner.textInputService,
+        LocalPlatformTextInputPluginRegistry provides owner.platformTextInputPluginRegistry,
         LocalTextToolbar provides owner.textToolbar,
         LocalUriHandler provides uriHandler,
         LocalViewConfiguration provides owner.viewConfiguration,
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/semantics/SemanticsProperties.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/semantics/SemanticsProperties.kt
index 09a0e4c..1bd632b 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/semantics/SemanticsProperties.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/semantics/SemanticsProperties.kt
@@ -587,6 +587,7 @@
          * [SemanticsProperties.Disabled], [SemanticsActions.OnClick]
          */
         val Button = Role(0)
+
         /**
          * This element is a Checkbox which is a component that represents two states (checked /
          * unchecked). Associated semantics properties for accessibility:
@@ -594,6 +595,7 @@
          * [SemanticsActions.OnClick]
          */
         val Checkbox = Role(1)
+
         /**
          * This element is a Switch which is a two state toggleable component that provides on/off
          * like options. Associated semantics properties for accessibility:
@@ -601,12 +603,14 @@
          * [SemanticsActions.OnClick]
          */
         val Switch = Role(2)
+
         /**
          * This element is a RadioButton which is a component to represent two states, selected and not
          * selected. Associated semantics properties for accessibility: [SemanticsProperties.Disabled],
          * [SemanticsProperties.StateDescription], [SemanticsActions.OnClick]
          */
         val RadioButton = Role(3)
+
         /**
          * This element is a Tab which represents a single page of content using a text label and/or
          * icon. A Tab also has two states: selected and not selected. Associated semantics properties
@@ -614,11 +618,13 @@
          * [SemanticsActions.OnClick]
          */
         val Tab = Role(4)
+
         /**
          * This element is an image. Associated semantics properties for accessibility:
          * [SemanticsProperties.ContentDescription]
          */
         val Image = Role(5)
+
         /**
          * This element is associated with a drop down menu.
          * Associated semantics properties for accessibility:
@@ -653,6 +659,7 @@
          * changes to this node.
          */
         val Polite = LiveRegionMode(0)
+
         /**
          * Live region mode specifying that accessibility services should interrupt
          * ongoing speech to immediately announce changes to this node.
@@ -688,7 +695,9 @@
  */
 var SemanticsPropertyReceiver.contentDescription: String
     get() = throwSemanticsGetNotSupported()
-    set(value) { set(SemanticsProperties.ContentDescription, listOf(value)) }
+    set(value) {
+        set(SemanticsProperties.ContentDescription, listOf(value))
+    }
 
 /**
  * Developer-set state description of the semantics node.
@@ -786,13 +795,13 @@
  * The horizontal scroll state of this node if this node is scrollable.
  */
 var SemanticsPropertyReceiver.horizontalScrollAxisRange
-by SemanticsProperties.HorizontalScrollAxisRange
+    by SemanticsProperties.HorizontalScrollAxisRange
 
 /**
  * The vertical scroll state of this node if this node is scrollable.
  */
 var SemanticsPropertyReceiver.verticalScrollAxisRange
-by SemanticsProperties.VerticalScrollAxisRange
+    by SemanticsProperties.VerticalScrollAxisRange
 
 /**
  * Whether this semantics node represents a Popup. Not to be confused with if this node is
@@ -838,7 +847,9 @@
  */
 var SemanticsPropertyReceiver.text: AnnotatedString
     get() = throwSemanticsGetNotSupported()
-    set(value) { set(SemanticsProperties.Text, listOf(value)) }
+    set(value) {
+        set(SemanticsProperties.Text, listOf(value))
+    }
 
 /**
  * Input text of the text field with visual transformation applied to it. It must be a real text
@@ -921,7 +932,7 @@
  * with lazy collections, it won't get the number of elements in the collection.
  *
  * @see SemanticsPropertyReceiver.selected
-*/
+ */
 fun SemanticsPropertyReceiver.selectableGroup() {
     this[SemanticsProperties.SelectableGroup] = Unit
 }
@@ -1026,11 +1037,13 @@
  * using [textSelectionRange].
  *
  * @param label Optional label for this action.
- * @param action Action to be performed when the [SemanticsActions.SetSelection] is called.
+ * @param action Action to be performed when the [SemanticsActions.SetSelection] is called. The
+ * parameters to the action are: `startIndex`, `endIndex`, and whether the indices are relative
+ * to the original text or the transformed text (when a `VisualTransformation` is applied).
  */
 fun SemanticsPropertyReceiver.setSelection(
     label: String? = null,
-    action: ((startIndex: Int, endIndex: Int, traversalMode: Boolean) -> Boolean)?
+    action: ((startIndex: Int, endIndex: Int, relativeToOriginalText: Boolean) -> Boolean)?
 ) {
     this[SemanticsActions.SetSelection] = AccessibilityAction(label, action)
 }
diff --git a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/platform/PlatformClipboardManager.skiko.kt b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/platform/PlatformClipboardManager.skiko.kt
index 860f026..cf28fac 100644
--- a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/platform/PlatformClipboardManager.skiko.kt
+++ b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/platform/PlatformClipboardManager.skiko.kt
@@ -16,4 +16,4 @@
 
 package androidx.compose.ui.platform
 
-internal expect class PlatformClipboardManager : ClipboardManager
+internal expect class PlatformClipboardManager() : ClipboardManager
diff --git a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/platform/SkiaBasedOwner.skiko.kt b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/platform/SkiaBasedOwner.skiko.kt
index 8114dfc..4a337ec 100644
--- a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/platform/SkiaBasedOwner.skiko.kt
+++ b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/platform/SkiaBasedOwner.skiko.kt
@@ -71,6 +71,8 @@
 import androidx.compose.ui.node.RootForTest
 import androidx.compose.ui.semantics.SemanticsModifierCore
 import androidx.compose.ui.semantics.SemanticsOwner
+import androidx.compose.ui.text.ExperimentalTextApi
+import androidx.compose.ui.text.input.PlatformTextInputPluginRegistry
 import androidx.compose.ui.text.input.TextInputService
 import androidx.compose.ui.text.font.createFontFamilyResolver
 import androidx.compose.ui.text.platform.FontLoader
@@ -81,11 +83,14 @@
 import androidx.compose.ui.unit.IntRect
 import androidx.compose.ui.unit.LayoutDirection
 import androidx.compose.ui.input.pointer.PointerKeyboardModifiers
+import androidx.compose.ui.text.InternalTextApi
+import androidx.compose.ui.text.input.PlatformTextInputPluginRegistryImpl
 
 private typealias Command = () -> Unit
 
 @OptIn(
     ExperimentalComposeUiApi::class,
+    ExperimentalTextApi::class,
     InternalCoreApi::class,
     InternalComposeUiApi::class
 )
@@ -207,6 +212,13 @@
 
     override val textInputService = TextInputService(platformInputService)
 
+    @Suppress("UNUSED_ANONYMOUS_PARAMETER")
+    @OptIn(InternalTextApi::class)
+    override val platformTextInputPluginRegistry: PlatformTextInputPluginRegistry
+        get() = PlatformTextInputPluginRegistryImpl { factory, platformTextInput ->
+            TODO("See https://issuetracker.google.com/267235947")
+        }
+
     @Deprecated(
         "fontLoader is deprecated, use fontFamilyResolver",
         replaceWith = ReplaceWith("fontFamilyResolver")
diff --git a/compose/ui/ui/src/test/kotlin/androidx/compose/ui/node/LayoutNodeTest.kt b/compose/ui/ui/src/test/kotlin/androidx/compose/ui/node/LayoutNodeTest.kt
index 1f258e8..0957ca6 100644
--- a/compose/ui/ui/src/test/kotlin/androidx/compose/ui/node/LayoutNodeTest.kt
+++ b/compose/ui/ui/src/test/kotlin/androidx/compose/ui/node/LayoutNodeTest.kt
@@ -61,8 +61,10 @@
 import androidx.compose.ui.platform.invertTo
 import androidx.compose.ui.semantics.SemanticsConfiguration
 import androidx.compose.ui.semantics.SemanticsModifier
+import androidx.compose.ui.text.ExperimentalTextApi
 import androidx.compose.ui.text.font.Font
 import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.input.PlatformTextInputPluginRegistry
 import androidx.compose.ui.text.input.TextInputService
 import androidx.compose.ui.unit.Constraints
 import androidx.compose.ui.unit.Density
@@ -2525,6 +2527,9 @@
         get() = Density(1f)
     override val textInputService: TextInputService
         get() = TODO("Not yet implemented")
+    @OptIn(ExperimentalTextApi::class)
+    override val platformTextInputPluginRegistry: PlatformTextInputPluginRegistry
+        get() = TODO("Not yet implemented")
     override val pointerIconService: PointerIconService
         get() = TODO("Not yet implemented")
     override val focusOwner: FocusOwner
diff --git a/compose/ui/ui/src/test/kotlin/androidx/compose/ui/node/ModifierLocalConsumerEntityTest.kt b/compose/ui/ui/src/test/kotlin/androidx/compose/ui/node/ModifierLocalConsumerEntityTest.kt
index 5b7e198..6643267 100644
--- a/compose/ui/ui/src/test/kotlin/androidx/compose/ui/node/ModifierLocalConsumerEntityTest.kt
+++ b/compose/ui/ui/src/test/kotlin/androidx/compose/ui/node/ModifierLocalConsumerEntityTest.kt
@@ -41,8 +41,10 @@
 import androidx.compose.ui.platform.TextToolbar
 import androidx.compose.ui.platform.ViewConfiguration
 import androidx.compose.ui.platform.WindowInfo
+import androidx.compose.ui.text.ExperimentalTextApi
 import androidx.compose.ui.text.font.Font
 import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.input.PlatformTextInputPluginRegistry
 import androidx.compose.ui.text.input.TextInputService
 import androidx.compose.ui.unit.Constraints
 import androidx.compose.ui.unit.Density
@@ -340,6 +342,9 @@
             get() = TODO("Not yet implemented")
         override val textInputService: TextInputService
             get() = TODO("Not yet implemented")
+        @OptIn(ExperimentalTextApi::class)
+        override val platformTextInputPluginRegistry: PlatformTextInputPluginRegistry
+            get() = TODO("Not yet implemented")
         override val pointerIconService: PointerIconService
             get() = TODO("Not yet implemented")
         override val focusOwner: FocusOwner
diff --git a/compose/ui/ui/src/test/kotlin/androidx/compose/ui/text/input/TextInputServiceAndroidCommandDebouncingTest.kt b/compose/ui/ui/src/test/kotlin/androidx/compose/ui/text/input/TextInputServiceAndroidCommandDebouncingTest.kt
index ba426db..4fd72dc 100644
--- a/compose/ui/ui/src/test/kotlin/androidx/compose/ui/text/input/TextInputServiceAndroidCommandDebouncingTest.kt
+++ b/compose/ui/ui/src/test/kotlin/androidx/compose/ui/text/input/TextInputServiceAndroidCommandDebouncingTest.kt
@@ -39,7 +39,8 @@
     private val view = mock<View>()
     private val inputMethodManager = TestInputMethodManager()
     private val executor = Executor { runnable -> scope.launch { runnable.run() } }
-    private val service = TextInputServiceAndroid(view, inputMethodManager, executor)
+    private val service =
+        TextInputServiceAndroid(view, inputMethodManager, inputCommandProcessorExecutor = executor)
     private val dispatcher = StandardTestDispatcher()
     private val scope = TestScope(dispatcher + Job())
 
@@ -195,7 +196,8 @@
         assertThat(inputMethodManager.hideSoftInputCalls).isEqualTo(0)
     }
 
-    @Test fun stopInput_isNotProcessedImmediately() {
+    @Test
+    fun stopInput_isNotProcessedImmediately() {
         service.stopInput()
 
         assertThat(inputMethodManager.restartCalls).isEqualTo(0)
@@ -203,7 +205,8 @@
         assertThat(inputMethodManager.hideSoftInputCalls).isEqualTo(0)
     }
 
-    @Test fun startInput_isNotProcessedImmediately() {
+    @Test
+    fun startInput_isNotProcessedImmediately() {
         service.startInput()
 
         assertThat(inputMethodManager.restartCalls).isEqualTo(0)
@@ -211,7 +214,8 @@
         assertThat(inputMethodManager.hideSoftInputCalls).isEqualTo(0)
     }
 
-    @Test fun showSoftwareKeyboard_isNotProcessedImmediately() {
+    @Test
+    fun showSoftwareKeyboard_isNotProcessedImmediately() {
         service.showSoftwareKeyboard()
 
         assertThat(inputMethodManager.restartCalls).isEqualTo(0)
@@ -219,7 +223,8 @@
         assertThat(inputMethodManager.hideSoftInputCalls).isEqualTo(0)
     }
 
-    @Test fun hideSoftwareKeyboard_isNotProcessedImmediately() {
+    @Test
+    fun hideSoftwareKeyboard_isNotProcessedImmediately() {
         service.hideSoftwareKeyboard()
 
         assertThat(inputMethodManager.restartCalls).isEqualTo(0)
@@ -227,7 +232,8 @@
         assertThat(inputMethodManager.hideSoftInputCalls).isEqualTo(0)
     }
 
-    @Test fun commandsAreIgnored_ifFocusLostBeforeProcessing() {
+    @Test
+    fun commandsAreIgnored_ifFocusLostBeforeProcessing() {
         // Send command while view still has focus.
         service.showSoftwareKeyboard()
         // Blur the view.
@@ -238,7 +244,8 @@
         assertThat(inputMethodManager.showSoftInputCalls).isEqualTo(0)
     }
 
-    @Test fun commandsAreDrained_whenProcessedWithoutFocus() {
+    @Test
+    fun commandsAreDrained_whenProcessedWithoutFocus() {
         whenever(view.isFocused).thenReturn(false)
         service.showSoftwareKeyboard()
         service.hideSoftwareKeyboard()
diff --git a/constraintlayout/constraintlayout-compose/api/current.txt b/constraintlayout/constraintlayout-compose/api/current.txt
index b0e909f..22002f0 100644
--- a/constraintlayout/constraintlayout-compose/api/current.txt
+++ b/constraintlayout/constraintlayout-compose/api/current.txt
@@ -144,9 +144,9 @@
     method public final androidx.constraintlayout.compose.ConstrainedLayoutReference createFlow(androidx.constraintlayout.compose.LayoutReference![]? elements, optional boolean flowVertically, optional float verticalGap, optional float horizontalGap, optional int maxElement, optional float padding, optional androidx.constraintlayout.compose.Wrap wrapMode, optional androidx.constraintlayout.compose.VerticalAlign verticalAlign, optional androidx.constraintlayout.compose.HorizontalAlign horizontalAlign, optional float horizontalFlowBias, optional float verticalFlowBias, optional androidx.constraintlayout.compose.FlowStyle verticalStyle, optional androidx.constraintlayout.compose.FlowStyle horizontalStyle);
     method public final androidx.constraintlayout.compose.ConstrainedLayoutReference createFlow(androidx.constraintlayout.compose.LayoutReference![]? elements, optional boolean flowVertically, optional float verticalGap, optional float horizontalGap, optional int maxElement, optional float paddingHorizontal, optional float paddingVertical, optional androidx.constraintlayout.compose.Wrap wrapMode, optional androidx.constraintlayout.compose.VerticalAlign verticalAlign, optional androidx.constraintlayout.compose.HorizontalAlign horizontalAlign, optional float horizontalFlowBias, optional float verticalFlowBias, optional androidx.constraintlayout.compose.FlowStyle verticalStyle, optional androidx.constraintlayout.compose.FlowStyle horizontalStyle);
     method public final androidx.constraintlayout.compose.ConstrainedLayoutReference createFlow(androidx.constraintlayout.compose.LayoutReference![]? elements, optional boolean flowVertically, optional float verticalGap, optional float horizontalGap, optional int maxElement, optional float paddingLeft, optional float paddingTop, optional float paddingRight, optional float paddingBottom, optional androidx.constraintlayout.compose.Wrap wrapMode, optional androidx.constraintlayout.compose.VerticalAlign verticalAlign, optional androidx.constraintlayout.compose.HorizontalAlign horizontalAlign, optional float horizontalFlowBias, optional float verticalFlowBias, optional androidx.constraintlayout.compose.FlowStyle verticalStyle, optional androidx.constraintlayout.compose.FlowStyle horizontalStyle);
-    method public final androidx.constraintlayout.compose.ConstrainedLayoutReference createGrid(androidx.constraintlayout.compose.LayoutReference![] elements, optional int orientation, optional int rows, optional int columns, optional float verticalGap, optional float horizontalGap, optional int[] rowWeights, optional int[] columnWeights, optional String skips, optional String spans, optional float padding);
-    method public final androidx.constraintlayout.compose.ConstrainedLayoutReference createGrid(androidx.constraintlayout.compose.LayoutReference![] elements, optional int orientation, optional int rows, optional int columns, optional float verticalGap, optional float horizontalGap, optional int[] rowWeights, optional int[] columnWeights, optional String skips, optional String spans, optional float paddingHorizontal, optional float paddingVertical);
-    method public final androidx.constraintlayout.compose.ConstrainedLayoutReference createGrid(androidx.constraintlayout.compose.LayoutReference![] elements, optional int orientation, optional int rows, optional int columns, optional float verticalGap, optional float horizontalGap, optional int[] rowWeights, optional int[] columnWeights, optional String skips, optional String spans, optional float paddingLeft, optional float paddingTop, optional float paddingRight, optional float paddingBottom);
+    method public final androidx.constraintlayout.compose.ConstrainedLayoutReference createGrid(androidx.constraintlayout.compose.LayoutReference![] elements, optional int orientation, optional int rows, optional int columns, optional float verticalGap, optional float horizontalGap, optional int[] rowWeights, optional int[] columnWeights, optional String skips, optional String spans, optional float padding, optional androidx.constraintlayout.compose.GridFlag![] flags);
+    method public final androidx.constraintlayout.compose.ConstrainedLayoutReference createGrid(androidx.constraintlayout.compose.LayoutReference![] elements, optional int orientation, optional int rows, optional int columns, optional float verticalGap, optional float horizontalGap, optional int[] rowWeights, optional int[] columnWeights, optional String skips, optional String spans, optional float paddingHorizontal, optional float paddingVertical, optional androidx.constraintlayout.compose.GridFlag![] flags);
+    method public final androidx.constraintlayout.compose.ConstrainedLayoutReference createGrid(androidx.constraintlayout.compose.LayoutReference![] elements, optional int orientation, optional int rows, optional int columns, optional float verticalGap, optional float horizontalGap, optional int[] rowWeights, optional int[] columnWeights, optional String skips, optional String spans, optional float paddingLeft, optional float paddingTop, optional float paddingRight, optional float paddingBottom, optional androidx.constraintlayout.compose.GridFlag![] flags);
     method public final androidx.constraintlayout.compose.ConstraintLayoutBaseScope.VerticalAnchor createGuidelineFromAbsoluteLeft(float offset);
     method public final androidx.constraintlayout.compose.ConstraintLayoutBaseScope.VerticalAnchor createGuidelineFromAbsoluteLeft(float fraction);
     method public final androidx.constraintlayout.compose.ConstraintLayoutBaseScope.VerticalAnchor createGuidelineFromAbsoluteRight(float offset);
@@ -339,6 +339,17 @@
     property public final androidx.constraintlayout.compose.FlowStyle SpreadInside;
   }
 
+  @androidx.compose.runtime.Immutable public final class GridFlag {
+    field public static final androidx.constraintlayout.compose.GridFlag.Companion Companion;
+  }
+
+  public static final class GridFlag.Companion {
+    method public androidx.constraintlayout.compose.GridFlag getSpansRespectWidgetOrder();
+    method public androidx.constraintlayout.compose.GridFlag getSubGridByColRow();
+    property public final androidx.constraintlayout.compose.GridFlag SpansRespectWidgetOrder;
+    property public final androidx.constraintlayout.compose.GridFlag SubGridByColRow;
+  }
+
   @androidx.compose.runtime.Immutable public final class HorizontalAlign {
     field public static final androidx.constraintlayout.compose.HorizontalAlign.Companion Companion;
   }
diff --git a/constraintlayout/constraintlayout-compose/api/public_plus_experimental_current.txt b/constraintlayout/constraintlayout-compose/api/public_plus_experimental_current.txt
index 6766d73..80dd7e5 100644
--- a/constraintlayout/constraintlayout-compose/api/public_plus_experimental_current.txt
+++ b/constraintlayout/constraintlayout-compose/api/public_plus_experimental_current.txt
@@ -179,9 +179,9 @@
     method public final androidx.constraintlayout.compose.ConstrainedLayoutReference createFlow(androidx.constraintlayout.compose.LayoutReference![]? elements, optional boolean flowVertically, optional float verticalGap, optional float horizontalGap, optional int maxElement, optional float padding, optional androidx.constraintlayout.compose.Wrap wrapMode, optional androidx.constraintlayout.compose.VerticalAlign verticalAlign, optional androidx.constraintlayout.compose.HorizontalAlign horizontalAlign, optional float horizontalFlowBias, optional float verticalFlowBias, optional androidx.constraintlayout.compose.FlowStyle verticalStyle, optional androidx.constraintlayout.compose.FlowStyle horizontalStyle);
     method public final androidx.constraintlayout.compose.ConstrainedLayoutReference createFlow(androidx.constraintlayout.compose.LayoutReference![]? elements, optional boolean flowVertically, optional float verticalGap, optional float horizontalGap, optional int maxElement, optional float paddingHorizontal, optional float paddingVertical, optional androidx.constraintlayout.compose.Wrap wrapMode, optional androidx.constraintlayout.compose.VerticalAlign verticalAlign, optional androidx.constraintlayout.compose.HorizontalAlign horizontalAlign, optional float horizontalFlowBias, optional float verticalFlowBias, optional androidx.constraintlayout.compose.FlowStyle verticalStyle, optional androidx.constraintlayout.compose.FlowStyle horizontalStyle);
     method public final androidx.constraintlayout.compose.ConstrainedLayoutReference createFlow(androidx.constraintlayout.compose.LayoutReference![]? elements, optional boolean flowVertically, optional float verticalGap, optional float horizontalGap, optional int maxElement, optional float paddingLeft, optional float paddingTop, optional float paddingRight, optional float paddingBottom, optional androidx.constraintlayout.compose.Wrap wrapMode, optional androidx.constraintlayout.compose.VerticalAlign verticalAlign, optional androidx.constraintlayout.compose.HorizontalAlign horizontalAlign, optional float horizontalFlowBias, optional float verticalFlowBias, optional androidx.constraintlayout.compose.FlowStyle verticalStyle, optional androidx.constraintlayout.compose.FlowStyle horizontalStyle);
-    method public final androidx.constraintlayout.compose.ConstrainedLayoutReference createGrid(androidx.constraintlayout.compose.LayoutReference![] elements, optional int orientation, optional int rows, optional int columns, optional float verticalGap, optional float horizontalGap, optional int[] rowWeights, optional int[] columnWeights, optional String skips, optional String spans, optional float padding);
-    method public final androidx.constraintlayout.compose.ConstrainedLayoutReference createGrid(androidx.constraintlayout.compose.LayoutReference![] elements, optional int orientation, optional int rows, optional int columns, optional float verticalGap, optional float horizontalGap, optional int[] rowWeights, optional int[] columnWeights, optional String skips, optional String spans, optional float paddingHorizontal, optional float paddingVertical);
-    method public final androidx.constraintlayout.compose.ConstrainedLayoutReference createGrid(androidx.constraintlayout.compose.LayoutReference![] elements, optional int orientation, optional int rows, optional int columns, optional float verticalGap, optional float horizontalGap, optional int[] rowWeights, optional int[] columnWeights, optional String skips, optional String spans, optional float paddingLeft, optional float paddingTop, optional float paddingRight, optional float paddingBottom);
+    method public final androidx.constraintlayout.compose.ConstrainedLayoutReference createGrid(androidx.constraintlayout.compose.LayoutReference![] elements, optional int orientation, optional int rows, optional int columns, optional float verticalGap, optional float horizontalGap, optional int[] rowWeights, optional int[] columnWeights, optional String skips, optional String spans, optional float padding, optional androidx.constraintlayout.compose.GridFlag![] flags);
+    method public final androidx.constraintlayout.compose.ConstrainedLayoutReference createGrid(androidx.constraintlayout.compose.LayoutReference![] elements, optional int orientation, optional int rows, optional int columns, optional float verticalGap, optional float horizontalGap, optional int[] rowWeights, optional int[] columnWeights, optional String skips, optional String spans, optional float paddingHorizontal, optional float paddingVertical, optional androidx.constraintlayout.compose.GridFlag![] flags);
+    method public final androidx.constraintlayout.compose.ConstrainedLayoutReference createGrid(androidx.constraintlayout.compose.LayoutReference![] elements, optional int orientation, optional int rows, optional int columns, optional float verticalGap, optional float horizontalGap, optional int[] rowWeights, optional int[] columnWeights, optional String skips, optional String spans, optional float paddingLeft, optional float paddingTop, optional float paddingRight, optional float paddingBottom, optional androidx.constraintlayout.compose.GridFlag![] flags);
     method public final androidx.constraintlayout.compose.ConstraintLayoutBaseScope.VerticalAnchor createGuidelineFromAbsoluteLeft(float offset);
     method public final androidx.constraintlayout.compose.ConstraintLayoutBaseScope.VerticalAnchor createGuidelineFromAbsoluteLeft(float fraction);
     method public final androidx.constraintlayout.compose.ConstraintLayoutBaseScope.VerticalAnchor createGuidelineFromAbsoluteRight(float offset);
@@ -412,6 +412,17 @@
     property public final androidx.constraintlayout.compose.FlowStyle SpreadInside;
   }
 
+  @androidx.compose.runtime.Immutable public final class GridFlag {
+    field public static final androidx.constraintlayout.compose.GridFlag.Companion Companion;
+  }
+
+  public static final class GridFlag.Companion {
+    method public androidx.constraintlayout.compose.GridFlag getSpansRespectWidgetOrder();
+    method public androidx.constraintlayout.compose.GridFlag getSubGridByColRow();
+    property public final androidx.constraintlayout.compose.GridFlag SpansRespectWidgetOrder;
+    property public final androidx.constraintlayout.compose.GridFlag SubGridByColRow;
+  }
+
   @androidx.compose.runtime.Immutable public final class HorizontalAlign {
     field public static final androidx.constraintlayout.compose.HorizontalAlign.Companion Companion;
   }
diff --git a/constraintlayout/constraintlayout-compose/api/restricted_current.txt b/constraintlayout/constraintlayout-compose/api/restricted_current.txt
index 75753a2..0b3a079 100644
--- a/constraintlayout/constraintlayout-compose/api/restricted_current.txt
+++ b/constraintlayout/constraintlayout-compose/api/restricted_current.txt
@@ -151,9 +151,9 @@
     method public final androidx.constraintlayout.compose.ConstrainedLayoutReference createFlow(androidx.constraintlayout.compose.LayoutReference![]? elements, optional boolean flowVertically, optional float verticalGap, optional float horizontalGap, optional int maxElement, optional float padding, optional androidx.constraintlayout.compose.Wrap wrapMode, optional androidx.constraintlayout.compose.VerticalAlign verticalAlign, optional androidx.constraintlayout.compose.HorizontalAlign horizontalAlign, optional float horizontalFlowBias, optional float verticalFlowBias, optional androidx.constraintlayout.compose.FlowStyle verticalStyle, optional androidx.constraintlayout.compose.FlowStyle horizontalStyle);
     method public final androidx.constraintlayout.compose.ConstrainedLayoutReference createFlow(androidx.constraintlayout.compose.LayoutReference![]? elements, optional boolean flowVertically, optional float verticalGap, optional float horizontalGap, optional int maxElement, optional float paddingHorizontal, optional float paddingVertical, optional androidx.constraintlayout.compose.Wrap wrapMode, optional androidx.constraintlayout.compose.VerticalAlign verticalAlign, optional androidx.constraintlayout.compose.HorizontalAlign horizontalAlign, optional float horizontalFlowBias, optional float verticalFlowBias, optional androidx.constraintlayout.compose.FlowStyle verticalStyle, optional androidx.constraintlayout.compose.FlowStyle horizontalStyle);
     method public final androidx.constraintlayout.compose.ConstrainedLayoutReference createFlow(androidx.constraintlayout.compose.LayoutReference![]? elements, optional boolean flowVertically, optional float verticalGap, optional float horizontalGap, optional int maxElement, optional float paddingLeft, optional float paddingTop, optional float paddingRight, optional float paddingBottom, optional androidx.constraintlayout.compose.Wrap wrapMode, optional androidx.constraintlayout.compose.VerticalAlign verticalAlign, optional androidx.constraintlayout.compose.HorizontalAlign horizontalAlign, optional float horizontalFlowBias, optional float verticalFlowBias, optional androidx.constraintlayout.compose.FlowStyle verticalStyle, optional androidx.constraintlayout.compose.FlowStyle horizontalStyle);
-    method public final androidx.constraintlayout.compose.ConstrainedLayoutReference createGrid(androidx.constraintlayout.compose.LayoutReference![] elements, optional int orientation, optional int rows, optional int columns, optional float verticalGap, optional float horizontalGap, optional int[] rowWeights, optional int[] columnWeights, optional String skips, optional String spans, optional float padding);
-    method public final androidx.constraintlayout.compose.ConstrainedLayoutReference createGrid(androidx.constraintlayout.compose.LayoutReference![] elements, optional int orientation, optional int rows, optional int columns, optional float verticalGap, optional float horizontalGap, optional int[] rowWeights, optional int[] columnWeights, optional String skips, optional String spans, optional float paddingHorizontal, optional float paddingVertical);
-    method public final androidx.constraintlayout.compose.ConstrainedLayoutReference createGrid(androidx.constraintlayout.compose.LayoutReference![] elements, optional int orientation, optional int rows, optional int columns, optional float verticalGap, optional float horizontalGap, optional int[] rowWeights, optional int[] columnWeights, optional String skips, optional String spans, optional float paddingLeft, optional float paddingTop, optional float paddingRight, optional float paddingBottom);
+    method public final androidx.constraintlayout.compose.ConstrainedLayoutReference createGrid(androidx.constraintlayout.compose.LayoutReference![] elements, optional int orientation, optional int rows, optional int columns, optional float verticalGap, optional float horizontalGap, optional int[] rowWeights, optional int[] columnWeights, optional String skips, optional String spans, optional float padding, optional androidx.constraintlayout.compose.GridFlag![] flags);
+    method public final androidx.constraintlayout.compose.ConstrainedLayoutReference createGrid(androidx.constraintlayout.compose.LayoutReference![] elements, optional int orientation, optional int rows, optional int columns, optional float verticalGap, optional float horizontalGap, optional int[] rowWeights, optional int[] columnWeights, optional String skips, optional String spans, optional float paddingHorizontal, optional float paddingVertical, optional androidx.constraintlayout.compose.GridFlag![] flags);
+    method public final androidx.constraintlayout.compose.ConstrainedLayoutReference createGrid(androidx.constraintlayout.compose.LayoutReference![] elements, optional int orientation, optional int rows, optional int columns, optional float verticalGap, optional float horizontalGap, optional int[] rowWeights, optional int[] columnWeights, optional String skips, optional String spans, optional float paddingLeft, optional float paddingTop, optional float paddingRight, optional float paddingBottom, optional androidx.constraintlayout.compose.GridFlag![] flags);
     method public final androidx.constraintlayout.compose.ConstraintLayoutBaseScope.VerticalAnchor createGuidelineFromAbsoluteLeft(float offset);
     method public final androidx.constraintlayout.compose.ConstraintLayoutBaseScope.VerticalAnchor createGuidelineFromAbsoluteLeft(float fraction);
     method public final androidx.constraintlayout.compose.ConstraintLayoutBaseScope.VerticalAnchor createGuidelineFromAbsoluteRight(float offset);
@@ -382,6 +382,17 @@
     property public final androidx.constraintlayout.compose.FlowStyle SpreadInside;
   }
 
+  @androidx.compose.runtime.Immutable public final class GridFlag {
+    field public static final androidx.constraintlayout.compose.GridFlag.Companion Companion;
+  }
+
+  public static final class GridFlag.Companion {
+    method public androidx.constraintlayout.compose.GridFlag getSpansRespectWidgetOrder();
+    method public androidx.constraintlayout.compose.GridFlag getSubGridByColRow();
+    property public final androidx.constraintlayout.compose.GridFlag SpansRespectWidgetOrder;
+    property public final androidx.constraintlayout.compose.GridFlag SubGridByColRow;
+  }
+
   @androidx.compose.runtime.Immutable public final class HorizontalAlign {
     field public static final androidx.constraintlayout.compose.HorizontalAlign.Companion Companion;
   }
diff --git a/constraintlayout/constraintlayout-compose/src/androidAndroidTest/kotlin/androidx/constraintlayout/compose/GridDslTest.kt b/constraintlayout/constraintlayout-compose/src/androidAndroidTest/kotlin/androidx/constraintlayout/compose/GridDslTest.kt
index 5146e0c..6450111 100644
--- a/constraintlayout/constraintlayout-compose/src/androidAndroidTest/kotlin/androidx/constraintlayout/compose/GridDslTest.kt
+++ b/constraintlayout/constraintlayout-compose/src/androidAndroidTest/kotlin/androidx/constraintlayout/compose/GridDslTest.kt
@@ -76,6 +76,7 @@
                 gridSkips = "",
                 gridRowWeights = intArrayOf(),
                 gridColumnWeights = intArrayOf(),
+                gridFlags = arrayOf(),
             )
         }
         var leftX = 0.dp
@@ -114,7 +115,8 @@
                 gridSpans = "",
                 gridSkips = "",
                 gridRowWeights = intArrayOf(),
-                gridColumnWeights = intArrayOf()
+                gridColumnWeights = intArrayOf(),
+                gridFlags = arrayOf(),
             )
         }
         var leftX = 0.dp
@@ -153,7 +155,8 @@
                 gridSpans = "",
                 gridSkips = "",
                 gridRowWeights = intArrayOf(),
-                gridColumnWeights = intArrayOf()
+                gridColumnWeights = intArrayOf(),
+                gridFlags = arrayOf(),
             )
         }
         var expectedX = 0.dp
@@ -192,7 +195,8 @@
                 gridSpans = "",
                 gridSkips = "",
                 gridRowWeights = intArrayOf(),
-                gridColumnWeights = intArrayOf()
+                gridColumnWeights = intArrayOf(),
+                gridFlags = arrayOf(),
             )
         }
         var expectedX = 0.dp
@@ -231,7 +235,8 @@
                 gridSpans = "",
                 gridSkips = "0:1x1",
                 gridRowWeights = intArrayOf(),
-                gridColumnWeights = intArrayOf()
+                gridColumnWeights = intArrayOf(),
+                gridFlags = arrayOf(),
             )
         }
         var leftX = 0.dp
@@ -252,6 +257,44 @@
     }
 
     @Test
+    fun testReversedDirectionSkips() {
+        val rootSize = 200.dp
+        val boxesCount = 2
+        val rows = 2
+        val columns = 2
+        rule.setContent {
+            gridComposableTest(
+                modifier = Modifier.size(rootSize),
+                boxesCount = boxesCount,
+                gridOrientation = 0,
+                numRows = rows,
+                numColumns = columns,
+                hGap = 0,
+                vGap = 0,
+                gridSpans = "",
+                gridSkips = "0:2x1",
+                gridRowWeights = intArrayOf(),
+                gridColumnWeights = intArrayOf(),
+                gridFlags = arrayOf(GridFlag.SpansRespectWidgetOrder, GridFlag.SubGridByColRow)
+            )
+        }
+        var leftX = 0.dp
+        var topY = 0.dp
+        var rightX: Dp
+        var bottomY: Dp
+
+        // 10.dp is the size of a singular box
+        val gapSize = (rootSize - (10.dp * 2f)) / (columns * 2f)
+        rule.waitForIdle()
+        leftX += gapSize
+        topY += gapSize
+        rightX = leftX + 10.dp + gapSize + gapSize
+        bottomY = topY + 10.dp + gapSize + gapSize
+        rule.onNodeWithTag("box0").assertPositionInRootIsEqualTo(leftX, bottomY)
+        rule.onNodeWithTag("box1").assertPositionInRootIsEqualTo(rightX, bottomY)
+    }
+
+    @Test
     fun testSpans() {
         val rootSize = 200.dp
         val boxesCount = 3
@@ -269,7 +312,88 @@
                 gridSpans = "0:1x2",
                 gridSkips = "",
                 gridRowWeights = intArrayOf(),
-                gridColumnWeights = intArrayOf()
+                gridColumnWeights = intArrayOf(),
+                gridFlags = arrayOf(),
+            )
+        }
+        var leftX = 0.dp
+        var topY = 0.dp
+        var rightX: Dp
+        var bottomY: Dp
+
+        // 10.dp is the size of a singular box
+        var spanLeft = (rootSize - 10.dp) / 2f
+        val gapSize = (rootSize - (10.dp * 2f)) / (columns * 2f)
+        rule.waitForIdle()
+        leftX += gapSize
+        topY += gapSize
+        rule.onNodeWithTag("box0").assertPositionInRootIsEqualTo(spanLeft, topY)
+        rightX = leftX + 10.dp + gapSize + gapSize
+        bottomY = topY + 10.dp + gapSize + gapSize
+        rule.onNodeWithTag("box1").assertPositionInRootIsEqualTo(leftX, bottomY)
+        rule.onNodeWithTag("box2").assertPositionInRootIsEqualTo(rightX, bottomY)
+    }
+
+    @Test
+    fun testOrderFirstSpans() {
+        val rootSize = 200.dp
+        val boxesCount = 3
+        val rows = 2
+        val columns = 2
+        rule.setContent {
+            gridComposableTest(
+                modifier = Modifier.size(rootSize),
+                boxesCount = boxesCount,
+                gridOrientation = 0,
+                numRows = rows,
+                numColumns = columns,
+                hGap = 0,
+                vGap = 0,
+                gridSpans = "1:2x1",
+                gridSkips = "",
+                gridRowWeights = intArrayOf(),
+                gridColumnWeights = intArrayOf(),
+                gridFlags = arrayOf(GridFlag.SpansRespectWidgetOrder),
+            )
+        }
+        var leftX = 0.dp
+        var topY = 0.dp
+        var rightX: Dp
+        var bottomY: Dp
+
+        // 10.dp is the size of a singular box
+        var spanTop = (rootSize - 10.dp) / 2f
+        val gapSize = (rootSize - (10.dp * 2f)) / (columns * 2f)
+        rule.waitForIdle()
+        leftX += gapSize
+        topY += gapSize
+        rule.onNodeWithTag("box0").assertPositionInRootIsEqualTo(leftX, topY)
+        rightX = leftX + 10.dp + gapSize + gapSize
+        rule.onNodeWithTag("box1").assertPositionInRootIsEqualTo(rightX, spanTop)
+        bottomY = topY + 10.dp + gapSize + gapSize
+        rule.onNodeWithTag("box2").assertPositionInRootIsEqualTo(leftX, bottomY)
+    }
+
+    @Test
+    fun testReversedDirectionSpans() {
+        val rootSize = 200.dp
+        val boxesCount = 3
+        val rows = 2
+        val columns = 2
+        rule.setContent {
+            gridComposableTest(
+                modifier = Modifier.size(rootSize),
+                boxesCount = boxesCount,
+                gridOrientation = 0,
+                numRows = rows,
+                numColumns = columns,
+                hGap = 0,
+                vGap = 0,
+                gridSpans = "0:2x1",
+                gridSkips = "",
+                gridRowWeights = intArrayOf(),
+                gridColumnWeights = intArrayOf(),
+                gridFlags = arrayOf(GridFlag.SubGridByColRow),
             )
         }
         var leftX = 0.dp
@@ -309,7 +433,8 @@
                 gridSpans = "",
                 gridSkips = "",
                 gridRowWeights = weights,
-                gridColumnWeights = intArrayOf()
+                gridColumnWeights = intArrayOf(),
+                gridFlags = arrayOf(),
             )
         }
         var expectedLeft = (rootSize - 10.dp) / 2f
@@ -346,7 +471,8 @@
                 gridSpans = "",
                 gridSkips = "",
                 gridRowWeights = intArrayOf(),
-                gridColumnWeights = weights
+                gridColumnWeights = weights,
+                gridFlags = arrayOf(),
             )
         }
         var expectedLeft = 0.dp
@@ -405,6 +531,7 @@
         gridOrientation: Int,
         vGap: Int,
         hGap: Int,
+        gridFlags: Array<GridFlag>,
     ) {
         ConstraintLayout(
             ConstraintSet {
@@ -425,6 +552,7 @@
                     horizontalGap = hGap.dp,
                     rowWeights = gridRowWeights,
                     columnWeights = gridColumnWeights,
+                    flags = gridFlags,
                 )
                 constrain(g1) {
                     width = Dimension.matchParent
diff --git a/constraintlayout/constraintlayout-compose/src/androidAndroidTest/kotlin/androidx/constraintlayout/compose/GridTest.kt b/constraintlayout/constraintlayout-compose/src/androidAndroidTest/kotlin/androidx/constraintlayout/compose/GridTest.kt
index 1cf3fc5..bd17fc6 100644
--- a/constraintlayout/constraintlayout-compose/src/androidAndroidTest/kotlin/androidx/constraintlayout/compose/GridTest.kt
+++ b/constraintlayout/constraintlayout-compose/src/androidAndroidTest/kotlin/androidx/constraintlayout/compose/GridTest.kt
@@ -77,7 +77,8 @@
                 spans = "''",
                 skips = "''",
                 rowWeights = "''",
-                columnWeights = "''"
+                columnWeights = "''",
+                flags = "''"
             )
         }
         var leftX = 0.dp
@@ -118,7 +119,8 @@
                 spans = "''",
                 skips = "''",
                 rowWeights = "''",
-                columnWeights = "''"
+                columnWeights = "''",
+                flags = "''"
             )
         }
         var leftX = 0.dp
@@ -159,7 +161,8 @@
                 spans = "''",
                 skips = "''",
                 rowWeights = "''",
-                columnWeights = "''"
+                columnWeights = "''",
+                flags = "''"
             )
         }
         var expectedX = 0.dp
@@ -200,7 +203,8 @@
                 spans = "''",
                 skips = "''",
                 rowWeights = "''",
-                columnWeights = "''"
+                columnWeights = "''",
+                flags = "''"
             )
         }
         var expectedX = 0.dp
@@ -241,7 +245,8 @@
                 spans = "''",
                 skips = "'0:1x1'",
                 rowWeights = "''",
-                columnWeights = "''"
+                columnWeights = "''",
+                flags = "''"
             )
         }
         var leftX = 0.dp
@@ -262,6 +267,46 @@
     }
 
     @Test
+    fun testReversedDirectionSkips() {
+        val rootSize = 200.dp
+        val boxesCount = 2
+        val rows = 2
+        val columns = 2
+        rule.setContent {
+            gridComposableTest(
+                modifier = Modifier.size(rootSize),
+                width = "'parent'",
+                height = "'parent'",
+                boxesCount = boxesCount,
+                orientation = 0,
+                rows = rows,
+                columns = columns,
+                hGap = 0,
+                vGap = 0,
+                spans = "''",
+                skips = "'0:2x1'",
+                rowWeights = "''",
+                columnWeights = "''",
+                flags = "'SubGridByColRow'"
+            )
+        }
+        var leftX = 0.dp
+        var topY = 0.dp
+        var rightX: Dp
+        var bottomY: Dp
+
+        // 10.dp is the size of a singular box
+        val gapSize = (rootSize - (10.dp * 2f)) / (columns * 2f)
+        rule.waitForIdle()
+        leftX += gapSize
+        topY += gapSize
+        rightX = leftX + 10.dp + gapSize + gapSize
+        bottomY = topY + 10.dp + gapSize + gapSize
+        rule.onNodeWithTag("box0").assertPositionInRootIsEqualTo(leftX, bottomY)
+        rule.onNodeWithTag("box1").assertPositionInRootIsEqualTo(rightX, bottomY)
+    }
+
+    @Test
     fun testSpans() {
         val rootSize = 200.dp
         val boxesCount = 3
@@ -281,7 +326,92 @@
                 spans = "'0:1x2'",
                 skips = "''",
                 rowWeights = "''",
-                columnWeights = "''"
+                columnWeights = "''",
+                flags = "''"
+            )
+        }
+        var leftX = 0.dp
+        var topY = 0.dp
+        var rightX: Dp
+        var bottomY: Dp
+
+        // 10.dp is the size of a singular box
+        var spanLeft = (rootSize - 10.dp) / 2f
+        val gapSize = (rootSize - (10.dp * 2f)) / (columns * 2f)
+        rule.waitForIdle()
+        leftX += gapSize
+        topY += gapSize
+        rule.onNodeWithTag("box0").assertPositionInRootIsEqualTo(spanLeft, topY)
+        rightX = leftX + 10.dp + gapSize + gapSize
+        bottomY = topY + 10.dp + gapSize + gapSize
+        rule.onNodeWithTag("box1").assertPositionInRootIsEqualTo(leftX, bottomY)
+        rule.onNodeWithTag("box2").assertPositionInRootIsEqualTo(rightX, bottomY)
+    }
+
+    @Test
+    fun testOrderFirstSpans() {
+        val rootSize = 200.dp
+        val boxesCount = 3
+        val rows = 2
+        val columns = 2
+        rule.setContent {
+            gridComposableTest(
+                modifier = Modifier.size(rootSize),
+                width = "'parent'",
+                height = "'parent'",
+                boxesCount = boxesCount,
+                orientation = 0,
+                rows = rows,
+                columns = columns,
+                hGap = 0,
+                vGap = 0,
+                spans = "'1:2x1'",
+                skips = "''",
+                rowWeights = "''",
+                columnWeights = "''",
+                flags = "'SpansRespectWidgetOrder'"
+            )
+        }
+        var leftX = 0.dp
+        var topY = 0.dp
+        var rightX: Dp
+        var bottomY: Dp
+
+        // 10.dp is the size of a singular box
+        var spanTop = (rootSize - 10.dp) / 2f
+        val gapSize = (rootSize - (10.dp * 2f)) / (columns * 2f)
+        rule.waitForIdle()
+        leftX += gapSize
+        topY += gapSize
+        rule.onNodeWithTag("box0").assertPositionInRootIsEqualTo(leftX, topY)
+        rightX = leftX + 10.dp + gapSize + gapSize
+        rule.onNodeWithTag("box1").assertPositionInRootIsEqualTo(rightX, spanTop)
+        bottomY = topY + 10.dp + gapSize + gapSize
+        rule.onNodeWithTag("box2").assertPositionInRootIsEqualTo(leftX, bottomY)
+    }
+
+    @Test
+    fun testReversedDirectionSpans() {
+        val rootSize = 200.dp
+        val boxesCount = 3
+        val rows = 2
+        val columns = 2
+        rule.setContent {
+            gridComposableTest(
+                modifier = Modifier.size(rootSize),
+                width = "'parent'",
+                height = "'parent'",
+                boxesCount = boxesCount,
+                orientation = 0,
+                rows = rows,
+                columns = columns,
+                hGap = 0,
+                vGap = 0,
+                spans = "'0:2x1'",
+                skips = "''",
+                rowWeights = "''",
+                columnWeights = "''",
+                flags = "'SubGridByColRow'"
             )
         }
         var leftX = 0.dp
@@ -322,7 +452,8 @@
                 spans = "''",
                 skips = "''",
                 rowWeights = "'1,3'",
-                columnWeights = "''"
+                columnWeights = "''",
+                flags = "''"
             )
         }
         var expectedLeft = (rootSize - 10.dp) / 2f
@@ -360,7 +491,8 @@
                 spans = "''",
                 skips = "''",
                 rowWeights = "''",
-                columnWeights = "'1,3'"
+                columnWeights = "'1,3'",
+                flags = "''"
             )
         }
         var expectedLeft = 0.dp
@@ -423,6 +555,7 @@
         orientation: Int,
         vGap: Int,
         hGap: Int,
+        flags: String,
     ) {
         val ids = (0 until boxesCount).map { "box$it" }.toTypedArray()
         val gridContains = ids.joinToString(separator = ", ") { "'$it'" }
@@ -445,6 +578,7 @@
                 columnWeights: $columnWeights
                 orientation: $orientation,
                 contains: [$gridContains],
+                flags: $flags,
               }
         }
         """.trimIndent()
diff --git a/constraintlayout/constraintlayout-compose/src/androidMain/kotlin/androidx/constraintlayout/compose/ConstraintLayoutBaseScope.kt b/constraintlayout/constraintlayout-compose/src/androidMain/kotlin/androidx/constraintlayout/compose/ConstraintLayoutBaseScope.kt
index c3912d6..351bad1 100644
--- a/constraintlayout/constraintlayout-compose/src/androidMain/kotlin/androidx/constraintlayout/compose/ConstraintLayoutBaseScope.kt
+++ b/constraintlayout/constraintlayout-compose/src/androidMain/kotlin/androidx/constraintlayout/compose/ConstraintLayoutBaseScope.kt
@@ -972,6 +972,7 @@
      *      val j = createRefFor("0")
      *      val k = createRefFor("box")
      *      val weights = intArrayOf(3, 3, 2, 2)
+     *      val flags = arrayOf("SubGridByColRow", "SpansRespectWidgetOrder")
      *      val g1 = createGrid(
      *          k, a, b, c, d, e, f, g, h, i, j, k,
      *          rows = 5,
@@ -983,6 +984,7 @@
      *          rowWeights = weights,
      *          paddingHorizontal = 10.dp,
      *          paddingVertical = 10.dp,
+     *          flags = flags,
      *      )
      *      constrain(g1) {
      *          width = Dimension.matchParent
@@ -1026,6 +1028,17 @@
      *        row - the number of rows to span
      *        col- the number of columns to span
      * @param padding sets padding around the content
+     * @param flags set different flags to be enabled (not case-sensitive), including
+     *          SubGridByColRow: reverse the width and height specification for spans/skips.
+     *              Original - Position:HeightxWidth; with the flag - Position:WidthxHeight
+     *          SpansRespectWidgetOrder: spans would respect the order of the widgets.
+     *              Original - the widgets in the front of the widget list would be
+     *              assigned to the spanned area; with the flag - all the widges will be arranged
+     *              based on the given order. For example, for a layout with 1 row and 3 columns.
+     *              If we have two widgets: w1, w2 with a span as 1:1x2, the original layout would
+     *              be [w2 w1 w1]. Since w1 is in the front of the list, it would be assigned to
+     *              the spanned area. With the flag, the layout would be [w1 w2 w2] that respects
+     *              the order of the widget list.
      */
     fun createGrid(
         vararg elements: LayoutReference,
@@ -1039,6 +1052,7 @@
         skips: String = "",
         spans: String = "",
         padding: Dp = 0.dp,
+        flags: Array<GridFlag> = arrayOf(),
     ): ConstrainedLayoutReference {
         return createGrid(
             elements = elements,
@@ -1055,6 +1069,7 @@
             paddingTop = padding,
             paddingRight = padding,
             paddingBottom = padding,
+            flags = flags,
         )
     }
 
@@ -1075,6 +1090,7 @@
      *      val j = createRefFor("0")
      *      val k = createRefFor("box")
      *      val weights = intArrayOf(3, 3, 2, 2)
+     *      val flags = arrayOf("SubGridByColRow", "SpansRespectWidgetOrder")
      *      val g1 = createGrid(
      *          k, a, b, c, d, e, f, g, h, i, j, k,
      *          rows = 5,
@@ -1086,6 +1102,7 @@
      *          rowWeights = weights,
      *          paddingHorizontal = 10.dp,
      *          paddingVertical = 10.dp,
+     *          flags = flags,
      *      )
      *      constrain(g1) {
      *          width = Dimension.matchParent
@@ -1130,6 +1147,17 @@
      *        col- the number of columns to span
      * @param paddingHorizontal sets paddingLeft and paddingRight of the content
      * @param paddingVertical sets paddingTop and paddingBottom of the content
+     * @param flags set different flags to be enabled (not case-sensitive), including
+     *          SubGridByColRow: reverse the width and height specification for spans/skips.
+     *              Original - Position:HeightxWidth; with the flag - Position:WidthxHeight
+     *          SpansRespectWidgetOrder: spans would respect the order of the widgets.
+     *              Original - the widgets in the front of the widget list would be
+     *              assigned to the spanned area; with the flag - all the widges will be arranged
+     *              based on the given order. For example, for a layout with 1 row and 3 columns.
+     *              If we have two widgets: w1, w2 with a span as 1:1x2, the original layout would
+     *              be [w2 w1 w1]. Since w1 is in the front of the list, it would be assigned to
+     *              the spanned area. With the flag, the layout would be [w1 w2 w2] that respects
+     *              the order of the widget list.
      */
     fun createGrid(
         vararg elements: LayoutReference,
@@ -1144,6 +1172,7 @@
         spans: String = "",
         paddingHorizontal: Dp = 0.dp,
         paddingVertical: Dp = 0.dp,
+        flags: Array<GridFlag> = arrayOf(),
     ): ConstrainedLayoutReference {
         return createGrid(
             elements = elements,
@@ -1160,6 +1189,7 @@
             paddingTop = paddingVertical,
             paddingRight = paddingHorizontal,
             paddingBottom = paddingVertical,
+            flags = flags
         )
     }
 
@@ -1180,6 +1210,7 @@
      *      val j = createRefFor("0")
      *      val k = createRefFor("box")
      *      val weights = intArrayOf(3, 3, 2, 2)
+     *      val flags = arrayOf("SubGridByColRow", "SpansRespectWidgetOrder")
      *      val g1 = createGrid(
      *          k, a, b, c, d, e, f, g, h, i, j, k,
      *          rows = 5,
@@ -1193,6 +1224,7 @@
      *          paddingTop = 10.dp,
      *          paddingRight = 10.dp,
      *          paddingBottom = 10.dp,
+     *          flags = flags,
      *      )
      *      constrain(g1) {
      *          width = Dimension.matchParent
@@ -1239,6 +1271,17 @@
      * @param paddingTop sets paddingTop of the content
      * @param paddingRight sets paddingRight of the content
      * @param paddingBottom sets paddingBottom of the content
+     * @param flags set different flags to be enabled (not case-sensitive), including
+     *          SubGridByColRow: reverse the width and height specification for spans/skips.
+     *              Original - Position:HeightxWidth; with the flag - Position:WidthxHeight
+     *          SpansRespectWidgetOrder: spans would respect the order of the widgets.
+     *              Original - the widgets in the front of the widget list would be
+     *              assigned to the spanned area; with the flag - all the widges will be arranged
+     *              based on the given order. For example, for a layout with 1 row and 3 columns.
+     *              If we have two widgets: w1, w2 with a span as 1:1x2, the original layout would
+     *              be [w2 w1 w1]. Since w1 is in the front of the list, it would be assigned to
+     *              the spanned area. With the flag, the layout would be [w1 w2 w2] that respects
+     *              the order of the widget list.
      */
     fun createGrid(
         vararg elements: LayoutReference,
@@ -1255,9 +1298,11 @@
         paddingTop: Dp = 0.dp,
         paddingRight: Dp = 0.dp,
         paddingBottom: Dp = 0.dp,
+        flags: Array<GridFlag> = arrayOf(),
     ): ConstrainedLayoutReference {
         val ref = ConstrainedLayoutReference(createHelperId())
         val elementArray = CLArray(charArrayOf())
+        val flagArray = CLArray(charArrayOf())
         elements.forEach {
             elementArray.add(CLString.from(it.id.toString()))
         }
@@ -1267,6 +1312,9 @@
             add(CLNumber(paddingRight.value))
             add(CLNumber(paddingBottom.value))
         }
+        flags.forEach {
+            flagArray.add(CLString.from(it.name))
+        }
         var strRowWeights = ""
         var strColumnWeights = ""
         if (rowWeights.size > 1) {
@@ -1289,6 +1337,7 @@
             putString("columnWeights", strColumnWeights)
             putString("skips", skips)
             putString("spans", spans)
+            put("flags", flagArray)
         }
 
         return ref
@@ -1812,6 +1861,29 @@
 }
 
 /**
+ * GridFlag defines the available flags of Grid
+ * SubGridByColRow: reverse the width and height specification for spans/skips.
+ *   Original - Position:HeightxWidth; with the flag - Position:WidthxHeight
+ * SpansRespectWidgetOrder: spans would respect the order of the widgets.
+ *   Original - the widgets in the front of the widget list would be
+ *              assigned to the spanned area; with the flag - all the widges will be arranged
+ *              based on the given order. For example, for a layout with 1 row and 3 columns.
+ *              If we have two widgets: w1, w2 with a span as 1:1x2, the original layout would
+ *              be [w2 w1 w1]. Since w1 is in the front of the list, it would be assigned to
+ *              the spanned area. With the flag, the layout would be [w1 w2 w2] that respects
+ *              the order of the widget list.
+ */
+@Immutable
+class GridFlag internal constructor(
+    internal val name: String
+) {
+    companion object {
+        val SpansRespectWidgetOrder = GridFlag("spansrespectwidgetorder")
+        val SubGridByColRow = GridFlag("subgridbycolrow")
+    }
+}
+
+/**
  * Wrap defines the type of chain
  */
 @Immutable
diff --git a/constraintlayout/constraintlayout-core/api/current.txt b/constraintlayout/constraintlayout-core/api/current.txt
index 8eee998..9fcc11f 100644
--- a/constraintlayout/constraintlayout-core/api/current.txt
+++ b/constraintlayout/constraintlayout-core/api/current.txt
@@ -2597,6 +2597,7 @@
     ctor public GridReference(androidx.constraintlayout.core.state.State, androidx.constraintlayout.core.state.State.Helper);
     method public String? getColumnWeights();
     method public int getColumnsSet();
+    method public int[] getFlags();
     method public float getHorizontalGaps();
     method public int getOrientation();
     method public int getPaddingBottom();
@@ -2610,6 +2611,8 @@
     method public float getVerticalGaps();
     method public void setColumnWeights(String);
     method public void setColumnsSet(int);
+    method public void setFlags(int[]);
+    method public void setFlags(String);
     method public void setHorizontalGaps(float);
     method public void setOrientation(int);
     method public void setPaddingBottom(int);
@@ -2655,6 +2658,7 @@
     ctor public GridCore(int, int);
     method public String? getColumnWeights();
     method public androidx.constraintlayout.core.widgets.ConstraintWidgetContainer? getContainer();
+    method public int[] getFlags();
     method public float getHorizontalGaps();
     method public int getOrientation();
     method public String? getRowWeights();
@@ -2662,6 +2666,7 @@
     method public void setColumnWeights(String);
     method public void setColumns(int);
     method public void setContainer(androidx.constraintlayout.core.widgets.ConstraintWidgetContainer);
+    method public void setFlags(int[]);
     method public void setHorizontalGaps(float);
     method public void setOrientation(int);
     method public void setRowWeights(String);
@@ -2670,6 +2675,8 @@
     method public void setSpans(CharSequence);
     method public void setVerticalGaps(float);
     field public static final int HORIZONTAL = 0; // 0x0
+    field public static final int SPANS_RESPECT_WIDGET_ORDER = 1; // 0x1
+    field public static final int SUB_GRID_BY_COL_ROW = 0; // 0x0
     field public static final int VERTICAL = 1; // 0x1
   }
 
diff --git a/constraintlayout/constraintlayout-core/api/public_plus_experimental_current.txt b/constraintlayout/constraintlayout-core/api/public_plus_experimental_current.txt
index 8eee998..9fcc11f 100644
--- a/constraintlayout/constraintlayout-core/api/public_plus_experimental_current.txt
+++ b/constraintlayout/constraintlayout-core/api/public_plus_experimental_current.txt
@@ -2597,6 +2597,7 @@
     ctor public GridReference(androidx.constraintlayout.core.state.State, androidx.constraintlayout.core.state.State.Helper);
     method public String? getColumnWeights();
     method public int getColumnsSet();
+    method public int[] getFlags();
     method public float getHorizontalGaps();
     method public int getOrientation();
     method public int getPaddingBottom();
@@ -2610,6 +2611,8 @@
     method public float getVerticalGaps();
     method public void setColumnWeights(String);
     method public void setColumnsSet(int);
+    method public void setFlags(int[]);
+    method public void setFlags(String);
     method public void setHorizontalGaps(float);
     method public void setOrientation(int);
     method public void setPaddingBottom(int);
@@ -2655,6 +2658,7 @@
     ctor public GridCore(int, int);
     method public String? getColumnWeights();
     method public androidx.constraintlayout.core.widgets.ConstraintWidgetContainer? getContainer();
+    method public int[] getFlags();
     method public float getHorizontalGaps();
     method public int getOrientation();
     method public String? getRowWeights();
@@ -2662,6 +2666,7 @@
     method public void setColumnWeights(String);
     method public void setColumns(int);
     method public void setContainer(androidx.constraintlayout.core.widgets.ConstraintWidgetContainer);
+    method public void setFlags(int[]);
     method public void setHorizontalGaps(float);
     method public void setOrientation(int);
     method public void setRowWeights(String);
@@ -2670,6 +2675,8 @@
     method public void setSpans(CharSequence);
     method public void setVerticalGaps(float);
     field public static final int HORIZONTAL = 0; // 0x0
+    field public static final int SPANS_RESPECT_WIDGET_ORDER = 1; // 0x1
+    field public static final int SUB_GRID_BY_COL_ROW = 0; // 0x0
     field public static final int VERTICAL = 1; // 0x1
   }
 
diff --git a/constraintlayout/constraintlayout-core/api/restricted_current.txt b/constraintlayout/constraintlayout-core/api/restricted_current.txt
index a7d6598..c60c866 100644
--- a/constraintlayout/constraintlayout-core/api/restricted_current.txt
+++ b/constraintlayout/constraintlayout-core/api/restricted_current.txt
@@ -2600,6 +2600,7 @@
     ctor public GridReference(androidx.constraintlayout.core.state.State, androidx.constraintlayout.core.state.State.Helper);
     method public String? getColumnWeights();
     method public int getColumnsSet();
+    method public int[] getFlags();
     method public float getHorizontalGaps();
     method public int getOrientation();
     method public int getPaddingBottom();
@@ -2613,6 +2614,8 @@
     method public float getVerticalGaps();
     method public void setColumnWeights(String);
     method public void setColumnsSet(int);
+    method public void setFlags(int[]);
+    method public void setFlags(String);
     method public void setHorizontalGaps(float);
     method public void setOrientation(int);
     method public void setPaddingBottom(int);
@@ -2658,6 +2661,7 @@
     ctor public GridCore(int, int);
     method public String? getColumnWeights();
     method public androidx.constraintlayout.core.widgets.ConstraintWidgetContainer? getContainer();
+    method public int[] getFlags();
     method public float getHorizontalGaps();
     method public int getOrientation();
     method public String? getRowWeights();
@@ -2665,6 +2669,7 @@
     method public void setColumnWeights(String);
     method public void setColumns(int);
     method public void setContainer(androidx.constraintlayout.core.widgets.ConstraintWidgetContainer);
+    method public void setFlags(int[]);
     method public void setHorizontalGaps(float);
     method public void setOrientation(int);
     method public void setRowWeights(String);
@@ -2673,6 +2678,8 @@
     method public void setSpans(CharSequence);
     method public void setVerticalGaps(float);
     field public static final int HORIZONTAL = 0; // 0x0
+    field public static final int SPANS_RESPECT_WIDGET_ORDER = 1; // 0x1
+    field public static final int SUB_GRID_BY_COL_ROW = 0; // 0x0
     field public static final int VERTICAL = 1; // 0x1
   }
 
diff --git a/constraintlayout/constraintlayout-core/src/main/java/androidx/constraintlayout/core/state/ConstraintSetParser.java b/constraintlayout/constraintlayout-core/src/main/java/androidx/constraintlayout/core/state/ConstraintSetParser.java
index 879247a..85be99b 100644
--- a/constraintlayout/constraintlayout-core/src/main/java/androidx/constraintlayout/core/state/ConstraintSetParser.java
+++ b/constraintlayout/constraintlayout-core/src/main/java/androidx/constraintlayout/core/state/ConstraintSetParser.java
@@ -1032,6 +1032,25 @@
                     grid.setPaddingRight(paddingRight);
                     grid.setPaddingBottom(paddingBottom);
                     break;
+                case "flags":
+                    String flags = element.get(param).content();
+                    if (flags != null && flags.length() > 0) {
+                        grid.setFlags(flags);
+                    } else {
+                        CLArray flagArray = element.getArrayOrNull(param);
+                        flags = "";
+                        if (flagArray != null) {
+                            for (int i = 0; i < flagArray.size(); i++) {
+                                String flag = flagArray.get(i).content();
+                                flags += flag;
+                                if (i != flagArray.size() - 1) {
+                                    flags += "|";
+                                }
+                            }
+                            grid.setFlags(flags);
+                        }
+                    }
+                    break;
                 default:
                     ConstraintReference reference = state.constraints(name);
                     applyAttribute(state, layoutVariables, reference, element, param);
@@ -2069,4 +2088,4 @@
         }
         return null;
     }
-}
\ No newline at end of file
+}
diff --git a/constraintlayout/constraintlayout-core/src/main/java/androidx/constraintlayout/core/state/helpers/GridReference.java b/constraintlayout/constraintlayout-core/src/main/java/androidx/constraintlayout/core/state/helpers/GridReference.java
index 9571ddf..792634e 100644
--- a/constraintlayout/constraintlayout-core/src/main/java/androidx/constraintlayout/core/state/helpers/GridReference.java
+++ b/constraintlayout/constraintlayout-core/src/main/java/androidx/constraintlayout/core/state/helpers/GridReference.java
@@ -23,11 +23,16 @@
 import androidx.constraintlayout.core.utils.GridCore;
 import androidx.constraintlayout.core.widgets.HelperWidget;
 
+import java.util.ArrayList;
+
 /**
  * A HelperReference of a Grid Helper that helps enable Grid in Compose
  */
 public class GridReference extends HelperReference {
 
+    private static final String SPANS_RESPECT_WIDGET_ORDER = "spansrespectwidgetorder";
+    private static final String SUB_GRID_BY_COL_ROW = "subgridbycolrow";
+
     public GridReference(@NonNull State state, @NonNull State.Helper type) {
         super(state, type);
         if (type == State.Helper.ROW) {
@@ -108,6 +113,11 @@
     private String mSkips;
 
     /**
+     * All the flags of a Grid
+     */
+    private int[] mFlags;
+
+    /**
      * get padding left
      * @return padding left
      */
@@ -172,6 +182,53 @@
     }
 
     /**
+     * Get all the flags of a Grid
+     * @return a String array containing all the flags
+     */
+    @NonNull
+    public int[] getFlags() {
+        return mFlags;
+    }
+
+    /**
+     * Set flags of a Grid
+     * @param flags a String array containing all the flags
+     */
+    public void setFlags(@NonNull int[] flags) {
+        mFlags = flags;
+    }
+
+    /**
+     * Set flags of a Grid
+     * @param flags a String containing all the flags
+     */
+    public void setFlags(@NonNull String flags) {
+        if (flags.length() == 0) {
+            return;
+        }
+
+        String[] strArr = flags.split("\\|");
+        ArrayList<Integer> flagList = new ArrayList<>();
+        for (String flag: strArr) {
+            switch (flag.toLowerCase()) {
+                case SUB_GRID_BY_COL_ROW:
+                    flagList.add(0);
+                    break;
+                case SPANS_RESPECT_WIDGET_ORDER:
+                    flagList.add(1);
+                    break;
+            }
+        }
+        int[] flagArr = new int[flagList.size()];
+        int i = 0;
+        for (int flag: flagList) {
+            flagArr[i++] = flag;
+        }
+
+        mFlags = flagArr;
+    }
+
+    /**
      * Get the number of rows
      * @return the number of rows
      */
@@ -393,7 +450,11 @@
             mGrid.setSkips(mSkips);
         }
 
+        if (mFlags != null && mFlags.length > 0) {
+            mGrid.setFlags(mFlags);
+        }
+
         // General attributes of a widget
         applyBase();
     }
-}
\ No newline at end of file
+}
diff --git a/constraintlayout/constraintlayout-core/src/main/java/androidx/constraintlayout/core/utils/GridCore.java b/constraintlayout/constraintlayout-core/src/main/java/androidx/constraintlayout/core/utils/GridCore.java
index 7f1153f..2975c5c 100644
--- a/constraintlayout/constraintlayout-core/src/main/java/androidx/constraintlayout/core/utils/GridCore.java
+++ b/constraintlayout/constraintlayout-core/src/main/java/androidx/constraintlayout/core/utils/GridCore.java
@@ -36,6 +36,8 @@
 
     public static final int HORIZONTAL = 0;
     public static final int VERTICAL = 1;
+    public static final int SUB_GRID_BY_COL_ROW = 0;
+    public static final int SPANS_RESPECT_WIDGET_ORDER = 1;
     private static final int DEFAULT_SIZE = 3; // default rows and columns.
     private static final int MAX_ROWS = 50; // maximum number of rows can be specified.
     private static final int MAX_COLUMNS = 50; // maximum number of columns can be specified.
@@ -134,6 +136,32 @@
      */
     private int[][] mConstraintMatrix;
 
+    /**
+     * A String array stores the flags
+     */
+    private int[] mFlags;
+
+    /**
+     * A int matrix to store the span related information
+     */
+    private int[][] mSpanMatrix;
+
+    /**
+     * Index specify the next span to be handled.
+     */
+    private int mSpanIndex = 0;
+
+    /**
+     * Flag to respect the order of the Widgets when arranging for span
+     */
+    private boolean mSpansRespectWidgetOrder = false;
+
+    /**
+     * Flag to reverse the order of width/height specified in span
+     * e.g., 1:3x2 -> 1:2x3
+     */
+    private boolean mSubGridByColRow = false;
+
     public GridCore() {
         updateActualRowsAndColumns();
         initMatrices();
@@ -362,11 +390,32 @@
     }
 
     /**
+     * Get all the flags of a Grid
+     * @return a int array containing all the flags
+     */
+    @NonNull
+    public int[] getFlags() {
+        return mFlags;
+    }
+
+    /**
+     * Set flags of a Grid
+     * @param flags a int array containing all the flags
+     */
+    public void setFlags(@NonNull int[] flags) {
+        mFlags = flags;
+    }
+
+    /**
      * Handle the span use cases
      *
      * @param spansMatrix a int matrix that contains span information
      */
     private void handleSpans(int[][] spansMatrix) {
+        if (mSpansRespectWidgetOrder) {
+            return;
+        }
+
         for (int i = 0; i < spansMatrix.length; i++) {
             int row = getRowByIndex(spansMatrix[i][0]);
             int col = getColByIndex(spansMatrix[i][0]);
@@ -401,6 +450,21 @@
                 return;
             }
 
+            if (mSpansRespectWidgetOrder && mSpanMatrix != null) {
+                if (mSpanIndex < mSpanMatrix.length && mSpanMatrix[mSpanIndex][0] == position) {
+                    // when invoke getNextPosition this position would be set to false
+                    mPositionMatrix[row][col] = true;
+                    // if there is not enough space to constrain the span, don't do it.
+                    if (!invalidatePositions(row, col,
+                            mSpanMatrix[mSpanIndex][1], mSpanMatrix[mSpanIndex][2])) {
+                        continue;
+                    }
+                    connectWidget(mWidgets[i], row, col,
+                            mSpanMatrix[mSpanIndex][1], mSpanMatrix[mSpanIndex][2]);
+                    mSpanIndex++;
+                    continue;
+                }
+            }
             connectWidget(mWidgets[i], row, col, 1, 1);
         }
     }
@@ -415,6 +479,8 @@
             return;
         }
 
+        handleFlags();
+
         if (isUpdate) {
             for (int i = 0; i < mPositionMatrix.length; i++) {
                 for (int j = 0; j < mPositionMatrix[0].length; j++) {
@@ -433,17 +499,16 @@
             }
         }
 
-        int[][] parsedSpans = null;
         if (mSpans != null && !mSpans.trim().isEmpty()) {
-            parsedSpans = parseSpans(this.mSpans, true);
+            mSpanMatrix = parseSpans(this.mSpans, true);
         }
 
         // Need to create boxes before handleSpans since the spanned widgets would be
         // constrained in this step.
         createBoxes();
 
-        if (parsedSpans != null) {
-            handleSpans(parsedSpans);
+        if (mSpanMatrix != null) {
+            handleSpans(mSpanMatrix);
         }
     }
 
@@ -844,15 +909,20 @@
                     indexAndSpan = spans[i].trim().split(":");
                     rowAndCol = indexAndSpan[1].split("x");
                     spanMatrix[i][0] = Integer.parseInt(indexAndSpan[0]);
-                    spanMatrix[i][1] = Integer.parseInt(rowAndCol[0]);
-                    spanMatrix[i][2] = Integer.parseInt(rowAndCol[1]);
+                    if (mSubGridByColRow) {
+                        spanMatrix[i][1] = Integer.parseInt(rowAndCol[1]);
+                        spanMatrix[i][2] = Integer.parseInt(rowAndCol[0]);
+                    } else {
+                        spanMatrix[i][1] = Integer.parseInt(rowAndCol[0]);
+                        spanMatrix[i][2] = Integer.parseInt(rowAndCol[1]);
+                    }
+
                 }
             }
             return spanMatrix;
         } catch (Exception e) {
             return null;
         }
-
     }
 
     /**
@@ -909,6 +979,25 @@
         fillConstraintMatrix(isUpdate);
     }
 
+    /**
+     * If flags are given, set the values of the corresponding variables to true.
+     */
+    private void handleFlags() {
+        if (mFlags == null) {
+            return;
+        }
+
+        for (int flag: mFlags) {
+            switch (flag) {
+                case SPANS_RESPECT_WIDGET_ORDER:
+                    mSpansRespectWidgetOrder = true;
+                    break;
+                case SUB_GRID_BY_COL_ROW:
+                    mSubGridByColRow = true;
+                    break;
+            }
+        }
+    }
 
     @Override
     public void measure(int widthMode, int widthSize, int heightMode, int heightSize) {
diff --git a/core/core/api/aidlRelease/current/android/support/v4/app/INotificationSideChannel.aidl b/core/core/api/aidlRelease/current/android/support/v4/app/INotificationSideChannel.aidl
new file mode 100644
index 0000000..e9863e96
--- /dev/null
+++ b/core/core/api/aidlRelease/current/android/support/v4/app/INotificationSideChannel.aidl
@@ -0,0 +1,40 @@
+/**
+ * Copyright 2014, The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+///////////////////////////////////////////////////////////////////////////////
+// THIS FILE IS IMMUTABLE. DO NOT EDIT IN ANY CASE.                          //
+///////////////////////////////////////////////////////////////////////////////
+
+// This file is a snapshot of an AIDL file. Do not edit it manually. There are
+// two cases:
+// 1). this is a frozen version file - do not edit this in any case.
+// 2). this is a 'current' file. If you make a backwards compatible change to
+//     the interface (from the latest frozen version), the build system will
+//     prompt you to update this file with `m <name>-update-api`.
+//
+// You must not make a backward incompatible change to any AIDL file built
+// with the aidl_interface module type with versions property set. The module
+// type is used to build AIDL files in a way that they can be used across
+// independently updatable components of the system. If a device is shipped
+// with such a backward incompatible change, it has a high risk of breaking
+// later when a module using the interface is updated, e.g., Mainline modules.
+
+package android.support.v4.app;
+/* @hide */
+interface INotificationSideChannel {
+  oneway void notify(String packageName, int id, String tag, in android.app.Notification notification);
+  oneway void cancel(String packageName, int id, String tag);
+  oneway void cancelAll(String packageName);
+}
diff --git a/core/core/api/aidlRelease/current/android/support/v4/os/IResultReceiver.aidl b/core/core/api/aidlRelease/current/android/support/v4/os/IResultReceiver.aidl
new file mode 100644
index 0000000..cad8249
--- /dev/null
+++ b/core/core/api/aidlRelease/current/android/support/v4/os/IResultReceiver.aidl
@@ -0,0 +1,38 @@
+/**
+ * Copyright 2015, The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+///////////////////////////////////////////////////////////////////////////////
+// THIS FILE IS IMMUTABLE. DO NOT EDIT IN ANY CASE.                          //
+///////////////////////////////////////////////////////////////////////////////
+
+// This file is a snapshot of an AIDL file. Do not edit it manually. There are
+// two cases:
+// 1). this is a frozen version file - do not edit this in any case.
+// 2). this is a 'current' file. If you make a backwards compatible change to
+//     the interface (from the latest frozen version), the build system will
+//     prompt you to update this file with `m <name>-update-api`.
+//
+// You must not make a backward incompatible change to any AIDL file built
+// with the aidl_interface module type with versions property set. The module
+// type is used to build AIDL files in a way that they can be used across
+// independently updatable components of the system. If a device is shipped
+// with such a backward incompatible change, it has a high risk of breaking
+// later when a module using the interface is updated, e.g., Mainline modules.
+
+package android.support.v4.os;
+/* @hide */
+interface IResultReceiver {
+  oneway void send(int resultCode, in android.os.Bundle resultData);
+}
diff --git a/core/core/api/aidlRelease/current/android/support/v4/os/IResultReceiver2.aidl b/core/core/api/aidlRelease/current/android/support/v4/os/IResultReceiver2.aidl
new file mode 100644
index 0000000..881607e
--- /dev/null
+++ b/core/core/api/aidlRelease/current/android/support/v4/os/IResultReceiver2.aidl
@@ -0,0 +1,38 @@
+/**
+ * Copyright 2015, The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+///////////////////////////////////////////////////////////////////////////////
+// THIS FILE IS IMMUTABLE. DO NOT EDIT IN ANY CASE.                          //
+///////////////////////////////////////////////////////////////////////////////
+
+// This file is a snapshot of an AIDL file. Do not edit it manually. There are
+// two cases:
+// 1). this is a frozen version file - do not edit this in any case.
+// 2). this is a 'current' file. If you make a backwards compatible change to
+//     the interface (from the latest frozen version), the build system will
+//     prompt you to update this file with `m <name>-update-api`.
+//
+// You must not make a backward incompatible change to any AIDL file built
+// with the aidl_interface module type with versions property set. The module
+// type is used to build AIDL files in a way that they can be used across
+// independently updatable components of the system. If a device is shipped
+// with such a backward incompatible change, it has a high risk of breaking
+// later when a module using the interface is updated, e.g., Mainline modules.
+
+package android.support.v4.os;
+/* @hide */
+interface IResultReceiver2 {
+  oneway void send(int resultCode, in android.os.Bundle resultData);
+}
diff --git a/core/core/api/aidlRelease/current/android/support/v4/os/ResultReceiver.aidl b/core/core/api/aidlRelease/current/android/support/v4/os/ResultReceiver.aidl
new file mode 100644
index 0000000..7b3c909
--- /dev/null
+++ b/core/core/api/aidlRelease/current/android/support/v4/os/ResultReceiver.aidl
@@ -0,0 +1,37 @@
+/**
+ * Copyright 2015, The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+///////////////////////////////////////////////////////////////////////////////
+// THIS FILE IS IMMUTABLE. DO NOT EDIT IN ANY CASE.                          //
+///////////////////////////////////////////////////////////////////////////////
+
+// This file is a snapshot of an AIDL file. Do not edit it manually. There are
+// two cases:
+// 1). this is a frozen version file - do not edit this in any case.
+// 2). this is a 'current' file. If you make a backwards compatible change to
+//     the interface (from the latest frozen version), the build system will
+//     prompt you to update this file with `m <name>-update-api`.
+//
+// You must not make a backward incompatible change to any AIDL file built
+// with the aidl_interface module type with versions property set. The module
+// type is used to build AIDL files in a way that they can be used across
+// independently updatable components of the system. If a device is shipped
+// with such a backward incompatible change, it has a high risk of breaking
+// later when a module using the interface is updated, e.g., Mainline modules.
+
+package android.support.v4.os;
+@JavaOnlyStableParcelable
+parcelable ResultReceiver {
+}
diff --git a/core/core/api/aidlRelease/current/androidx/core/app/unusedapprestrictions/IUnusedAppRestrictionsBackportCallback.aidl b/core/core/api/aidlRelease/current/androidx/core/app/unusedapprestrictions/IUnusedAppRestrictionsBackportCallback.aidl
new file mode 100644
index 0000000..41570bd
--- /dev/null
+++ b/core/core/api/aidlRelease/current/androidx/core/app/unusedapprestrictions/IUnusedAppRestrictionsBackportCallback.aidl
@@ -0,0 +1,38 @@
+/**
+ * Copyright 2021, The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+///////////////////////////////////////////////////////////////////////////////
+// THIS FILE IS IMMUTABLE. DO NOT EDIT IN ANY CASE.                          //
+///////////////////////////////////////////////////////////////////////////////
+
+// This file is a snapshot of an AIDL file. Do not edit it manually. There are
+// two cases:
+// 1). this is a frozen version file - do not edit this in any case.
+// 2). this is a 'current' file. If you make a backwards compatible change to
+//     the interface (from the latest frozen version), the build system will
+//     prompt you to update this file with `m <name>-update-api`.
+//
+// You must not make a backward incompatible change to any AIDL file built
+// with the aidl_interface module type with versions property set. The module
+// type is used to build AIDL files in a way that they can be used across
+// independently updatable components of the system. If a device is shipped
+// with such a backward incompatible change, it has a high risk of breaking
+// later when a module using the interface is updated, e.g., Mainline modules.
+
+package androidx.core.app.unusedapprestrictions;
+/* @hide */
+interface IUnusedAppRestrictionsBackportCallback {
+  oneway void onIsPermissionRevocationEnabledForAppResult(boolean success, boolean isEnabled);
+}
diff --git a/core/core/api/aidlRelease/current/androidx/core/app/unusedapprestrictions/IUnusedAppRestrictionsBackportService.aidl b/core/core/api/aidlRelease/current/androidx/core/app/unusedapprestrictions/IUnusedAppRestrictionsBackportService.aidl
new file mode 100644
index 0000000..9aba17c
--- /dev/null
+++ b/core/core/api/aidlRelease/current/androidx/core/app/unusedapprestrictions/IUnusedAppRestrictionsBackportService.aidl
@@ -0,0 +1,38 @@
+/**
+ * Copyright 2021, The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+///////////////////////////////////////////////////////////////////////////////
+// THIS FILE IS IMMUTABLE. DO NOT EDIT IN ANY CASE.                          //
+///////////////////////////////////////////////////////////////////////////////
+
+// This file is a snapshot of an AIDL file. Do not edit it manually. There are
+// two cases:
+// 1). this is a frozen version file - do not edit this in any case.
+// 2). this is a 'current' file. If you make a backwards compatible change to
+//     the interface (from the latest frozen version), the build system will
+//     prompt you to update this file with `m <name>-update-api`.
+//
+// You must not make a backward incompatible change to any AIDL file built
+// with the aidl_interface module type with versions property set. The module
+// type is used to build AIDL files in a way that they can be used across
+// independently updatable components of the system. If a device is shipped
+// with such a backward incompatible change, it has a high risk of breaking
+// later when a module using the interface is updated, e.g., Mainline modules.
+
+package androidx.core.app.unusedapprestrictions;
+/* @hide */
+interface IUnusedAppRestrictionsBackportService {
+  oneway void isPermissionRevocationEnabledForApp(in androidx.core.app.unusedapprestrictions.IUnusedAppRestrictionsBackportCallback callback);
+}
diff --git a/core/core/build.gradle b/core/core/build.gradle
index 2f93e91..b9743ef 100644
--- a/core/core/build.gradle
+++ b/core/core/build.gradle
@@ -4,6 +4,7 @@
     id("AndroidXPlugin")
     id("com.android.library")
     id("kotlin-android")
+    id("androidx.stableaidl")
 }
 
 dependencies {
@@ -67,6 +68,10 @@
     }
     buildTypes.all {
         consumerProguardFiles "proguard-rules.pro"
+
+        stableAidl {
+            version 1
+        }
     }
 
     packagingOptions {
diff --git a/core/core/src/androidTest/java/androidx/core/view/WindowInsetsAnimationCompatActivityTest.kt b/core/core/src/androidTest/java/androidx/core/view/WindowInsetsAnimationCompatActivityTest.kt
index 7b89e4d..971024c 100644
--- a/core/core/src/androidTest/java/androidx/core/view/WindowInsetsAnimationCompatActivityTest.kt
+++ b/core/core/src/androidTest/java/androidx/core/view/WindowInsetsAnimationCompatActivityTest.kt
@@ -220,7 +220,7 @@
         triggerInsetAnimation(container)
         latch.await(5, TimeUnit.SECONDS)
         Truth.assertThat(res).containsExactly("prepare", "start", "progress", "end").inOrder()
-        Truth.assertThat(progress).containsAtLeast(0.0f, 1.0f)
+        Truth.assertThat(progress).contains(1.0f)
         Truth.assertThat(progress).isInOrder()
     }
 
diff --git a/core/core/src/main/aidl/android/support/v4/os/IResultReceiver.aidl b/core/core/src/main/aidl/android/support/v4/os/IResultReceiver.aidl
deleted file mode 100644
index cd23ff3..0000000
--- a/core/core/src/main/aidl/android/support/v4/os/IResultReceiver.aidl
+++ /dev/null
@@ -1,24 +0,0 @@
-/*
-** Copyright 2015, The Android Open Source Project
-**
-** Licensed under the Apache License, Version 2.0 (the "License");
-** you may not use this file except in compliance with the License.
-** You may obtain a copy of the License at
-**
-**     http://www.apache.org/licenses/LICENSE-2.0
-**
-** Unless required by applicable law or agreed to in writing, software
-** distributed under the License is distributed on an "AS IS" BASIS,
-** WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-** See the License for the specific language governing permissions and
-** limitations under the License.
-*/
-
-package android.support.v4.os;
-
-import android.os.Bundle;
-
-/** @hide */
-oneway interface IResultReceiver {
-    void send(int resultCode, in Bundle resultData);
-}
diff --git a/core/core/src/main/aidl/android/support/v4/os/ResultReceiver.aidl b/core/core/src/main/aidl/android/support/v4/os/ResultReceiver.aidl
deleted file mode 100644
index 81c81f6..0000000
--- a/core/core/src/main/aidl/android/support/v4/os/ResultReceiver.aidl
+++ /dev/null
@@ -1,19 +0,0 @@
-/*
-** Copyright 2015, The Android Open Source Project
-**
-** Licensed under the Apache License, Version 2.0 (the "License");
-** you may not use this file except in compliance with the License.
-** You may obtain a copy of the License at
-**
-**     http://www.apache.org/licenses/LICENSE-2.0
-**
-** Unless required by applicable law or agreed to in writing, software
-** distributed under the License is distributed on an "AS IS" BASIS,
-** WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-** See the License for the specific language governing permissions and
-** limitations under the License.
-*/
-
-package android.support.v4.os;
-
-parcelable ResultReceiver;
diff --git a/core/core/src/main/aidl/androidx/core/app/unusedapprestrictions/IUnusedAppRestrictionsBackportCallback.aidl b/core/core/src/main/aidl/androidx/core/app/unusedapprestrictions/IUnusedAppRestrictionsBackportCallback.aidl
deleted file mode 100644
index 617199c..0000000
--- a/core/core/src/main/aidl/androidx/core/app/unusedapprestrictions/IUnusedAppRestrictionsBackportCallback.aidl
+++ /dev/null
@@ -1,18 +0,0 @@
-package androidx.core.app.unusedapprestrictions;
-
-/** @hide */
-interface IUnusedAppRestrictionsBackportCallback {
-
- /**
-  * This will be called with the results of the
-  * IUnusedAppRestrictionsBackportService.isPermissionRevocationEnabledForApp API.
-  *
-  * @param success false if there was an error while checking if the app is
-  * enabled, otherwise true.
-  * @param isEnabled true if permission revocation is enabled for the app,
-  * otherwise false.
-  */
-  oneway void onIsPermissionRevocationEnabledForAppResult(
-    boolean success, boolean isEnabled
-  );
-}
\ No newline at end of file
diff --git a/core/core/src/main/aidl/androidx/core/app/unusedapprestrictions/IUnusedAppRestrictionsBackportService.aidl b/core/core/src/main/aidl/androidx/core/app/unusedapprestrictions/IUnusedAppRestrictionsBackportService.aidl
deleted file mode 100644
index 48fbfe4..0000000
--- a/core/core/src/main/aidl/androidx/core/app/unusedapprestrictions/IUnusedAppRestrictionsBackportService.aidl
+++ /dev/null
@@ -1,20 +0,0 @@
-package androidx.core.app.unusedapprestrictions;
-
-import androidx.core.app.unusedapprestrictions.IUnusedAppRestrictionsBackportCallback;
-
-/** @hide */
-interface IUnusedAppRestrictionsBackportService {
-
-  /**
-   * Checks whether permission revocation is enabled for the calling application.
-   *
-   * <p>This API is only intended to work for the backported version of
-   * permission revocation running on Android M-Q and will not work for Android
-   * R+ versions of permission revocation. Only the Verifier on the device can implement this,
-   * as that is the component responsible for auto-revoking permissions on M-Q devices.
-   *
-   * @param callback An IUnusedAppRestrictionsBackportCallback object that will
-   * be called with the results of this API
-   */
-  oneway void isPermissionRevocationEnabledForApp(in IUnusedAppRestrictionsBackportCallback callback);
-}
\ No newline at end of file
diff --git a/core/core/src/main/java/android/support/v4/os/ResultReceiver.java b/core/core/src/main/java/android/support/v4/os/ResultReceiver.java
index 4e508a4..81ec408 100644
--- a/core/core/src/main/java/android/support/v4/os/ResultReceiver.java
+++ b/core/core/src/main/java/android/support/v4/os/ResultReceiver.java
@@ -35,7 +35,7 @@
  * supply with {@link #send}.
  *
  * <p>Note: the implementation underneath is just a simple wrapper around
- * a {@link Binder} that is used to perform the communication.  This means
+ * a {@link android.os.Binder} that is used to perform the communication.  This means
  * semantically you should treat it as such: this class does not impact process
  * lifecycle management (you must be using some higher-level component to tell
  * the system that your process needs to continue running), the connection will
@@ -128,18 +128,23 @@
 
     @Override
     public void writeToParcel(@NonNull Parcel out, int flags) {
+        // Begin AIDL parceling code: DO NOT MODIFY!
         synchronized (this) {
             if (mReceiver == null) {
                 mReceiver = new MyResultReceiver();
             }
             out.writeStrongBinder(mReceiver.asBinder());
         }
+        // End AIDL parceling code.
     }
 
     ResultReceiver(Parcel in) {
         mLocal = false;
         mHandler = null;
+
+        // Begin AIDL unparceling code: DO NOT MODIFY!
         mReceiver = IResultReceiver.Stub.asInterface(in.readStrongBinder());
+        // End AIDL parceling code.
     }
 
     public static final Creator<ResultReceiver> CREATOR
diff --git a/core/core/src/main/aidl/android/support/v4/app/INotificationSideChannel.aidl b/core/core/src/main/stableAidl/android/support/v4/app/INotificationSideChannel.aidl
similarity index 91%
rename from core/core/src/main/aidl/android/support/v4/app/INotificationSideChannel.aidl
rename to core/core/src/main/stableAidl/android/support/v4/app/INotificationSideChannel.aidl
index 9df1577..dae811d 100644
--- a/core/core/src/main/aidl/android/support/v4/app/INotificationSideChannel.aidl
+++ b/core/core/src/main/stableAidl/android/support/v4/app/INotificationSideChannel.aidl
@@ -1,11 +1,11 @@
-/*
- * Copyright (C) 2014 The Android Open Source Project
+/**
+ * Copyright 2014, The Android Open Source Project
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
  * You may obtain a copy of the License at
  *
- *      http://www.apache.org/licenses/LICENSE-2.0
+ *     http://www.apache.org/licenses/LICENSE-2.0
  *
  * Unless required by applicable law or agreed to in writing, software
  * distributed under the License is distributed on an "AS IS" BASIS,
diff --git a/appactions/interaction/interaction-service/src/main/java/androidx/appactions/interaction/service/proto/package-info.java b/core/core/src/main/stableAidl/android/support/v4/os/IResultReceiver.aidl
similarity index 66%
copy from appactions/interaction/interaction-service/src/main/java/androidx/appactions/interaction/service/proto/package-info.java
copy to core/core/src/main/stableAidl/android/support/v4/os/IResultReceiver.aidl
index 93db9d1..f31d6ae 100644
--- a/appactions/interaction/interaction-service/src/main/java/androidx/appactions/interaction/service/proto/package-info.java
+++ b/core/core/src/main/stableAidl/android/support/v4/os/IResultReceiver.aidl
@@ -1,11 +1,11 @@
-/*
- * Copyright 2023 The Android Open Source Project
+/**
+ * Copyright 2015, The Android Open Source Project
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
  * You may obtain a copy of the License at
  *
- *      http://www.apache.org/licenses/LICENSE-2.0
+ *     http://www.apache.org/licenses/LICENSE-2.0
  *
  * Unless required by applicable law or agreed to in writing, software
  * distributed under the License is distributed on an "AS IS" BASIS,
@@ -14,8 +14,11 @@
  * limitations under the License.
  */
 
-/** @hide */
-@RestrictTo(RestrictTo.Scope.LIBRARY)
-package androidx.appactions.interaction.service.proto;
+package android.support.v4.os;
 
-import androidx.annotation.RestrictTo;
+import android.os.Bundle;
+
+/** @hide */
+oneway interface IResultReceiver {
+    void send(int resultCode, in Bundle resultData);
+}
diff --git a/appactions/interaction/interaction-service/src/main/java/androidx/appactions/interaction/service/proto/package-info.java b/core/core/src/main/stableAidl/android/support/v4/os/IResultReceiver2.aidl
similarity index 66%
copy from appactions/interaction/interaction-service/src/main/java/androidx/appactions/interaction/service/proto/package-info.java
copy to core/core/src/main/stableAidl/android/support/v4/os/IResultReceiver2.aidl
index 93db9d1..19914e7 100644
--- a/appactions/interaction/interaction-service/src/main/java/androidx/appactions/interaction/service/proto/package-info.java
+++ b/core/core/src/main/stableAidl/android/support/v4/os/IResultReceiver2.aidl
@@ -1,11 +1,11 @@
-/*
- * Copyright 2023 The Android Open Source Project
+/**
+ * Copyright 2015, The Android Open Source Project
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
  * You may obtain a copy of the License at
  *
- *      http://www.apache.org/licenses/LICENSE-2.0
+ *     http://www.apache.org/licenses/LICENSE-2.0
  *
  * Unless required by applicable law or agreed to in writing, software
  * distributed under the License is distributed on an "AS IS" BASIS,
@@ -14,8 +14,11 @@
  * limitations under the License.
  */
 
-/** @hide */
-@RestrictTo(RestrictTo.Scope.LIBRARY)
-package androidx.appactions.interaction.service.proto;
+package android.support.v4.os;
 
-import androidx.annotation.RestrictTo;
+import android.os.Bundle;
+
+/** @hide */
+oneway interface IResultReceiver2 {
+    void send(int resultCode, in Bundle resultData);
+}
diff --git a/appactions/interaction/interaction-service/src/main/java/androidx/appactions/interaction/service/proto/package-info.java b/core/core/src/main/stableAidl/android/support/v4/os/ResultReceiver.aidl
similarity index 66%
copy from appactions/interaction/interaction-service/src/main/java/androidx/appactions/interaction/service/proto/package-info.java
copy to core/core/src/main/stableAidl/android/support/v4/os/ResultReceiver.aidl
index 93db9d1..430b8b5 100644
--- a/appactions/interaction/interaction-service/src/main/java/androidx/appactions/interaction/service/proto/package-info.java
+++ b/core/core/src/main/stableAidl/android/support/v4/os/ResultReceiver.aidl
@@ -1,11 +1,11 @@
-/*
- * Copyright 2023 The Android Open Source Project
+/**
+ * Copyright 2015, The Android Open Source Project
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
  * You may obtain a copy of the License at
  *
- *      http://www.apache.org/licenses/LICENSE-2.0
+ *     http://www.apache.org/licenses/LICENSE-2.0
  *
  * Unless required by applicable law or agreed to in writing, software
  * distributed under the License is distributed on an "AS IS" BASIS,
@@ -14,8 +14,6 @@
  * limitations under the License.
  */
 
-/** @hide */
-@RestrictTo(RestrictTo.Scope.LIBRARY)
-package androidx.appactions.interaction.service.proto;
+package android.support.v4.os;
 
-import androidx.annotation.RestrictTo;
+@JavaOnlyStableParcelable parcelable ResultReceiver;
diff --git a/core/core/src/main/stableAidl/androidx/core/app/unusedapprestrictions/IUnusedAppRestrictionsBackportCallback.aidl b/core/core/src/main/stableAidl/androidx/core/app/unusedapprestrictions/IUnusedAppRestrictionsBackportCallback.aidl
new file mode 100644
index 0000000..f927a8e
--- /dev/null
+++ b/core/core/src/main/stableAidl/androidx/core/app/unusedapprestrictions/IUnusedAppRestrictionsBackportCallback.aidl
@@ -0,0 +1,34 @@
+/**
+ * Copyright 2021, The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.core.app.unusedapprestrictions;
+
+/** @hide */
+interface IUnusedAppRestrictionsBackportCallback {
+
+ /**
+  * This will be called with the results of the
+  * IUnusedAppRestrictionsBackportService.isPermissionRevocationEnabledForApp API.
+  *
+  * @param success false if there was an error while checking if the app is
+  * enabled, otherwise true.
+  * @param isEnabled true if permission revocation is enabled for the app,
+  * otherwise false.
+  */
+  oneway void onIsPermissionRevocationEnabledForAppResult(
+    boolean success, boolean isEnabled
+  );
+}
diff --git a/core/core/src/main/stableAidl/androidx/core/app/unusedapprestrictions/IUnusedAppRestrictionsBackportService.aidl b/core/core/src/main/stableAidl/androidx/core/app/unusedapprestrictions/IUnusedAppRestrictionsBackportService.aidl
new file mode 100644
index 0000000..8e62742
--- /dev/null
+++ b/core/core/src/main/stableAidl/androidx/core/app/unusedapprestrictions/IUnusedAppRestrictionsBackportService.aidl
@@ -0,0 +1,36 @@
+/**
+ * Copyright 2021, The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.core.app.unusedapprestrictions;
+
+import androidx.core.app.unusedapprestrictions.IUnusedAppRestrictionsBackportCallback;
+
+/** @hide */
+interface IUnusedAppRestrictionsBackportService {
+
+  /**
+   * Checks whether permission revocation is enabled for the calling application.
+   *
+   * <p>This API is only intended to work for the backported version of
+   * permission revocation running on Android M-Q and will not work for Android
+   * R+ versions of permission revocation. Only the Verifier on the device can implement this,
+   * as that is the component responsible for auto-revoking permissions on M-Q devices.
+   *
+   * @param callback An IUnusedAppRestrictionsBackportCallback object that will
+   * be called with the results of this API
+   */
+  oneway void isPermissionRevocationEnabledForApp(in IUnusedAppRestrictionsBackportCallback callback);
+}
diff --git a/appactions/interaction/interaction-service/src/main/java/androidx/appactions/interaction/service/proto/package-info.java b/core/core/src/main/stableAidlImports/android/app/Notification.aidl
similarity index 73%
copy from appactions/interaction/interaction-service/src/main/java/androidx/appactions/interaction/service/proto/package-info.java
copy to core/core/src/main/stableAidlImports/android/app/Notification.aidl
index 93db9d1..66130cb 100644
--- a/appactions/interaction/interaction-service/src/main/java/androidx/appactions/interaction/service/proto/package-info.java
+++ b/core/core/src/main/stableAidlImports/android/app/Notification.aidl
@@ -1,5 +1,5 @@
 /*
- * Copyright 2023 The Android Open Source Project
+ * Copyright (C) 2022 The Android Open Source Project
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -14,8 +14,6 @@
  * limitations under the License.
  */
 
-/** @hide */
-@RestrictTo(RestrictTo.Scope.LIBRARY)
-package androidx.appactions.interaction.service.proto;
+package android.app;
 
-import androidx.annotation.RestrictTo;
+@JavaOnlyStableParcelable parcelable Notification;
diff --git a/appactions/interaction/interaction-service/src/main/java/androidx/appactions/interaction/service/proto/package-info.java b/core/core/src/main/stableAidlImports/android/os/Bundle.aidl
similarity index 73%
copy from appactions/interaction/interaction-service/src/main/java/androidx/appactions/interaction/service/proto/package-info.java
copy to core/core/src/main/stableAidlImports/android/os/Bundle.aidl
index 93db9d1..9642d31 100644
--- a/appactions/interaction/interaction-service/src/main/java/androidx/appactions/interaction/service/proto/package-info.java
+++ b/core/core/src/main/stableAidlImports/android/os/Bundle.aidl
@@ -1,5 +1,5 @@
 /*
- * Copyright 2023 The Android Open Source Project
+ * Copyright (C) 2022 The Android Open Source Project
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -14,8 +14,6 @@
  * limitations under the License.
  */
 
-/** @hide */
-@RestrictTo(RestrictTo.Scope.LIBRARY)
-package androidx.appactions.interaction.service.proto;
+package android.os;
 
-import androidx.annotation.RestrictTo;
+@JavaOnlyStableParcelable parcelable Bundle;
diff --git a/datastore/datastore-core/src/commonMain/kotlin/androidx/datastore/core/DataStoreImpl.kt b/datastore/datastore-core/src/commonMain/kotlin/androidx/datastore/core/DataStoreImpl.kt
index c0fb57a..2bd31ff 100644
--- a/datastore/datastore-core/src/commonMain/kotlin/androidx/datastore/core/DataStoreImpl.kt
+++ b/datastore/datastore-core/src/commonMain/kotlin/androidx/datastore/core/DataStoreImpl.kt
@@ -32,6 +32,7 @@
 import kotlinx.coroutines.flow.emitAll
 import kotlinx.coroutines.flow.flow
 import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.takeWhile
 import kotlinx.coroutines.sync.Mutex
 import kotlinx.coroutines.sync.withLock
 import kotlinx.coroutines.withContext
@@ -83,14 +84,14 @@
         }
 
         emitAll(
-            downstreamFlow.dropWhile {
-                if (currentDownStreamFlowState is Data<T>) {
+            downstreamFlow.takeWhile {
+                // end the flow if we reach the final value
+                it !is Final
+            }.dropWhile {
+                if (currentDownStreamFlowState is Data<T> && it is Data) {
                     // we need to drop until initTasks are completed and set to null, and data
                     // version >= the current version when entering flow
-                    (it !is Data) || (it.version < latestVersionAtRead)
-                } else if (currentDownStreamFlowState is Final<T>) {
-                    // We don't need to drop Final values.
-                    false
+                    it.version < latestVersionAtRead
                 } else {
                     // we need to drop the last seen state since it was either an exception or
                     // wasn't yet initialized. Since we sent a message to actor, we *will* see a
@@ -100,9 +101,8 @@
             }.map {
                 when (it) {
                     is ReadException<T> -> throw it.readException
-                    is Final<T> -> throw it.finalException
                     is Data<T> -> it.value
-                    is UnInitialized -> error(
+                    is Final<T>, is UnInitialized -> error(
                         BUG_MESSAGE
                     )
                 }
@@ -129,7 +129,9 @@
     private var initTasks: List<suspend (api: InitializerApi<T>) -> Unit>? =
         initTasksList.toList()
 
-    private val storageConnection: StorageConnection<T> by lazy {
+    // TODO(b/269772127): make this private after we allow multiple instances of DataStore on the
+    //  same file
+    internal val storageConnection: StorageConnection<T> by lazy {
         storage.createConnection()
     }
     private val coordinator: InterProcessCoordinator by lazy { storageConnection.coordinator }
@@ -178,7 +180,7 @@
     private suspend fun handleRead(read: Message.Read<T>) {
         when (val currentState = downstreamFlow.value) {
             is Data -> {
-                readData()
+                readDataAndUpdateCache()
             }
             is ReadException -> {
                 if (currentState === read.lastState) {
@@ -365,7 +367,7 @@
     }
 
     // It handles the read when the current state is Data
-    private suspend fun readData(): T {
+    private suspend fun readDataAndUpdateCache() {
         // Check if the cached version matches with shared memory counter
         val currentState = downstreamFlow.value
         val version = coordinator.getVersion()
@@ -373,18 +375,25 @@
 
         // Return cached value if cached version is latest
         if (currentState is Data && version == cachedVersion) {
-            return currentState.value
+            return
         }
         var latestVersion = INVALID_VERSION
-        val data = coordinator.tryLock {
-            val result = readDataFromFileOrDefault()
-            if (it) {
-                latestVersion = coordinator.getVersion()
+        try {
+            val data = coordinator.tryLock { locked ->
+                val result = readDataFromFileOrDefault()
+                if (locked) {
+                    latestVersion = coordinator.getVersion()
+                }
+                result
             }
-            result
+            downstreamFlow.value = Data(data, data.hashCode(), latestVersion)
+        } catch (throwable: Throwable) {
+            downstreamFlow.value = ReadException(throwable)
+            if (throwable is CancellationException) {
+                // let cancellation propagate
+                throw throwable
+            }
         }
-        downstreamFlow.value = Data(data, data.hashCode(), latestVersion)
-        return data
     }
 
     // Caller is responsible for (try to) getting file lock. It reads from the file directly without
diff --git a/datastore/datastore-core/src/commonMain/kotlin/androidx/datastore/core/SingleProcessCoordinator.kt b/datastore/datastore-core/src/commonMain/kotlin/androidx/datastore/core/SingleProcessCoordinator.kt
index 0110901..c455240 100644
--- a/datastore/datastore-core/src/commonMain/kotlin/androidx/datastore/core/SingleProcessCoordinator.kt
+++ b/datastore/datastore-core/src/commonMain/kotlin/androidx/datastore/core/SingleProcessCoordinator.kt
@@ -27,7 +27,7 @@
  */
 internal class SingleProcessCoordinator() : InterProcessCoordinator {
     private val mutex = Mutex()
-    private val VALID_VERSION = 0
+    private val version = AtomicInt(0)
 
     override val updateNotifications: Flow<Unit> = flow {}
 
@@ -47,8 +47,8 @@
     }
 
     // get the current version
-    override suspend fun getVersion(): Int = VALID_VERSION
+    override suspend fun getVersion(): Int = version.get()
 
     // increment version and return the new one
-    override suspend fun incrementAndGetVersion(): Int = VALID_VERSION
+    override suspend fun incrementAndGetVersion(): Int = version.incrementAndGet()
 }
\ No newline at end of file
diff --git a/datastore/datastore-core/src/commonTest/kotlin/androidx/datastore/core/SingleProcessDataStoreTest.kt b/datastore/datastore-core/src/commonTest/kotlin/androidx/datastore/core/SingleProcessDataStoreTest.kt
index d7c295c..79c682d 100644
--- a/datastore/datastore-core/src/commonTest/kotlin/androidx/datastore/core/SingleProcessDataStoreTest.kt
+++ b/datastore/datastore-core/src/commonTest/kotlin/androidx/datastore/core/SingleProcessDataStoreTest.kt
@@ -50,6 +50,8 @@
 import kotlin.test.BeforeTest
 import kotlin.test.Test
 import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.test.runCurrent
 
 @OptIn(ExperimentalCoroutinesApi::class)
 abstract class SingleProcessDataStoreTest<F : TestFile>(private val testIO: TestIO<F, *>) {
@@ -820,6 +822,96 @@
         dataStore2.data.first()
     }
 
+    /**
+     * test that if read fails, all collectors are notified with it.
+     */
+    @Test
+    fun readFailsAfter_successfulUpdate() = doTest {
+        val asyncCollector = async(coroutineContext + Job()) {
+            // this uses a separate independent job not to cancel the test scope when
+            // the expected exception happens
+            store.data.collect()
+        }
+        store.updateData { 2 }
+        // update the version so the next read thinks the data has changed.
+        // ideally, this test should create another datastore instance that will change the data but
+        // we don't allow multiple instances on the same file so this is an easy workaround to
+        // create the test case.
+        store.incrementSharedCounter()
+        serializerConfig.failingRead = true
+        // trigger read
+        assertThrows(testIO.ioExceptionClass()) { store.data.first() }
+        runCurrent()
+        // should cancel due to exception
+        assertThat(asyncCollector.isCancelled).isTrue()
+        // recover from failure
+        serializerConfig.failingRead = false
+        assertThat(store.data.first()).isEqualTo(2)
+    }
+
+    /**
+     * test that failed updateData calls do not affect the cache or do not affect other collectors
+     */
+    @Test
+    fun readFailsAfter_failedUpdate() = doTest {
+        // fill cache
+        store.data.first()
+        serializerConfig.failingWrite = true
+        val asyncCollector = async {
+            store.data.collect()
+        }
+        // set cache to failure
+        assertThrows(testIO.ioExceptionClass()) {
+            store.updateData { 3 }
+        }
+        runCurrent()
+        // existing collector does not get an error due to failed write
+        assertThat(asyncCollector.isActive).isTrue()
+        assertThat(store.data.first()).isEqualTo(0)
+        asyncCollector.cancelAndJoin()
+    }
+
+    @Test
+    fun finalValueIsReceived() = doTest {
+        val datastoreScope = TestScope()
+        val store = newDataStore(
+            file = testIO.newTempFile(),
+            scope = datastoreScope.backgroundScope
+        )
+        suspend fun <R> runAndPumpInStore(block: suspend () -> R): R {
+            val async = datastoreScope.async { block() }
+            datastoreScope.runCurrent()
+            check(async.isCompleted) {
+                "Async block did not complete."
+            }
+            return async.await()
+        }
+        runAndPumpInStore {
+            store.updateData { 2 }
+        }
+        val asyncCollector = async {
+            store.data.toList()
+        }
+        datastoreScope.runCurrent()
+        runCurrent()
+        assertThat(asyncCollector.isActive).isTrue()
+        runAndPumpInStore {
+            store.updateData { 3 }
+        }
+        datastoreScope.runCurrent()
+        runCurrent()
+
+        assertThat(asyncCollector.isActive).isTrue()
+        // finalize the store
+        runAndPumpInStore {
+            datastoreScope.backgroundScope.coroutineContext[Job]!!.cancelAndJoin()
+        }
+        datastoreScope.runCurrent()
+        runCurrent()
+        assertThat(asyncCollector.isActive).isFalse()
+        assertThat(asyncCollector.await()).containsExactly(2.toByte(), 3.toByte()).inOrder()
+    }
+
     private class TestingCorruptionHandler(
         private val replaceWith: Byte? = null
     ) : CorruptionHandler<Byte> {
@@ -859,3 +951,14 @@
     }
 }
 private typealias InitTaskList = suspend (api: InitializerApi<Byte>) -> Unit
+
+/**
+ * Utility method to increment shared counter using internal APIs.
+ */
+private suspend fun <T> DataStore<T>.incrementSharedCounter() {
+    val coordinator = (this as DataStoreImpl).storageConnection.coordinator
+    val currentVersion = coordinator.getVersion()
+    assertThat(
+        coordinator.incrementAndGetVersion()
+    ).isEqualTo(currentVersion + 1)
+}
\ No newline at end of file
diff --git a/datastore/datastore-core/src/jvmTest/kotlin/androidx/datastore/core/SingleProcessDataStoreStressTest.kt b/datastore/datastore-core/src/jvmTest/kotlin/androidx/datastore/core/SingleProcessDataStoreStressTest.kt
index a4f5183..f54ed5e 100644
--- a/datastore/datastore-core/src/jvmTest/kotlin/androidx/datastore/core/SingleProcessDataStoreStressTest.kt
+++ b/datastore/datastore-core/src/jvmTest/kotlin/androidx/datastore/core/SingleProcessDataStoreStressTest.kt
@@ -167,6 +167,7 @@
     }
 
     @Test
+    @Ignore("b/270197519")
     fun testManyConcurrentReadsAndWrites_withBeginningReadFailures() = runBlocking<Unit> {
         val myScope = CoroutineScope(
             Job() + Executors.newFixedThreadPool(4).asCoroutineDispatcher()
diff --git a/datastore/datastore-rxjava2/src/test/java/androidx/datastore/rxjava2/RxDataStoreTest.java b/datastore/datastore-rxjava2/src/test/java/androidx/datastore/rxjava2/RxDataStoreTest.java
index 40077e0..a26ecc5 100644
--- a/datastore/datastore-rxjava2/src/test/java/androidx/datastore/rxjava2/RxDataStoreTest.java
+++ b/datastore/datastore-rxjava2/src/test/java/androidx/datastore/rxjava2/RxDataStoreTest.java
@@ -20,7 +20,6 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import org.junit.Ignore;
 import org.junit.Rule;
 import org.junit.Test;
 import org.junit.rules.TemporaryFolder;
@@ -62,7 +61,6 @@
         assertThat(firstByte).isEqualTo(1);
     }
 
-    @Ignore // b/214040264
     @Test
     public void testTake3() throws Exception {
         File newFile = tempFolder.newFile();
@@ -72,7 +70,8 @@
                         .build();
 
         TestSubscriber<Byte> testSubscriber = byteStore.data().test();
-
+        // wait for the initial value
+        testSubscriber.awaitCount(1);
         byteStore.updateDataAsync(RxDataStoreTest::incrementByte);
         // Wait for our subscriber to see the second write, otherwise we may skip from 0 - 2
         testSubscriber.awaitCount(2);
diff --git a/datastore/datastore-rxjava3/src/test/java/androidx/datastore/rxjava3/RxDataStoreTest.java b/datastore/datastore-rxjava3/src/test/java/androidx/datastore/rxjava3/RxDataStoreTest.java
index 39da7e1..5c4680e 100644
--- a/datastore/datastore-rxjava3/src/test/java/androidx/datastore/rxjava3/RxDataStoreTest.java
+++ b/datastore/datastore-rxjava3/src/test/java/androidx/datastore/rxjava3/RxDataStoreTest.java
@@ -69,7 +69,8 @@
                         .build();
 
         TestSubscriber<Byte> testSubscriber = byteStore.data().test();
-
+        // wait for the initial value
+        testSubscriber.awaitCount(1);
         byteStore.updateDataAsync(RxDataStoreTest::incrementByte);
         // Wait for our subscriber to see the second write, otherwise we may skip from 0 - 2
         testSubscriber.awaitCount(2);
diff --git a/development/build_log_simplifier/message-flakes.ignore b/development/build_log_simplifier/message-flakes.ignore
index 964539e..4d139bd 100644
--- a/development/build_log_simplifier/message-flakes.ignore
+++ b/development/build_log_simplifier/message-flakes.ignore
@@ -145,5 +145,5 @@
 Publishing build scan\.\.\.
 https://ge\.androidx\.dev/s/.*
 # androidx-demos:compileReleaseJavaWithJavac
-Note: \$SUPPORT/samples/Support[0-9]+Demos/src/main/java/com/example/android/supportv[0-9]+/util/DiffUtilActivity\.java uses unchecked or unsafe operations\.
+Note: \$SUPPORT/samples/AndroidXDemos/src/main/java/com/example/androidx/util/DiffUtilActivity\.java uses unchecked or unsafe operations\.
 Calculating task graph as configuration cache cannot be reused because JVM has changed\.
diff --git a/development/build_log_simplifier/messages.ignore b/development/build_log_simplifier/messages.ignore
index 86c3857..0ce85fa 100644
--- a/development/build_log_simplifier/messages.ignore
+++ b/development/build_log_simplifier/messages.ignore
@@ -155,6 +155,7 @@
 WARN: Missing @param tag for parameter `variationSettings` of function androidx\.compose\.ui\.text\.font/AndroidFont/AndroidFont/\#androidx\.compose\.ui\.text\.font\.FontLoadingStrategy\#androidx\.compose\.ui\.text\.font\.AndroidFont\.TypefaceLoader\#androidx\.compose\.ui\.text\.font\.FontVariation\.Settings/PointingToDeclaration/
 WARN: Missing @param tag for parameter `motionScene` of function androidx\.constraintlayout\.compose//MotionCarousel/\#androidx\.constraintlayout\.compose\.MotionScene\#kotlin\.Int\#kotlin\.Int\#kotlin\.String\#kotlin\.String\#kotlin\.String\#kotlin\.Boolean\#kotlin\.Function[0-9]+\[androidx\.constraintlayout\.compose\.MotionCarouselScope,kotlin\.Unit\]/PointingToDeclaration/
 WARN: Missing @param tag for parameter `content` of function androidx\.glance\.appwidget//AndroidRemoteViews/\#android\.widget\.RemoteViews\#kotlin\.Int\#kotlin\.Function[0-9]+\[kotlin\.Unit\]/PointingToDeclaration/
+WARN: Missing @param tag for parameter `rendererName` of function androidx\.media[0-9]+\.exoplayer/ExoPlaybackException/createForRenderer/\#java\.lang\.Throwable\#java\.lang\.String\#int\#androidx\.media[0-9]+\.common\.Format\#int\#boolean\#int/PointingToDeclaration/
 WARN: Missing @param tag for parameter `output` of function androidx\.glance\.appwidget\.proto/LayoutProtoSerializer/writeTo/\#androidx\.glance\.appwidget\.proto\.LayoutProto\.LayoutConfig\#java\.io\.OutputStream/PointingToDeclaration/
 WARN: Missing @param tag for parameter `factory` of function androidx\.lifecycle\.viewmodel\.compose//viewModel/\#java\.lang\.Class\[TypeParam\(bounds=\[androidx\.lifecycle\.ViewModel\]\)\]\#androidx\.lifecycle\.ViewModelStoreOwner\#kotlin\.String\?\#androidx\.lifecycle\.ViewModelProvider\.Factory\?\#androidx\.lifecycle\.viewmodel\.CreationExtras/PointingToDeclaration/
 WARN: Failed to resolve `@see PagingSource\.invalidate`!
@@ -447,6 +448,7 @@
 WARN: Missing @param tag for parameter `c` of function androidx\.cursoradapter\.widget/ResourceCursorAdapter/ResourceCursorAdapter/\#android\.content\.Context\#int\#android\.database\.Cursor/PointingToDeclaration/
 WARN: Missing @param tag for parameter `listener` of function androidx\.customview\.poolingcontainer/PoolingContainer/addPoolingContainerListener/android\.view\.View\#androidx\.customview\.poolingcontainer\.PoolingContainerListener/PointingToDeclaration/
 WARN: Missing @param tag for parameter `listener` of function androidx\.customview\.poolingcontainer//addPoolingContainerListener/android\.view\.View\#androidx\.customview\.poolingcontainer\.PoolingContainerListener/PointingToDeclaration/
+WARN: Failed to resolve `@see <a href="https://www\.w[0-9]+\.org/TR/ttml[0-9]+/">Timed Text Markup Language [0-9]+ \(TTML[0-9]+\) \- [0-9]+\.[0-9]+\.[0-9]+</a>`!
 WARN: Missing @param tag for parameter `pointerId` of function androidx\.customview\.widget/ViewDragHelper/isEdgeTouched/\#int\#int/PointingToDeclaration/
 WARN: Missing @param tag for parameter `serializer` of function androidx\.datastore//dataStore/\#kotlin\.String\#androidx\.datastore\.core\.Serializer\[TypeParam\(bounds=\[kotlin\.Any\?\]\)\]\#androidx\.datastore\.core\.handlers\.ReplaceFileCorruptionHandler\[TypeParam\(bounds=\[kotlin\.Any\?\]\)\]\?\#kotlin\.Function[0-9]+\[android\.content\.Context,kotlin\.collections\.List\[androidx\.datastore\.core\.DataMigration\[TypeParam\(bounds=\[kotlin\.Any\?\]\)\]\]\]\#kotlinx\.coroutines\.CoroutineScope/PointingToDeclaration/
 WARN: Missing @param tag for parameter `serializer` of function androidx\.datastore/DataStoreDelegateKt/dataStore/\#kotlin\.String\#androidx\.datastore\.core\.Serializer\[TypeParam\(bounds=\[kotlin\.Any\?\]\)\]\#androidx\.datastore\.core\.handlers\.ReplaceFileCorruptionHandler\[TypeParam\(bounds=\[kotlin\.Any\?\]\)\]\?\#kotlin\.Function[0-9]+\[android\.content\.Context,kotlin\.collections\.List\[androidx\.datastore\.core\.DataMigration\[TypeParam\(bounds=\[kotlin\.Any\?\]\)\]\]\]\#kotlinx\.coroutines\.CoroutineScope/PointingToDeclaration/
@@ -461,6 +463,9 @@
 WARN: Failed to resolve `@see <a href="https://developer\.android\.com/guide/topics/ui/drag\-drop">Drag and drop</a>`!
 WARN: Missing @param tag for parameter `useEmojiAsDefaultStyle` of function androidx\.emoji\.text/EmojiCompat\.Config/setUseEmojiAsDefaultStyle/\#boolean\#java\.util\.List<java\.lang\.Integer>/PointingToDeclaration/
 WARN: Missing @param tag for parameter `useEmojiAsDefaultStyle` of function androidx\.emoji[0-9]+\.text/EmojiCompat\.Config/setUseEmojiAsDefaultStyle/\#boolean\#java\.util\.List<java\.lang\.Integer>/PointingToDeclaration/
+WARN: Missing @param tag for parameter `speed` of function androidx\.media[0-9]+\.common\.util/Util/getPlayoutDurationForMediaDuration/\#long\#float/PointingToDeclaration/
+WARN: Missing @param tag for parameter `length` of function androidx\.media[0-9]+\.datasource/DataSourceUtil/readExactly/\#androidx\.media[0-9]+\.datasource\.DataSource\#int/PointingToDeclaration/
+WARN: Missing @param tag for parameter `allowCrossProtocolRedirects` of function androidx\.media[0-9]+\.datasource/DefaultDataSource/DefaultDataSource/\#android\.content\.Context\#boolean/PointingToDeclaration/
 WARN: Missing @param tag for parameter `inflater` of function androidx\.fragment\.app/Fragment/onCreateOptionsMenu/\#android\.view\.Menu\#android\.view\.MenuInflater/PointingToDeclaration/
 WARN: Missing @param tag for parameter `hardwareBuffer` of function androidx\.graphics\.lowlatency/FrameBuffer/FrameBuffer/\#androidx\.graphics\.opengl\.egl\.EGLSpec\#android\.hardware\.HardwareBuffer/PointingToDeclaration/
 WARN: Missing @param tag for parameter `context` of function androidx\.graphics\.opengl\.egl/EGLSpec/eglMakeCurrent/\#android\.opengl\.EGLContext\#android\.opengl\.EGLSurface\#android\.opengl\.EGLSurface/PointingToDeclaration/
@@ -475,6 +480,10 @@
 WARN: Missing @param tag for parameter `detailsPresenter` of function androidx\.leanback\.widget/FullWidthDetailsOverviewRowPresenter\.ViewHolder/ViewHolder/\#android\.view\.View\#androidx\.leanback\.widget\.Presenter\#androidx\.leanback\.widget\.DetailsOverviewLogoPresenter/PointingToDeclaration/
 WARN: Missing @param tag for parameter `logoPresenter` of function androidx\.leanback\.widget/FullWidthDetailsOverviewRowPresenter\.ViewHolder/ViewHolder/\#android\.view\.View\#androidx\.leanback\.widget\.Presenter\#androidx\.leanback\.widget\.DetailsOverviewLogoPresenter/PointingToDeclaration/
 WARN: Missing @param tag for parameter `parent` of function androidx\.leanback\.widget/GridLayoutManager/requestChildRectangleOnScreen/\#androidx\.recyclerview\.widget\.RecyclerView\#android\.view\.View\#android\.graphics\.Rect\#boolean/PointingToDeclaration/
+WARN: Missing @param tag for parameter `playerEmsgHandler` of function androidx\.media[0-9]+\.exoplayer\.dash/DashChunkSource\.Factory/createDashChunkSource/\#androidx\.media[0-9]+\.exoplayer\.upstream\.LoaderErrorThrower\#androidx\.media[0-9]+\.exoplayer\.dash\.manifest\.DashManifest\#androidx\.media[0-9]+\.exoplayer\.dash\.BaseUrlExclusionList\#int\#int\[\]\#androidx\.media[0-9]+\.exoplayer\.trackselection\.ExoTrackSelection\#int\#long\#boolean\#java\.util\.List<androidx\.media[0-9]+\.common\.Format>\#androidx\.media[0-9]+\.exoplayer\.dash\.PlayerEmsgHandler\.PlayerTrackEmsgHandler\#androidx\.media[0-9]+\.datasource\.TransferListener\#androidx\.media[0-9]+\.exoplayer\.analytics\.PlayerId/PointingToDeclaration/
+WARN: Missing @param tag for parameter `periodIndex` of function androidx\.media[0-9]+\.exoplayer\.dash/DashChunkSource/updateManifest/\#androidx\.media[0-9]+\.exoplayer\.dash\.manifest\.DashManifest\#int/PointingToDeclaration/
+WARN: Missing @param tag for parameter `playerEmsgHandler` of function androidx\.media[0-9]+\.exoplayer\.dash/DefaultDashChunkSource\.Factory/createDashChunkSource/\#androidx\.media[0-9]+\.exoplayer\.upstream\.LoaderErrorThrower\#androidx\.media[0-9]+\.exoplayer\.dash\.manifest\.DashManifest\#androidx\.media[0-9]+\.exoplayer\.dash\.BaseUrlExclusionList\#int\#int\[\]\#androidx\.media[0-9]+\.exoplayer\.trackselection\.ExoTrackSelection\#int\#long\#boolean\#java\.util\.List<androidx\.media[0-9]+\.common\.Format>\#androidx\.media[0-9]+\.exoplayer\.dash\.PlayerEmsgHandler\.PlayerTrackEmsgHandler\#androidx\.media[0-9]+\.datasource\.TransferListener\#androidx\.media[0-9]+\.exoplayer\.analytics\.PlayerId/PointingToDeclaration/
+WARN: Missing @param tag for parameter `newPeriodIndex` of function androidx\.media[0-9]+\.exoplayer\.dash/DefaultDashChunkSource/updateManifest/\#androidx\.media[0-9]+\.exoplayer\.dash\.manifest\.DashManifest\#int/PointingToDeclaration/
 WARN: Missing @param tag for parameter `name` of function androidx\.leanback\.widget/Parallax/createProperty/\#java\.lang\.String\#int/PointingToDeclaration/
 WARN: Missing @param tag for parameter `id` of function androidx\.leanback\.widget/PlaybackControlsRow\.ThumbsAction/ThumbsAction/\#int\#android\.content\.Context\#int\#int/PointingToDeclaration/
 WARN: Missing @param tag for parameter `solidIconIndex` of function androidx\.leanback\.widget/PlaybackControlsRow\.ThumbsAction/ThumbsAction/\#int\#android\.content\.Context\#int\#int/PointingToDeclaration/
@@ -490,12 +499,50 @@
 WARN: Use @androidx\.annotation\.Nullable, not @javax\.annotation/Nullable///PointingToDeclaration/
 WARN: Missing @param tag for parameter `metadata` of function androidx\.media[0-9]+\.player/MediaPlayer/setPlaylist/\#java\.util\.List<androidx\.media[0-9]+\.common\.MediaItem>\#androidx\.media[0-9]+\.common\.MediaMetadata/PointingToDeclaration/
 WARN: Missing @param tag for parameter `controller` of function androidx\.media[0-9]+\.session/MediaSession/sendCustomCommand/\#androidx\.media[0-9]+\.session\.MediaSession\.ControllerInfo\#androidx\.media[0-9]+\.session\.SessionCommand\#android\.os\.Bundle/PointingToDeclaration/
+WARN: Missing @param tag for parameter `runnable` of function androidx\.media[0-9]+\.test\.utils/Action\.ExecuteRunnable/ExecuteRunnable/\#java\.lang\.String\#java\.lang\.Runnable/PointingToDeclaration/
+WARN: Missing @param tag for parameter `drmSessionManager` of function androidx\.media[0-9]+\.test\.utils/FakeAdaptiveMediaSource/createMediaPeriod/\#androidx\.media[0-9]+\.exoplayer\.source\.MediaSource\.MediaPeriodId\#androidx\.media[0-9]+\.exoplayer\.source\.TrackGroupArray\#androidx\.media[0-9]+\.exoplayer\.upstream\.Allocator\#androidx\.media[0-9]+\.exoplayer\.source\.MediaSourceEventListener\.EventDispatcher\#androidx\.media[0-9]+\.exoplayer\.drm\.DrmSessionManager\#androidx\.media[0-9]+\.exoplayer\.drm\.DrmSessionEventListener\.EventDispatcher\#androidx\.media[0-9]+\.datasource\.TransferListener/PointingToDeclaration/
+WARN: Missing @param tag for parameter `params` of function androidx\.media[0-9]+\.test\.utils/FakeTrackSelector/selectAllTracks/\#androidx\.media[0-9]+\.exoplayer\.trackselection\.MappingTrackSelector\.MappedTrackInfo\#int\[\]\[\]\[\]\#int\[\]\#androidx\.media[0-9]+\.exoplayer\.trackselection\.DefaultTrackSelector\.Parameters/PointingToDeclaration/
+WARN: Missing @param tag for parameter `startPositionUs` of function androidx\.media[0-9]+\.test\.utils/MediaSourceTestRunner/createPeriod/\#androidx\.media[0-9]+\.exoplayer\.source\.MediaSource\.MediaPeriodId\#long/PointingToDeclaration/
+WARN: Missing @param tag for parameter `factory` of function androidx\.media[0-9]+\.test\.utils/ExtractorAsserts/assertBehavior/\#androidx\.media[0-9]+\.test\.utils\.ExtractorAsserts\.ExtractorFactory\#java\.lang\.String\#androidx\.media[0-9]+\.test\.utils\.ExtractorAsserts\.AssertionConfig\#androidx\.media[0-9]+\.test\.utils\.ExtractorAsserts\.SimulationConfig/PointingToDeclaration/
+WARN: Missing @param tag for parameter `file` of function androidx\.media[0-9]+\.test\.utils/ExtractorAsserts/assertBehavior/\#androidx\.media[0-9]+\.test\.utils\.ExtractorAsserts\.ExtractorFactory\#java\.lang\.String\#androidx\.media[0-9]+\.test\.utils\.ExtractorAsserts\.AssertionConfig\#androidx\.media[0-9]+\.test\.utils\.ExtractorAsserts\.SimulationConfig/PointingToDeclaration/
+WARN: Missing @param tag for parameter `drmSessionManager` of function androidx\.media[0-9]+\.test\.utils/FakeMediaSource/createMediaPeriod/\#androidx\.media[0-9]+\.exoplayer\.source\.MediaSource\.MediaPeriodId\#androidx\.media[0-9]+\.exoplayer\.source\.TrackGroupArray\#androidx\.media[0-9]+\.exoplayer\.upstream\.Allocator\#androidx\.media[0-9]+\.exoplayer\.source\.MediaSourceEventListener\.EventDispatcher\#androidx\.media[0-9]+\.exoplayer\.drm\.DrmSessionManager\#androidx\.media[0-9]+\.exoplayer\.drm\.DrmSessionEventListener\.EventDispatcher\#androidx\.media[0-9]+\.datasource\.TransferListener/PointingToDeclaration/
+WARN: Missing @param tag for parameter `manifests` of function androidx\.media[0-9]+\.test\.utils/FakeTimeline/FakeTimeline/\#java\.lang\.Object\[\]\#androidx\.media[0-9]+\.test\.utils\.FakeTimeline\.TimelineWindowDefinition\.\.\./PointingToDeclaration/
+WARN: Missing @param tag for parameter `manifests` of function androidx\.media[0-9]+\.test\.utils/FakeTimeline/FakeTimeline/\#java\.lang\.Object\[\]\#androidx\.media[0-9]+\.exoplayer\.source\.ShuffleOrder\#androidx\.media[0-9]+\.test\.utils\.FakeTimeline\.TimelineWindowDefinition\.\.\./PointingToDeclaration/
+WARN: Missing @param tag for parameter `shuffleOrder` of function androidx\.media[0-9]+\.test\.utils/FakeTimeline/FakeTimeline/\#java\.lang\.Object\[\]\#androidx\.media[0-9]+\.exoplayer\.source\.ShuffleOrder\#androidx\.media[0-9]+\.test\.utils\.FakeTimeline\.TimelineWindowDefinition\.\.\./PointingToDeclaration/
+WARN: Missing @param tag for parameter `allocator` of function androidx\.media[0-9]+\.test\.utils/FakeMediaPeriod/createSampleStream/\#androidx\.media[0-9]+\.exoplayer\.upstream\.Allocator\#androidx\.media[0-9]+\.exoplayer\.source\.MediaSourceEventListener\.EventDispatcher\#androidx\.media[0-9]+\.exoplayer\.drm\.DrmSessionManager\#androidx\.media[0-9]+\.exoplayer\.drm\.DrmSessionEventListener\.EventDispatcher\#androidx\.media[0-9]+\.common\.Format\#java\.util\.List<androidx\.media[0-9]+\.test\.utils\.FakeSampleStream\.FakeSampleStreamItem>/PointingToDeclaration/
+WARN: Missing @param tag for parameter `timeline` of function androidx\.media[0-9]+\.test\.utils/TimelineAsserts/assertWindowTags/\#androidx\.media[0-9]+\.common\.Timeline\#java\.lang\.Object\.\.\./PointingToDeclaration/
+WARN: Missing @param tag for parameter `target` of function androidx\.media[0-9]+\.test\.utils/ActionSchedule\.Builder/sendMessage/\#androidx\.media[0-9]+\.exoplayer\.PlayerMessage\.Target\#long/PointingToDeclaration/
+WARN: Missing @param tag for parameter `sources` of function androidx\.media[0-9]+\.test\.utils/ActionSchedule\.Builder/setMediaSources/\#boolean\#androidx\.media[0-9]+\.exoplayer\.source\.MediaSource\.\.\./PointingToDeclaration/
+WARN: Missing @param tag for parameter `sources` of function androidx\.media[0-9]+\.test\.utils/ActionSchedule\.Builder/setMediaSources/\#int\#long\#androidx\.media[0-9]+\.exoplayer\.source\.MediaSource\.\.\./PointingToDeclaration/
 WARN: Failed to resolve `@see <a href="http://developer\.android\.com/guide/topics/ui/controls/pickers\.html">Pickers API`!
 WARN: Failed to resolve `@see <a href="http://developer\.android\.com/design/patterns/navigation\-drawer\.html">Navigation`!
 WARN: Missing @param tag for parameter `verificationMode` of function androidx\.test\.espresso\.intent/Intents/intended/\#org\.hamcrest\.Matcher<android\.content\.Intent>\#androidx\.test\.espresso\.intent\.VerificationMode/PointingToDeclaration/
 WARNING: link to @throws type AssertionFailedError does not resolve\. Is it from a package that the containing file does not import\? Is docs inherited to an un\-documented override function, but the exception class is not in scope in the inheriting class\? The general fix for these is to fully qualify the exception name,  e\.g\.`@throws java\.io\.IOException under some conditions\. This was observed in Throws\(root=CustomDocTag\(children=\[P\(children=\[Text\(body=if the given , children=\[\], params=\{\}\), DocumentationLink\(dri=org\.hamcrest/Matcher///PointingToDeclaration/, children=\[Text\(body=Matcher, children=\[\], params=\{\}\)\], params=\{\}\), Text\(body= did not match the expected number of recorded intents, children=\[\], params=\{\}\)\], params=\{\}\)\], params=\{\}, name=MARKDOWN_FILE\), name=AssertionFailedError, exceptionAddress=null\)\.`
 WARNING: link to @throws type AssertionFailedError does not resolve\. Is it from a package that the containing file does not import\? Is docs inherited to an un\-documented override function, but the exception class is not in scope in the inheriting class\? The general fix for these is to fully qualify the exception name,  e\.g\.`@throws java\.io\.IOException under some conditions\. This was observed in Throws\(root=CustomDocTag\(children=\[P\(children=\[Text\(body=if the given , children=\[\], params=\{\}\), DocumentationLink\(dri=org\.hamcrest/Matcher///PointingToDeclaration/, children=\[Text\(body=Matcher, children=\[\], params=\{\}\)\], params=\{\}\), Text\(body= did not match any or matched more than one of the recorded intents, children=\[\], params=\{\}\)\], params=\{\}\)\], params=\{\}, name=MARKDOWN_FILE\), name=AssertionFailedError, exceptionAddress=null\)\.`
 WARN: Missing @param tag for parameter `extras` of function androidx\.media[0-9]+\.session/MediaController/setMediaUri/\#android\.net\.Uri\#android\.os\.Bundle/PointingToDeclaration/
+WARN: Missing @param tag for parameter `trackType` of function androidx\.media[0-9]+\.common/Tracks/isTypeSupported/\#int\#boolean/PointingToDeclaration/
+WARN: Missing @param tag for parameter `adIndexInAdGroup` of function androidx\.media[0-9]+\.common/Timeline\.Period/getAdState/\#int\#int/PointingToDeclaration/
+WARN: Missing @param tag for parameter `dataSourceFactory` of function androidx\.media[0-9]+\.exoplayer\.drm/OfflineLicenseHelper/newWidevineInstance/\#java\.lang\.String\#boolean\#androidx\.media[0-9]+\.datasource\.DataSource\.Factory\#java\.util\.Map<java\.lang\.String,java\.lang\.String>\#androidx\.media[0-9]+\.exoplayer\.drm\.DrmSessionEventListener\.EventDispatcher/PointingToDeclaration/
+WARN: Missing @param tag for parameter `drmEventDispatcher` of function androidx\.media[0-9]+\.exoplayer\.hls/HlsMediaPeriod/HlsMediaPeriod/\#androidx\.media[0-9]+\.exoplayer\.hls\.HlsExtractorFactory\#androidx\.media[0-9]+\.exoplayer\.hls\.playlist\.HlsPlaylistTracker\#androidx\.media[0-9]+\.exoplayer\.hls\.HlsDataSourceFactory\#androidx\.media[0-9]+\.datasource\.TransferListener\#androidx\.media[0-9]+\.exoplayer\.drm\.DrmSessionManager\#androidx\.media[0-9]+\.exoplayer\.drm\.DrmSessionEventListener\.EventDispatcher\#androidx\.media[0-9]+\.exoplayer\.upstream\.LoadErrorHandlingPolicy\#androidx\.media[0-9]+\.exoplayer\.source\.MediaSourceEventListener\.EventDispatcher\#androidx\.media[0-9]+\.exoplayer\.upstream\.Allocator\#androidx\.media[0-9]+\.exoplayer\.source\.CompositeSequenceableLoaderFactory\#boolean\#int\#boolean\#androidx\.media[0-9]+\.exoplayer\.analytics\.PlayerId/PointingToDeclaration/
+WARN: Missing @param tag for parameter `metadataType` of function androidx\.media[0-9]+\.exoplayer\.hls/HlsMediaPeriod/HlsMediaPeriod/\#androidx\.media[0-9]+\.exoplayer\.hls\.HlsExtractorFactory\#androidx\.media[0-9]+\.exoplayer\.hls\.playlist\.HlsPlaylistTracker\#androidx\.media[0-9]+\.exoplayer\.hls\.HlsDataSourceFactory\#androidx\.media[0-9]+\.datasource\.TransferListener\#androidx\.media[0-9]+\.exoplayer\.drm\.DrmSessionManager\#androidx\.media[0-9]+\.exoplayer\.drm\.DrmSessionEventListener\.EventDispatcher\#androidx\.media[0-9]+\.exoplayer\.upstream\.LoadErrorHandlingPolicy\#androidx\.media[0-9]+\.exoplayer\.source\.MediaSourceEventListener\.EventDispatcher\#androidx\.media[0-9]+\.exoplayer\.upstream\.Allocator\#androidx\.media[0-9]+\.exoplayer\.source\.CompositeSequenceableLoaderFactory\#boolean\#int\#boolean\#androidx\.media[0-9]+\.exoplayer\.analytics\.PlayerId/PointingToDeclaration/
+WARN: Missing @param tag for parameter `playerId` of function androidx\.media[0-9]+\.exoplayer\.hls/HlsMediaPeriod/HlsMediaPeriod/\#androidx\.media[0-9]+\.exoplayer\.hls\.HlsExtractorFactory\#androidx\.media[0-9]+\.exoplayer\.hls\.playlist\.HlsPlaylistTracker\#androidx\.media[0-9]+\.exoplayer\.hls\.HlsDataSourceFactory\#androidx\.media[0-9]+\.datasource\.TransferListener\#androidx\.media[0-9]+\.exoplayer\.drm\.DrmSessionManager\#androidx\.media[0-9]+\.exoplayer\.drm\.DrmSessionEventListener\.EventDispatcher\#androidx\.media[0-9]+\.exoplayer\.upstream\.LoadErrorHandlingPolicy\#androidx\.media[0-9]+\.exoplayer\.source\.MediaSourceEventListener\.EventDispatcher\#androidx\.media[0-9]+\.exoplayer\.upstream\.Allocator\#androidx\.media[0-9]+\.exoplayer\.source\.CompositeSequenceableLoaderFactory\#boolean\#int\#boolean\#androidx\.media[0-9]+\.exoplayer\.analytics\.PlayerId/PointingToDeclaration/
+WARN: Missing @param tag for parameter `preciseStart` of function androidx\.media[0-9]+\.exoplayer\.hls\.playlist/HlsMediaPlaylist/HlsMediaPlaylist/\#int\#java\.lang\.String\#java\.util\.List<java\.lang\.String>\#long\#boolean\#long\#boolean\#int\#long\#int\#long\#long\#boolean\#boolean\#boolean\#androidx\.media[0-9]+\.common\.DrmInitData\#java\.util\.List<androidx\.media[0-9]+\.exoplayer\.hls\.playlist\.HlsMediaPlaylist\.Segment>\#java\.util\.List<androidx\.media[0-9]+\.exoplayer\.hls\.playlist\.HlsMediaPlaylist\.Part>\#androidx\.media[0-9]+\.exoplayer\.hls\.playlist\.HlsMediaPlaylist\.ServerControl\#java\.util\.Map<android\.net\.Uri,androidx\.media[0-9]+\.exoplayer\.hls\.playlist\.HlsMediaPlaylist\.RenditionReport>/PointingToDeclaration/
+WARN: Missing @param tag for parameter `partTargetDurationUs` of function androidx\.media[0-9]+\.exoplayer\.hls\.playlist/HlsMediaPlaylist/HlsMediaPlaylist/\#int\#java\.lang\.String\#java\.util\.List<java\.lang\.String>\#long\#boolean\#long\#boolean\#int\#long\#int\#long\#long\#boolean\#boolean\#boolean\#androidx\.media[0-9]+\.common\.DrmInitData\#java\.util\.List<androidx\.media[0-9]+\.exoplayer\.hls\.playlist\.HlsMediaPlaylist\.Segment>\#java\.util\.List<androidx\.media[0-9]+\.exoplayer\.hls\.playlist\.HlsMediaPlaylist\.Part>\#androidx\.media[0-9]+\.exoplayer\.hls\.playlist\.HlsMediaPlaylist\.ServerControl\#java\.util\.Map<android\.net\.Uri,androidx\.media[0-9]+\.exoplayer\.hls\.playlist\.HlsMediaPlaylist\.RenditionReport>/PointingToDeclaration/
+WARN: Missing @param tag for parameter `codecAdapterFactory` of function androidx\.media[0-9]+\.exoplayer\.mediacodec/MediaCodecRenderer/MediaCodecRenderer/\#int\#androidx\.media[0-9]+\.exoplayer\.mediacodec\.MediaCodecAdapter\.Factory\#androidx\.media[0-9]+\.exoplayer\.mediacodec\.MediaCodecSelector\#boolean\#float/PointingToDeclaration/
+WARN: Missing @param tag for parameter `dataSource` of function androidx\.media[0-9]+\.exoplayer\.offline/SegmentDownloader/getManifest/\#androidx\.media[0-9]+\.datasource\.DataSource\#androidx\.media[0-9]+\.datasource\.DataSpec\#boolean/PointingToDeclaration/
+WARN: Use @androidx\.annotation\.Nullable, not @org\.checkerframework\.checker\.nullness\.qual/Nullable///PointingToDeclaration/
+WARN: Failed to resolve `@see <a href="http://msdn\.microsoft\.com/en\-us/library/ee[0-9]+\(v=vs\.[0-9]+\)\.aspx">IIS Smooth`!
+WARN: Missing @param tag for parameter `type` of function androidx\.media[0-9]+\.exoplayer\.trackselection/RandomTrackSelection/RandomTrackSelection/\#androidx\.media[0-9]+\.common\.TrackGroup\#int\[\]\#int\#java\.util\.Random/PointingToDeclaration/
+WARN: Missing @param tag for parameter `trackIndices` of function androidx\.media[0-9]+\.exoplayer\.trackselection/MappingTrackSelector\.MappedTrackInfo/getAdaptiveSupport/\#int\#int\#int\[\]/PointingToDeclaration/
+WARN: Missing @param tag for parameter `params` of function androidx\.media[0-9]+\.exoplayer\.trackselection/DefaultTrackSelector/selectAllTracks/\#androidx\.media[0-9]+\.exoplayer\.trackselection\.MappingTrackSelector\.MappedTrackInfo\#int\[\]\[\]\[\]\#int\[\]\#androidx\.media[0-9]+\.exoplayer\.trackselection\.DefaultTrackSelector\.Parameters/PointingToDeclaration/
+WARN: Failed to resolve `@see <a href="http://en\.wikipedia\.org/wiki/Moving_average">Wiki: Moving average</a>`!
+WARN: Failed to resolve `@see <a href="http://en\.wikipedia\.org/wiki/Selection_algorithm">Wiki: Selection algorithm</a>`!
+WARN: Missing @param tag for parameter `decoderName` of function androidx\.media[0-9]+\.exoplayer\.video/DecoderVideoRenderer/canReuseDecoder/\#java\.lang\.String\#androidx\.media[0-9]+\.common\.Format\#androidx\.media[0-9]+\.common\.Format/PointingToDeclaration/
+WARN: Missing @param tag for parameter `flacStreamMetadata` of function androidx\.media[0-9]+\.extractor/FlacFrameReader/getFirstSampleNumber/\#androidx\.media[0-9]+\.extractor\.ExtractorInput\#androidx\.media[0-9]+\.extractor\.FlacStreamMetadata/PointingToDeclaration/
+WARN: Failed to resolve `@see <a href="http://www\.w[0-9]+\.org/TR/ttaf[0-9]+\-dfxp/">TTML specification</a>`!
+WARN: Failed to resolve `@see <a href="http://dev\.w[0-9]+\.org/html[0-9]+/webvtt">WebVTT specification</a>`!
+WARN: Failed to resolve `@see <a href="https://www\.w[0-9]+\.org/TR/CSS[0-9]+/cascade\.html">CSS Cascading</a>`!
+Did you mean <a href="https://www\.w[0-9]+\.org/TR/CSS[0-9]+/cascade\#html">CSS Cascading</a>\?
 WARN: Failed to resolve `@see <a href="https://developer\.android\.com/guide/topics/media/media\-routing">Media Routing</a>`!
 WARN: Missing @param tag for parameter `context` of function androidx\.mediarouter\.media/RemotePlaybackClient/RemotePlaybackClient/\#android\.content\.Context\#androidx\.mediarouter\.media\.MediaRouter\.RouteInfo/PointingToDeclaration/
 WARN: Failed to resolve `@see NavAction\.getDefaultArguments`!
diff --git a/docs-public/build.gradle b/docs-public/build.gradle
index 36bbd84..618b0c6 100644
--- a/docs-public/build.gradle
+++ b/docs-public/build.gradle
@@ -211,6 +211,29 @@
     docs("androidx.media2:media2-session:1.2.1")
     docs("androidx.media2:media2-widget:1.2.1")
     docs("androidx.media:media:1.6.0")
+    docs("androidx.media3:media3-cast:1.0.0-rc01")
+    docs("androidx.media3:media3-common:1.0.0-rc01")
+    docs("androidx.media3:media3-database:1.0.0-rc01")
+    docs("androidx.media3:media3-datasource:1.0.0-rc01")
+    docs("androidx.media3:media3-datasource-cronet:1.0.0-rc01")
+    docs("androidx.media3:media3-datasource-okhttp:1.0.0-rc01")
+    docs("androidx.media3:media3-datasource-rtmp:1.0.0-rc01")
+    docs("androidx.media3:media3-decoder:1.0.0-rc01")
+    docs("androidx.media3:media3-effect:1.0.0-rc01")
+    docs("androidx.media3:media3-exoplayer:1.0.0-rc01")
+    docs("androidx.media3:media3-exoplayer-dash:1.0.0-rc01")
+    docs("androidx.media3:media3-exoplayer-hls:1.0.0-rc01")
+    docs("androidx.media3:media3-exoplayer-ima:1.0.0-rc01")
+    docs("androidx.media3:media3-exoplayer-rtsp:1.0.0-rc01")
+    docs("androidx.media3:media3-exoplayer-smoothstreaming:1.0.0-rc01")
+    docs("androidx.media3:media3-exoplayer-workmanager:1.0.0-rc01")
+    docs("androidx.media3:media3-extractor:1.0.0-rc01")
+    docs("androidx.media3:media3-session:1.0.0-rc01")
+    docs("androidx.media3:media3-test-utils:1.0.0-rc01")
+    docs("androidx.media3:media3-test-utils-robolectric:1.0.0-rc01")
+    docs("androidx.media3:media3-transformer:1.0.0-rc01")
+    docs("androidx.media3:media3-ui:1.0.0-rc01")
+    docs("androidx.media3:media3-ui-leanback:1.0.0-rc01")
     docs("androidx.mediarouter:mediarouter:1.6.0-alpha01")
     docs("androidx.mediarouter:mediarouter-testing:1.6.0-alpha01")
     docs("androidx.metrics:metrics-performance:1.0.0-alpha03")
diff --git a/docs-tip-of-tree/build.gradle b/docs-tip-of-tree/build.gradle
index eb5be79..b491421 100644
--- a/docs-tip-of-tree/build.gradle
+++ b/docs-tip-of-tree/build.gradle
@@ -332,6 +332,7 @@
     samples(project(":wear:compose:compose-material-samples"))
     docs(project(":wear:compose:compose-navigation"))
     samples(project(":wear:compose:compose-navigation-samples"))
+    docs(project(":wear:compose:compose-ui-tooling"))
     docs(project(":wear:wear-input"))
     docs(project(":wear:wear-input-testing"))
     samples(project(":wear:wear-input-samples"))
diff --git a/fragment/fragment-ktx/build.gradle b/fragment/fragment-ktx/build.gradle
index 5c5799c..56cef7b 100644
--- a/fragment/fragment-ktx/build.gradle
+++ b/fragment/fragment-ktx/build.gradle
@@ -34,10 +34,10 @@
     api("androidx.collection:collection-ktx:1.1.0") {
         because "Mirror fragment dependency graph for -ktx artifacts"
     }
-    api("androidx.lifecycle:lifecycle-livedata-core-ktx:2.5.1") {
+    api("androidx.lifecycle:lifecycle-livedata-core-ktx:2.6.0-rc01") {
         because 'Mirror fragment dependency graph for -ktx artifacts'
     }
-    api("androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1")
+    api("androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.0-rc01")
     api("androidx.savedstate:savedstate-ktx:1.2.0") {
         because 'Mirror fragment dependency graph for -ktx artifacts'
     }
diff --git a/fragment/fragment/build.gradle b/fragment/fragment/build.gradle
index 63b13fb..516d576 100644
--- a/fragment/fragment/build.gradle
+++ b/fragment/fragment/build.gradle
@@ -30,10 +30,10 @@
     api("androidx.viewpager:viewpager:1.0.0")
     api("androidx.loader:loader:1.0.0")
     api("androidx.activity:activity:1.5.1")
-    api(projectOrArtifact(":lifecycle:lifecycle-runtime"))
-    api("androidx.lifecycle:lifecycle-livedata-core:2.5.1")
-    api(projectOrArtifact(":lifecycle:lifecycle-viewmodel"))
-    api("androidx.lifecycle:lifecycle-viewmodel-savedstate:2.5.1")
+    api("androidx.lifecycle:lifecycle-runtime:2.6.0-rc01")
+    api("androidx.lifecycle:lifecycle-livedata-core:2.6.0-rc01")
+    api("androidx.lifecycle:lifecycle-viewmodel:2.6.0-rc01")
+    api("androidx.lifecycle:lifecycle-viewmodel-savedstate:2.6.0-rc01")
     implementation("androidx.profileinstaller:profileinstaller:1.2.1")
     api("androidx.savedstate:savedstate:1.2.0")
     api("androidx.annotation:annotation-experimental:1.0.0")
@@ -58,7 +58,6 @@
     androidTestImplementation(project(":internal-testutils-runtime"), {
         exclude group: "androidx.fragment", module: "fragment"
     })
-    androidTestImplementation(projectOrArtifact(":lifecycle:lifecycle-viewmodel"))
 
     testImplementation(projectOrArtifact(":fragment:fragment"))
     testImplementation(libs.kotlinStdlib)
diff --git a/fragment/fragment/src/main/java/androidx/fragment/app/DefaultSpecialEffectsController.kt b/fragment/fragment/src/main/java/androidx/fragment/app/DefaultSpecialEffectsController.kt
index fb949ef..0fcc195 100644
--- a/fragment/fragment/src/main/java/androidx/fragment/app/DefaultSpecialEffectsController.kt
+++ b/fragment/fragment/src/main/java/androidx/fragment/app/DefaultSpecialEffectsController.kt
@@ -15,6 +15,8 @@
  */
 package androidx.fragment.app
 
+import android.animation.Animator
+import android.animation.AnimatorListenerAdapter
 import android.content.Context
 import android.graphics.Rect
 import android.util.Log
@@ -22,7 +24,6 @@
 import android.view.ViewGroup
 import android.view.animation.Animation
 import androidx.collection.ArrayMap
-import androidx.core.animation.doOnEnd
 import androidx.core.os.CancellationSignal
 import androidx.core.view.OneShotPreDrawListener
 import androidx.core.view.ViewCompat
@@ -181,19 +182,21 @@
             }
             val viewToAnimate = fragment.mView
             container.startViewTransition(viewToAnimate)
-            animator.doOnEnd {
-                container.endViewTransition(viewToAnimate)
-                if (isHideOperation) {
-                    // Specifically for hide operations with Animator, we can't
-                    // applyState until the Animator finishes
-                    operation.finalState.applyState(viewToAnimate)
+            animator.addListener(object : AnimatorListenerAdapter() {
+                override fun onAnimationEnd(anim: Animator) {
+                    container.endViewTransition(viewToAnimate)
+                    if (isHideOperation) {
+                        // Specifically for hide operations with Animator, we can't
+                        // applyState until the Animator finishes
+                        operation.finalState.applyState(viewToAnimate)
+                    }
+                    animationInfo.completeSpecialEffect()
+                    if (FragmentManager.isLoggingEnabled(Log.VERBOSE)) {
+                        Log.v(FragmentManager.TAG,
+                            "Animator from operation $operation has ended.")
+                    }
                 }
-                animationInfo.completeSpecialEffect()
-                if (FragmentManager.isLoggingEnabled(Log.VERBOSE)) {
-                    Log.v(FragmentManager.TAG,
-                        "Animator from operation $operation has ended.")
-                }
-            }
+            })
             animator.setTarget(viewToAnimate)
             animator.start()
             if (FragmentManager.isLoggingEnabled(Log.VERBOSE)) {
diff --git a/glance/glance-appwidget/src/main/res/raw/keep.xml b/glance/glance-appwidget/src/main/res/raw/glance_appwidget_keep.xml
similarity index 63%
rename from glance/glance-appwidget/src/main/res/raw/keep.xml
rename to glance/glance-appwidget/src/main/res/raw/glance_appwidget_keep.xml
index 481c104..fc799e7 100644
--- a/glance/glance-appwidget/src/main/res/raw/keep.xml
+++ b/glance/glance-appwidget/src/main/res/raw/glance_appwidget_keep.xml
@@ -1,7 +1,5 @@
 <?xml version="1.0" encoding="utf-8"?>
 <resources
     xmlns:tools="http://schemas.android.com/tools"
-    tools:keep="@layout/root_*"
-    tools:ignore="ResourceName">
+    tools:keep="@layout/root_*">
 </resources>
-
diff --git a/glance/glance-appwidget/src/test/kotlin/androidx/glance/appwidget/AppWidgetSessionTest.kt b/glance/glance-appwidget/src/test/kotlin/androidx/glance/appwidget/AppWidgetSessionTest.kt
index 6972d7c..fa117a9 100644
--- a/glance/glance-appwidget/src/test/kotlin/androidx/glance/appwidget/AppWidgetSessionTest.kt
+++ b/glance/glance-appwidget/src/test/kotlin/androidx/glance/appwidget/AppWidgetSessionTest.kt
@@ -45,7 +45,6 @@
 import org.junit.Assert.assertThrows
 import org.junit.Before
 import org.junit.Test
-import org.junit.Ignore
 import org.junit.runner.RunWith
 import org.robolectric.RobolectricTestRunner
 import org.robolectric.Shadows
@@ -81,7 +80,6 @@
         assertThat(widget.provideGlanceCalled.get()).isTrue()
     }
 
-    @Ignore("b/266518169")
     @Test
     fun provideGlanceEmitsIgnoreResultForNullContent() = runTest {
         // The session starts out with null content, so we can check that here.
diff --git a/gradle/verification-keyring.keys b/gradle/verification-keyring.keys
index 996388e..1d2f470 100644
--- a/gradle/verification-keyring.keys
+++ b/gradle/verification-keyring.keys
@@ -951,6 +951,64 @@
 -----END PGP PUBLIC KEY BLOCK-----
 
 
+pub    125A9EC9FAA91AE1
+sub    F2EA967B5B8FD0FC
+sub    F860F86A8AA8521B
+-----BEGIN PGP PUBLIC KEY BLOCK-----
+Version: BCPG v1.68
+
+mQENBFolWewBCACurWoOCed1W8Ut0tmqkSTpaz1AvPrYvZxmNqSVbxGjd8S/Bpxm
+uypKQ/KIF88a8QbePYa6e/I9g8HiuA2Bg91T9khc1mztXvutkiFNaldecg2rFHZK
+tHMtLq0PEH2WMaATcQBgpf3ueihE+R4E3L2t8s7lBhCeGw+FcFs5zuzf92Out3XK
+SbWvnkQyvNfbdVx3jleEfrmjT0zHWQyPNn0//0gO4rgtHoQUnkGcUQJmYTUW59Jv
+RjWDKTpDNTVzTitP0g2+Ru2H+suRFGTMIQMlgUUv5bRYpejpzvTlubGsrRAiK34D
+b5koeGwW/tB8crJS4SqwGLMYRQjXRu2qO9KjABEBAAG5Ag0EWmil5AEQAN9XlvNC
+mUso25a5GN/hvKTlWQFHcOcpKUoJG4DYgtgMAX+3gNJfA5pvntsBgsBjt/8nS9gr
+rOLqaYknJTQ+tzsTjiLC2e36+aK2Jr5RsRSIWTgM94P+QuMNX8DiuuMq5JFFhCM1
+IJE17az4Iuay9ZMA9nCVolRSSepBWn2kCiacQg6YFQnxhvHyjpNuAvALoVyZ8AJm
+uuwOGMnB4qio2SROrHkV6ZLXPQak48yLFpswbhxQKzAsiG/sfoe/nO8yAUJQSAEd
+2yXDylaPHBXsnjI8HvQpGmPieCQMjlJenwMK9Ewqtxnuprklh6+/324MjBCanBo4
+OSiqC48GKWqtL2uYOqqdbuLc4SN7pLWBuSBDU0/4vwUS6mjyIwcuOK1f0SUBpUqP
+5U2iqFURn7x6E5cdDtfCagP3bFrAktkUcbyET+EgdFnYMmpoCqXGFPvPGmwLHFy9
+ELF7+bdqqNgEJviWE66V2feePveujqUFWqHCZ+X6DvwoQyZK9Z8ojoAWFi7AujJD
+BtaB99QeKYOBqJb1DymM1etMUEBRnP7Xxj0rIQmse579vwXrbRFlMV95cQne/LFH
+2jZJ3ORO9qpGetazXJv95e7RzRxLR/8qeXUU6oiyyrSaXABVTrVpyKISGlfhwlN+
+tq+oP9WVYMU4E6s1bE3n44PwwP7nH0KFc2NPABEBAAGJA2wEGAEIACAWIQRU9uWU
+kj69BPK4hgYSWp7J+qka4QUCWmil5AIbAgJACRASWp7J+qka4cF0IAQZAQgAHRYh
+BEeWssB9d/temlaYYfLqlntbj9D8BQJaaKXkAAoJEPLqlntbj9D8jwMP/jui0ujh
+1NEUJVvFNXvbeITVE22aPb/6f1ccEUK/tH2J7QPFbu885pKpw1HagYswlHpsTEsL
+yjia2Xc55N3rpA4C5in485/nCoxt2aWnkkNKmQcsU/+Nuj2WjaPsqtn/mCyhrUUg
+SK0+PG6nLK5ekUWbZaaHxITUpnOcmihRbvqBiC4vb8nN97BkJ4aXcNymDKlWjVKv
+V42Qk2nRwl+hvczhT3+FPRZDa3ToxQ6N2kyl6uudK5hYg+JcIgVKBpWIMp7vu1LN
+Mscuv+KbjbyTqNMbkTiT0L1tHuQ9ST8ouPx+pCxUfjlqc/9yS5CC/AvtG0XkO8ZH
+7fmQiGVVS6VKVZmm5AP0HVpatVVcKq3fIpa5f82dP6YM586ib7iXBfvsH3mEB6Sg
+m92d1c3uLrki+CIEZGdgEjOAhovGZUzdxt12Yh5T0Sk/mJ/V6fIRkORSU8Uek60H
+Eu15C6KbbwjaMBjurHpo/3aCnitxByNZubDRLFi+LG0rG8zbPBQgQKFhYb+8MExP
+hwuzd45VQdK323leHobPgkOY4hlYPr/RQEy8Jl2kfm3Q/mvvIK2aLwlrGwmTR7bx
+d8xQZvvbr9Fs5noMiU7dTD5isQMC63RkSvhAXe2mzYoT2umooO5HYJrgDm4lQuH8
+SkWIYoUxVHpG+I8lxD6R9Vod0Ijhyg/2EXvjxckIAKkJUXxygEmcHAXCzJ6YmpE8
+8nUctDM6puofYoZqCypEO/JUfatJITNj7Cf2vPWB33d0g3k4+dZKlY2rXNIene64
+a57XbEqx8G6/fAPsHKDjFmGU6CLPSPRwkERXhaGdtkFCXtT/WHctjpJCU3XDGW7Z
+1OqTdsFrxiR6JeRlawjQrXdERmdxIhK/I1uaAYZOfRfQt2OTFvC01IEWZhHH8+SO
+28zFI5wa5voDs/+Wv+hG/YgAmxquwXhlcuSEBW3hvLVIyXTDPXggSmRoeLFLy+xU
+YnryJHIYtus+IQDFY7YZUH+MsSiwrlcc/O8t/c6mxM6HbsDCIExc3IZlZQZlXVq5
+AQ0EWiVZ7AEIANeF3UT0VXZcDg0wshnO8r+nuqikhn3VeBR4T0PoctLnGgucjr9x
+h6E9FXIDmxAiYvhAeskt5bbN8a6gDQ2WKvGOwmpO24/crDBlrX5FOvy10j1lb7n1
+uCQFHtRbeDX/WUWYLlb9or33/QXP/h+5RVlHun3lzw53yBaPGBQUdxl6veJNuJMM
+mvTXGpVl5Dg19ijLoktedxfboChSWZ1k0sziTMOO/J07SzcLd+IdAhN1L/O2awoo
+UPz2Oo1RMvEQ14v08aW2USKpkOprrTMRJJ6MF5leBMDWQ48m6DzTnccLUQBpQSbU
+Kh3C1pw6MINm3vCD7+x5FO+B+9x1bhcHhbUAEQEAAYkBPAQYAQgAJhYhBFT25ZSS
+Pr0E8riGBhJansn6qRrhBQJaJVnsAhsMBQkDwmcAAAoJEBJansn6qRrhqRAH/iOP
+CnttWmCTNvZhiZMAPPZvJWNNU5q2dxip2x/Lz6/qUUAMEbVDNEP6ITU99OZHWJ3g
+xs640+NzjCASM4BzX7T2vGAyVtm4M7oYeeTdhDoaITANKkRzBJ2z9B/gZkXexlCk
+7zlLS7TrqLhGkyNaOAIz73ygtPp12TTOFzQF8CG1zGEH3veWbdehBqB5Qg3QJNNG
+njI+gJdRefMhHsRG4rQg0qs3jilQ6RMeXWZ6Ncz/xuTMYviyxhicO01w3PAA/3XI
+x0gGYEb/uYbga6qTBkphYK9nS5N+tkXp7fA397mVrPj1icjAXIIANqRFX0NLqinx
+4D0hnpwfN9FlrGaJAjU=
+=a9iN
+-----END PGP PUBLIC KEY BLOCK-----
+
+
 pub    14A84C976D265B25
 uid    Rafi Kamal <rafikamal93@gmail.com>
 
diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml
index 49b6a25..b3de043 100644
--- a/gradle/verification-metadata.xml
+++ b/gradle/verification-metadata.xml
@@ -29,15 +29,9 @@
          <trust group="^com[.]android($|([.].*))" regex="true" reason="b/215430394"/>
       </trusted-artifacts>
       <trusted-keys>
-         <trusted-key id="00089ee8c3afa95a854d0f1df800dd0933ecf7f7">
-            <trusting group="com.google.guava"/>
-            <trusting group="com.google.guava" name="guava"/>
-         </trusted-key>
+         <trusted-key id="00089ee8c3afa95a854d0f1df800dd0933ecf7f7" group="com.google.guava"/>
          <trusted-key id="019082bc00e0324e2aef4cf00d3b328562a119a7" group="org.openjdk.jmh"/>
-         <trusted-key id="042b29e928995b9db963c636c7ca19b7b620d787">
-            <trusting group="org.apache.maven"/>
-            <trusting group="org.apache.maven" name="maven-ant-tasks"/>
-         </trusted-key>
+         <trusted-key id="042b29e928995b9db963c636c7ca19b7b620d787" group="org.apache.maven"/>
          <trusted-key id="04543577d6a9cc626239c50c7ecbd740ff06aeb5">
             <trusting group="com.sun.activation"/>
             <trusting group="com.sun.istack"/>
@@ -58,10 +52,7 @@
          <trusted-key id="0cde80149711eb46dff17ae421a24b3f8b0f594a" group="org.apache"/>
          <trusted-key id="0d35d3f60078655126908e8af3d1600878e85a3d" group="io.netty"/>
          <trusted-key id="0d5d634755737a19abbe2930d4da5eab3cd7e958" group="com.google.devtools.ksp"/>
-         <trusted-key id="0d8f8561aa7a5dd20bae27043c0a8f4744f37328">
-            <trusting group="com.github.ben-manes.caffeine"/>
-            <trusting group="com.github.ben-manes.caffeine" name="caffeine"/>
-         </trusted-key>
+         <trusted-key id="0d8f8561aa7a5dd20bae27043c0a8f4744f37328" group="com.github.ben-manes.caffeine"/>
          <trusted-key id="0eb9d7c468f97e44051d650ad73c68ee4152c255" group="com.google.dagger"/>
          <trusted-key id="0f07d1201bddab67cfb84eb479752db6c966f0b8" group="com.google.android"/>
          <trusted-key id="10f3c7a02eca55e502badcf3991efb94db91127d" group="org.ow2"/>
@@ -75,25 +66,16 @@
          <trusted-key id="147b691a19097624902f4ea9689cbe64f4bc997f" group="^org[.]mockito($|([.].*))" regex="true"/>
          <trusted-key id="151ba00a46886a5f95441a0f5d67bffcba1f9a39" group="com.google.gradle"/>
          <trusted-key id="1597ab231b7add7e14b1d9c43f00db67ae236e2e" group="org.conscrypt"/>
-         <trusted-key id="160a7a9cf46221a56b06ad64461a804f2609fd89">
-            <trusting group="com.github.shyiko.klob"/>
-            <trusting group="com.github.shyiko.klob" name="klob"/>
-         </trusted-key>
+         <trusted-key id="160a7a9cf46221a56b06ad64461a804f2609fd89" group="com.github.shyiko.klob"/>
          <trusted-key id="187366a3ffe6bf8f94b9136a9987b20c8f6a3064" group="com.google.protobuf"/>
          <trusted-key id="190d5a957ff22273e601f7a7c92c5fec70161c62">
             <trusting group="org.apache"/>
             <trusting group="org.codehaus.mojo"/>
          </trusted-key>
          <trusted-key id="19beab2d799c020f17c69126b16698a4adf4d638" group="org.checkerframework"/>
-         <trusted-key id="1bc86444bbd2a24c3a40904a438e9634a2319637">
-            <trusting group="co.nstant.in"/>
-            <trusting group="co.nstant.in" name="cbor"/>
-         </trusted-key>
+         <trusted-key id="1bc86444bbd2a24c3a40904a438e9634a2319637" group="co.nstant.in"/>
          <trusted-key id="1cb7a3dbc99b562d69bfdfedae7af7ae095eb290" group="net.saff.checkmark"/>
-         <trusted-key id="1d0a8b5e77c678a7c724445abf984b4145ea13f7">
-            <trusting group="com.squareup"/>
-            <trusting group="com.squareup" name="javapoet"/>
-         </trusted-key>
+         <trusted-key id="1d0a8b5e77c678a7c724445abf984b4145ea13f7" group="com.squareup"/>
          <trusted-key id="1d9aa7f9e1e2824728b8cd1794b291aef984a085">
             <trusting group="io.reactivex"/>
             <trusting group="io.reactivex.rxjava2"/>
@@ -102,29 +84,16 @@
          <trusted-key id="1dbb44e80f61493d6369b5fb95c15058a5eda4f1">
             <trusting group="com.google.gradle"/>
             <trusting group="com.google.protobuf"/>
-            <trusting group="com.google.protobuf" name="protobuf-gradle-plugin"/>
          </trusted-key>
-         <trusted-key id="1f47744c9b6e14f2049c2857f1f111af65925306">
-            <trusting group="io.github.classgraph"/>
-            <trusting group="io.github.classgraph" name="classgraph"/>
-         </trusted-key>
+         <trusted-key id="1f47744c9b6e14f2049c2857f1f111af65925306" group="io.github.classgraph"/>
          <trusted-key id="1fa37fbe4453c1073e7ef61d6449005f96bc97a3" group="de.undercouch"/>
          <trusted-key id="20723a6399bc060154283b37cfae163b64ac9189" group="org.jetbrains.skiko"/>
-         <trusted-key id="22b79f456b06f4e75b8b579db57bd58ef6d0a713">
-            <trusting group="com.google.protobuf"/>
-            <trusting group="com.google.protobuf" name="protoc"/>
-         </trusted-key>
+         <trusted-key id="22b79f456b06f4e75b8b579db57bd58ef6d0a713" group="com.google.protobuf"/>
          <trusted-key id="24d04176586361fda94ee0315f7786df73e61f56" group="com.google.devtools.ksp"/>
          <trusted-key id="26063b04869f7d235ccc057447586a1b75ef0de5" group="com.squareup.wire"/>
          <trusted-key id="263923711ef4fe3f3f0c28af11509ed50ec155e6" group="org.reactivestreams"/>
-         <trusted-key id="2a4f55d9cda5877731fbe7466eff5ef5523052d4">
-            <trusting group="com.github.tschuchortdev"/>
-            <trusting group="com.github.tschuchortdev" name="kotlin-compile-testing"/>
-         </trusted-key>
-         <trusted-key id="2bab4466b44f54f8f99bbbdd5ed22f661bbf0acc">
-            <trusting group="com.almworks.sqlite4java"/>
-            <trusting group="com.almworks.sqlite4java" name="sqlite4java"/>
-         </trusted-key>
+         <trusted-key id="2a4f55d9cda5877731fbe7466eff5ef5523052d4" group="com.github.tschuchortdev"/>
+         <trusted-key id="2bab4466b44f54f8f99bbbdd5ed22f661bbf0acc" group="com.almworks.sqlite4java"/>
          <trusted-key id="2bcbdd0f23ea1cafcc11d4860374cf2e8dd1bdfd">
             <trusting group="net.java"/>
             <trusting group="org.codehaus"/>
@@ -141,10 +110,7 @@
          <trusted-key id="3051d45031e13516a6e8faff280d66a55f5316c5" group="org.bitbucket.b_c"/>
          <trusted-key id="314fe82e5a4c5377bca2edec5208812e1e4a6db0" group="com.gradle"/>
          <trusted-key id="31bae2e51d95e0f8ad9b7bcc40a3c4432bd7308c" group="com.googlecode.juniversalchardet"/>
-         <trusted-key id="31fae244a81d64507b47182e1b2718089ce964b8">
-            <trusting group="com.thoughtworks.qdox"/>
-            <trusting group="com.thoughtworks.qdox" name="qdox"/>
-         </trusted-key>
+         <trusted-key id="31fae244a81d64507b47182e1b2718089ce964b8" group="com.thoughtworks.qdox"/>
          <trusted-key id="3288b8be8512d6c0ca185268c51e6cbc7ff46f0b">
             <trusting group="com.google.auto"/>
             <trusting group="com.google.auto.service"/>
@@ -196,20 +162,15 @@
          <trusted-key id="4f8fec6785f611d9a712ea2734918b7d3969d2f5" group="com.google.dagger"/>
          <trusted-key id="517b94f8d0a46317a28d8ab30da8a5ec02d11ead" group="net.sf.jopt-simple"/>
          <trusted-key id="51b52dc5dd452f92be342cc2858fc4c4f43856a3" group="xerces"/>
+         <trusted-key id="54f6e594923ebd04f2b88606125a9ec9faa91ae1" group="io.antmedia" name="rtmp-client"/>
          <trusted-key id="55e770230e69cc6de143fb5b62c82e50836eb3ee" group="com.github.gundy"/>
-         <trusted-key id="56b505dc8a29c69138a430b9429c8816dea04cdb">
-            <trusting group="org.xerial"/>
-            <trusting group="org.xerial" name="sqlite-jdbc"/>
-         </trusted-key>
+         <trusted-key id="56b505dc8a29c69138a430b9429c8816dea04cdb" group="org.xerial"/>
          <trusted-key id="5719e50eac5a4b1dd390b72c2a742740e08e7f8d" group="org.antlr"/>
          <trusted-key id="5767f9cde920750621875079a40e24b5b408dbd5" group="org.robolectric"/>
          <trusted-key id="5897253bea3046aeea95a067e93671c7272b7b3f" group="org.jdom"/>
          <trusted-key id="58e79b6abc762159dc0b1591164bd2247b936711" group="junit"/>
          <trusted-key id="5ce325996a35213326ae2c68912d2c0eccda55c0" group="com.google.errorprone"/>
-         <trusted-key id="5dceb296690bd5101756f2441f8cf885d537a431">
-            <trusting group="com.nhaarman.mockitokotlin2"/>
-            <trusting group="com.nhaarman.mockitokotlin2" name="mockito-kotlin"/>
-         </trusted-key>
+         <trusted-key id="5dceb296690bd5101756f2441f8cf885d537a431" group="com.nhaarman.mockitokotlin2"/>
          <trusted-key id="5fa41c402006eac55d72aafd99ce9d9f22dc5c99" group="org.json"/>
          <trusted-key id="600ea202b1ec682f4a788e5aac7a514bc9f9bb70" group="io.opencensus"/>
          <trusted-key id="6214760097dc5cfad0175ac2c9fbaa83a8753994">
@@ -221,16 +182,10 @@
             <trusting group="com.fasterxml.woodstox"/>
             <trusting group="org.codehaus.woodstox"/>
          </trusted-key>
-         <trusted-key id="628462a5eaba59d57e99ae5a840b2bf6da8ed8c8">
-            <trusting group="com.google.android.apps.common.testing.accessibility.framework"/>
-            <trusting group="com.google.android.apps.common.testing.accessibility.framework" name="accessibility-test-framework"/>
-         </trusted-key>
+         <trusted-key id="628462a5eaba59d57e99ae5a840b2bf6da8ed8c8" group="com.google.android.apps.common.testing.accessibility.framework"/>
          <trusted-key id="635ee627345f3c1dd422b2e207d3516820bcf6b1" group="com.github.ben-manes.caffeine"/>
          <trusted-key id="64b9b09f164aa0bf88742eb61188b69f6d6259ca" group="com.google.accompanist"/>
-         <trusted-key id="666a4692ce11b7b3f4eb7b3410066a9707090cf9">
-            <trusting group="org.javassist"/>
-            <trusting group="org.javassist" name="javassist"/>
-         </trusted-key>
+         <trusted-key id="666a4692ce11b7b3f4eb7b3410066a9707090cf9" group="org.javassist"/>
          <trusted-key id="682f765eea718d250bbdb2f1685c46769dbb5e5d" group="com.squareup" name="kotlinpoet"/>
          <trusted-key id="694621a7227d8d5289699830abe9f3126bb741c1">
             <trusting group="com.google.guava"/>
@@ -271,32 +226,20 @@
          <trusted-key id="7615ad56144df2376f49d98b1669c4bb543e0445" group="com.google.errorprone"/>
          <trusted-key id="7616eb882daf57a11477aaf559a252fb1199d873" group="com.google.code.findbugs"/>
          <trusted-key id="78ab011fa6e5907950ea3e2747dcfc2a59f59b5b" group="io.outfoxx"/>
-         <trusted-key id="79156e0351af8604de9b186b09a79e1e15a04694">
-            <trusting group="org.vafer"/>
-            <trusting group="org.vafer" name="jdependency"/>
-         </trusted-key>
+         <trusted-key id="79156e0351af8604de9b186b09a79e1e15a04694" group="org.vafer"/>
          <trusted-key id="7c669810892cbd3148fa92995b05ccde140c2876" group="org.eclipse.jgit"/>
          <trusted-key id="7cb548acfe3d47e92afa566dc29b11246382a4d7" group="com.charleskorn.kaml"/>
-         <trusted-key id="7cd52b5a8295137c88fb5748dddafa7674e54418">
-            <trusting group="org.testng"/>
-            <trusting group="org.testng" name="testng"/>
-         </trusted-key>
+         <trusted-key id="7cd52b5a8295137c88fb5748dddafa7674e54418" group="org.testng"/>
          <trusted-key id="7e22d50a7ebd9d2cd269b2d4056aca74d46000bf" group="io.netty"/>
          <trusted-key id="7f36e793ae3252e5d9e9b98fee9e7dc9d92fc896" group="com.google.errorprone"/>
          <trusted-key id="7faa0f2206de228f0db01ad741321490758aad6f" group="org.codehaus.groovy"/>
          <trusted-key id="808d78b17a5a2d7c3668e31fbffc9b54721244ad" group="org.apache.commons"/>
-         <trusted-key id="80f6d6b0d90c6747753344cab5a9e81b565e89e0">
-            <trusting group="org.tomlj"/>
-            <trusting group="org.tomlj" name="tomlj"/>
-         </trusted-key>
+         <trusted-key id="80f6d6b0d90c6747753344cab5a9e81b565e89e0" group="org.tomlj"/>
          <trusted-key id="8254180bfc943b816e0b5e2e5e2f2b3d474efe6b" group="it.unimi.dsi"/>
          <trusted-key id="82f833963889d7ed06f1e4dc6525fd70cc303655" group="org.codehaus.mojo"/>
          <trusted-key id="835a685c8c6f49c54980e5caf406f31bc1468eba" group="org.jcodec"/>
          <trusted-key id="842afb86375d805422835bfd82b5574242c20d6f" group="org.antlr"/>
-         <trusted-key id="8569c95cadc508b09fe90f3002216ed811210daa">
-            <trusting group="io.github.detekt.sarif4k"/>
-            <trusting group="io.github.detekt.sarif4k" name="sarif4k"/>
-         </trusted-key>
+         <trusted-key id="8569c95cadc508b09fe90f3002216ed811210daa" group="io.github.detekt.sarif4k"/>
          <trusted-key id="8756c4f765c9ac3cb6b85d62379ce192d401ab61">
             <trusting group="com.github.ajalt"/>
             <trusting group="com.github.javaparser"/>
@@ -310,15 +253,12 @@
             <trusting group="com.fasterxml"/>
             <trusting group="com.fasterxml.jackson"/>
             <trusting group="com.fasterxml.jackson.core"/>
-            <trusting group="com.fasterxml.jackson.module" name="jackson-module-kotlin"/>
+            <trusting group="com.fasterxml.jackson.module"/>
          </trusted-key>
          <trusted-key id="8df3b0aa23ed78be5233f6c2dea3d207428ef16d" group="com.linkedin.dexmaker"/>
          <trusted-key id="8e3a02905a1ae67e7b0f9acd3967d4eda591b991" group="org.jetbrains.kotlinx" name="kotlinx-html-jvm"/>
          <trusted-key id="8f9a3c6d105b9f57844a721d79e193516be7998f" group="org.dom4j" name="dom4j"/>
-         <trusted-key id="908366594e746bf3c449f5622be5d98f751f4136">
-            <trusting group="org.pcollections"/>
-            <trusting group="org.pcollections" name="pcollections"/>
-         </trusted-key>
+         <trusted-key id="908366594e746bf3c449f5622be5d98f751f4136" group="org.pcollections"/>
          <trusted-key id="90ee19787a7bcf6fd37a1e9180c08b1c29100955">
             <trusting group="com.jakewharton.android.repackaged"/>
             <trusting group="com.squareup" name="javawriter"/>
@@ -350,14 +290,8 @@
             <trusting group="com.google.code.findbugs" name="jsr305"/>
             <trusting group="org.jacoco"/>
          </trusted-key>
-         <trusted-key id="a4fd709cc4b0515f2e6af04e218fa0f6a941a037">
-            <trusting group="com.github.kevinstern"/>
-            <trusting group="com.github.kevinstern" name="software-and-algorithms"/>
-         </trusted-key>
-         <trusted-key id="a5b2dde7843e7ca3e8caabd02383163bc40844fd">
-            <trusting group="org.reactivestreams"/>
-            <trusting group="org.reactivestreams" name="reactive-streams"/>
-         </trusted-key>
+         <trusted-key id="a4fd709cc4b0515f2e6af04e218fa0f6a941a037" group="com.github.kevinstern"/>
+         <trusted-key id="a5b2dde7843e7ca3e8caabd02383163bc40844fd" group="org.reactivestreams"/>
          <trusted-key id="a5bd02b93e7a40482eb1d66a5f69ad087600b22c" group="org.ow2.asm"/>
          <trusted-key id="a6d6c97108b8585f91b158748671a8df71296252" group="^com[.]squareup($|([.].*))" regex="true"/>
          <trusted-key id="a7892505cf1a58076453e52d7999befba1039e8b" group="net.bytebuddy"/>
@@ -368,66 +302,35 @@
             <trusting group="com.pinterest.ktlint"/>
             <trusting group="^com[.]pinterest($|([.].*))" regex="true"/>
          </trusted-key>
-         <trusted-key id="ae2b18e836c5f30687f37efdcc6346f2ce3872d9">
-            <trusting group="com.google.protobuf"/>
-            <trusting group="com.google.protobuf" name="protobuf-java"/>
-         </trusted-key>
+         <trusted-key id="ae2b18e836c5f30687f37efdcc6346f2ce3872d9" group="com.google.protobuf"/>
          <trusted-key id="ae9e53fc28ff2ab1012273d0bf1518e0160788a2" group="org.apache" name="apache"/>
          <trusted-key id="afa2b1823fc021bfd08c211fd5f4c07a434ab3da" group="com.squareup"/>
          <trusted-key id="afcc4c7594d09e2182c60e0f7a01b0f236e5430f" group="com.google.code.gson"/>
          <trusted-key id="b02335aa54ccf21e52bbf9abd9c565aa72ba2fdd" group="io.grpc"/>
-         <trusted-key id="b252e5789636134a311e4463971b04f56669b805">
-            <trusting group="com.google.jsilver"/>
-            <trusting group="com.google.jsilver" name="jsilver"/>
-         </trusted-key>
-         <trusted-key id="b41089a2da79b0fa5810252872385ff0af338d52">
-            <trusting group="org.threeten"/>
-            <trusting group="org.threeten" name="threeten-extra"/>
-            <trusting group="org.threeten" name="threetenbp"/>
-         </trusted-key>
+         <trusted-key id="b252e5789636134a311e4463971b04f56669b805" group="com.google.jsilver"/>
+         <trusted-key id="b41089a2da79b0fa5810252872385ff0af338d52" group="org.threeten"/>
          <trusted-key id="b46dc71e03feeb7f89d1f2491f7a8f87b9d8f501" group="org.jetbrains.trove4j"/>
-         <trusted-key id="b47034c19c9b1f3dc3702f8d476634a4694e716a">
-            <trusting group="com.googlecode.java-diff-utils"/>
-            <trusting group="com.googlecode.java-diff-utils" name="diffutils"/>
-         </trusted-key>
+         <trusted-key id="b47034c19c9b1f3dc3702f8d476634a4694e716a" group="com.googlecode.java-diff-utils"/>
          <trusted-key id="b4ac8cdc141af0ae468d16921da784ccb5c46dd5" group="net.bytebuddy"/>
-         <trusted-key id="b6e73d84ea4fcc47166087253faad2cd5ecbb314">
-            <trusting group="org.apache.commons"/>
-            <trusting group="org.apache.commons" name="commons-parent"/>
-         </trusted-key>
+         <trusted-key id="b6e73d84ea4fcc47166087253faad2cd5ecbb314" group="org.apache.commons"/>
          <trusted-key id="b801e2f8ef035068ec1139cc29579f18fa8fd93b" group="com.google.j2objc"/>
          <trusted-key id="b9cca13c59f21c6ce841a8d1a4b1a03fb9c2ce23" group="com.squareup.leakcanary"/>
          <trusted-key id="ba926f64ca647b6d853a38672e2010f8a7ff4a41" group="org.apache"/>
          <trusted-key id="bae5c184e3b70cb15617700598fe03a974ce0a0b" group="org.jetbrains.kotlin"/>
-         <trusted-key id="bc87a3fd0a54480f0badbebd21939ff0ca2a6567">
-            <trusting group="commons-codec"/>
-            <trusting group="commons-codec" name="commons-codec"/>
-         </trusted-key>
+         <trusted-key id="bc87a3fd0a54480f0badbebd21939ff0ca2a6567" group="commons-codec"/>
          <trusted-key id="bcc135fc7ed8214f823d73e97fe9900f412d622e" group="com.google.flatbuffers"/>
          <trusted-key id="bdb5fa4fe719d787fb3d3197f6d4a1d411e9d1ae" group="com.google.guava"/>
          <trusted-key id="be685132afd2740d9095f9040cc0b712fee75827" group="org.assertj"/>
-         <trusted-key id="c5aa57f4a38eba7b7f9156ddab2da4527f6ffc0b">
-            <trusting group="com.squareup" name="kotlinpoet"/>
-            <trusting group="com.squareup" name="kotlinpoet-javapoet"/>
-         </trusted-key>
-         <trusted-key id="c6f7d1c804c821f49af3bfc13ad93c3c677a106e">
-            <trusting group="io.perfmark"/>
-            <trusting group="io.perfmark" name="perfmark-api"/>
-         </trusted-key>
-         <trusted-key id="c70b844f002f21f6d2b9c87522e44ac0622b91c3">
-            <trusting group="com.beust"/>
-            <trusting group="com.beust" name="jcommander"/>
-         </trusted-key>
+         <trusted-key id="c5aa57f4a38eba7b7f9156ddab2da4527f6ffc0b" group="com.squareup"/>
+         <trusted-key id="c6f7d1c804c821f49af3bfc13ad93c3c677a106e" group="io.perfmark"/>
+         <trusted-key id="c70b844f002f21f6d2b9c87522e44ac0622b91c3" group="com.beust"/>
          <trusted-key id="c7be5bcc9fec15518cfda882b0f3710fa64900e7">
             <trusting group="com.google.auto"/>
             <trusting group="com.google.auto.service"/>
             <trusting group="com.google.auto.value"/>
             <trusting group="com.google.code.gson"/>
          </trusted-key>
-         <trusted-key id="c8741082ff1b0ee96bcabeec10ae8966a146e8be">
-            <trusting group="com.google.crypto.tink"/>
-            <trusting group="com.google.crypto.tink" name="tink-android"/>
-         </trusted-key>
+         <trusted-key id="c8741082ff1b0ee96bcabeec10ae8966a146e8be" group="com.google.crypto.tink"/>
          <trusted-key id="c888b9955815ea83b48531784896f7312a5ace4d">
             <trusting group="com.google.gradle"/>
             <trusting group="com.google.protobuf"/>
@@ -435,14 +338,10 @@
          </trusted-key>
          <trusted-key id="c9a503282182ec4ecdb914ea102e05d8da6c286d" group="javax.xml.bind" name="jaxb-api"/>
          <trusted-key id="cacfbd4755a2fc78709bdd92be096e29edb8d141" group="net.sf.proguard"/>
-         <trusted-key id="cb3190ca7842439e57f3712e44ce7bf2825ea2cd">
-            <trusting group="com.ibm.icu"/>
-            <trusting group="com.ibm.icu" name="icu4j"/>
-         </trusted-key>
+         <trusted-key id="cb3190ca7842439e57f3712e44ce7bf2825ea2cd" group="com.ibm.icu"/>
          <trusted-key id="cc4483cd6a3eb2939b948667a1b4460d8ba7b9af" group="org.mockito"/>
          <trusted-key id="cd5464315f0b98c77e6e8ecd9daadc1c9fcc82d0">
             <trusting group="commons-cli"/>
-            <trusting group="commons-cli" name="commons-cli"/>
             <trusting group="org.apache.commons"/>
          </trusted-key>
          <trusted-key id="ce8075a251547bee249bc151a2115ae15f6b8b72">
@@ -453,20 +352,14 @@
          <trusted-key id="d433f9c895710db8ab087fa6b7c3b43d18eaa8b7" group="org.codehaus.mojo"/>
          <trusted-key id="d477d51812e692011db11e66a6ea2e2bf22e0543" group="io.github.java-diff-utils"/>
          <trusted-key id="d4c89ea4aaf455fd88b22087efe8086f9e93774e" group="junit"/>
-         <trusted-key id="d4fb0b7b5e8c18c993a8a386eb9d04a9a679fe18">
-            <trusting group="com.uber.nullaway"/>
-            <trusting group="com.uber.nullaway" name="nullaway"/>
-         </trusted-key>
+         <trusted-key id="d4fb0b7b5e8c18c993a8a386eb9d04a9a679fe18" group="com.uber.nullaway"/>
          <trusted-key id="d54a395b5cf3f86eb45f6e426b1b008864323b92" group="org.antlr"/>
          <trusted-key id="d6f1bc78607808ec8e9f69437a8860944fad5f62">
             <trusting group="org.apache.commons"/>
             <trusting group="org.apache.commons" name="commons-parent"/>
          </trusted-key>
          <trusted-key id="d75e25b78ebb19e47c0a99bca7764f502a938c99" group="com.google.protobuf"/>
-         <trusted-key id="d790f72ea8fd39551012b62dcf9f3090ce4cb752">
-            <trusting group="org.abego.treelayout"/>
-            <trusting group="org.abego.treelayout" name="org.abego.treelayout.core"/>
-         </trusted-key>
+         <trusted-key id="d790f72ea8fd39551012b62dcf9f3090ce4cb752" group="org.abego.treelayout"/>
          <trusted-key id="da7a1bb85b19e4fb05073431205c8673dc742c7c">
             <trusting group="org.apache"/>
             <trusting group="org.apache.maven"/>
@@ -491,18 +384,9 @@
          <trusted-key id="e82d2eaf2e83830ce1f7f6be571a5291e827e1c7" group="net.java"/>
          <trusted-key id="e85aed155021af8a6c6b7a4a7c7d8456294423ba" group="org.objenesis"/>
          <trusted-key id="e8bf633b386b7ddcf1e1a9b3358a4abae72947c2" group="com.google.testparameterinjector"/>
-         <trusted-key id="ea0b70b5050192c98cfa7e4f3f36885c24df4b75">
-            <trusting group="org.mozilla"/>
-            <trusting group="org.mozilla" name="rhino"/>
-         </trusted-key>
-         <trusted-key id="ea23db1360d9029481e7f2efecdfea3cb4493b94">
-            <trusting group="jline"/>
-            <trusting group="jline" name="jline"/>
-         </trusted-key>
-         <trusted-key id="eaa526b91dd83ba3e1b9636fa730529ca355a63e">
-            <trusting group="org.ccil.cowan.tagsoup"/>
-            <trusting group="org.ccil.cowan.tagsoup" name="tagsoup"/>
-         </trusted-key>
+         <trusted-key id="ea0b70b5050192c98cfa7e4f3f36885c24df4b75" group="org.mozilla"/>
+         <trusted-key id="ea23db1360d9029481e7f2efecdfea3cb4493b94" group="jline"/>
+         <trusted-key id="eaa526b91dd83ba3e1b9636fa730529ca355a63e" group="org.ccil.cowan.tagsoup"/>
          <trusted-key id="ec86f41279f2ec8ffd2f54906ccc36cc6c69fc17" group="com.google"/>
          <trusted-key id="ee0ca873074092f806f59b65d364abaa39a47320" group="com.google.errorprone"/>
          <trusted-key id="f1a51e051f527e0c8e24d54d4b1e11d5a4b91e89" group="com.google.protobuf"/>
@@ -513,26 +397,18 @@
          </trusted-key>
          <trusted-key id="f3184bcd55f4d016e30d4c9bf42e87f9665015c9" group="org.jsoup"/>
          <trusted-key id="f42b96b8648b5c4a1c43a62fbb2914c1fa0811c3" group="net.bytebuddy"/>
-         <trusted-key id="fa1703b1d287caea3a60f931e0130a3ed5a2079e">
-            <trusting group="org.webjars"/>
-            <trusting group="org.webjars" name="jquery"/>
-         </trusted-key>
+         <trusted-key id="fa1703b1d287caea3a60f931e0130a3ed5a2079e" group="org.webjars"/>
          <trusted-key id="fa77dcfef2ee6eb2debedd2c012579464d01c06a">
             <trusting group="org.apache"/>
-            <trusting group="org.apache" name="apache"/>
             <trusting group="org.codehaus.plexus"/>
          </trusted-key>
          <trusted-key id="fa7929f83ad44c4590f6cc6815c71c0a4e0b8edd" group="net.java.dev.jna"/>
-         <trusted-key id="faabc3738b1f58da2d776fa2eb380dc13c39f675">
-            <trusting group="com.intellij"/>
-            <trusting group="com.intellij" name="annotations"/>
-         </trusted-key>
+         <trusted-key id="faabc3738b1f58da2d776fa2eb380dc13c39f675" group="com.intellij"/>
          <trusted-key id="fc411cd3cb7dcb0abc9801058118b3bcdb1a5000" group="jakarta.xml.bind"/>
          <trusted-key id="ff460acf3266fdce8eb8fe3ba797295e9d87bdd0" group="androidx.build.gradle.gcpbuildcache" name="gcpbuildcache"/>
          <trusted-key id="ff6e2c001948c5f2f38b0cc385911f425ec61b51">
             <trusting group="junit"/>
             <trusting group="org.junit"/>
-            <trusting group="org.junit" name="junit-bom"/>
             <trusting group="org.junit.jupiter"/>
             <trusting group="org.junit.platform"/>
             <trusting group="org.opentest4j"/>
@@ -570,6 +446,14 @@
             <sha256 value="cd6db17a11a31ede794ccbd1df0e4d9750f640234731f21cff885a9997277e81" origin="Generated by Gradle" reason="Artifact is not signed"/>
          </artifact>
       </component>
+      <component group="com.google.ads.interactivemedia.v3" name="interactivemedia" version="3.29.0" androidx:reason="Unsigned. Used by media3.">
+         <artifact name="interactivemedia-3.29.0.jar">
+            <sha256 value="4d9b94444b8eea1637435c8f0598ee9f86c05ade73b1e8911dc16494013c379a" origin="Generated by Gradle"/>
+         </artifact>
+         <artifact name="interactivemedia-3.29.0.pom">
+            <sha256 value="9fb18fd29b9dfe2e7ed5fe98a3be433a4c3cc4ea8f47f2b444155c39b4afddf5" origin="Generated by Gradle"/>
+         </artifact>
+      </component>
       <component group="com.google.android.apps.common.testing.accessibility.framework" name="accessibility-test-framework" version="2.1">
          <artifact name="accessibility-test-framework-2.1.jar">
             <sha256 value="7b0aa6ed7553597ce0610684a9f7eca8021eee218f2e2f427c04a7fbf5f920bd" origin="Generated by Gradle" reason="Artifact is not signed"/>
@@ -769,6 +653,14 @@
             <sha256 value="31ce606f4e9518936299bb0d27c978fa61e185fd1de7c9874fe959a53e34a685" origin="Generated by Gradle" reason="Artifact is not signed"/>
          </artifact>
       </component>
+      <component group="org.chromium.net" name="cronet-api" version="72.3626.96" androidx:reason="Unsigned. Used by media3.">
+         <artifact name="cronet-api-72.3626.96.aar">
+            <sha256 value="6542c0377f00f38e2c707cc3ade1607843f572342cc798b38b78897f7d8ec248" origin="Generated by Gradle"/>
+         </artifact>
+         <artifact name="cronet-api-72.3626.96.pom">
+            <sha256 value="8d25d21f7f2aca27dc10638ad417bbb08a189310274bc322aa620aafe7e82c92" origin="Generated by Gradle"/>
+         </artifact>
+      </component>
       <component group="org.apache" name="apache" version="15">
          <artifact name="apache-15.pom">
             <pgp value="6bdaca2c0493cca133b372d09c4f7e9d98b1cc53"/>
diff --git a/hilt/integration-tests/viewmodelapp/build.gradle b/hilt/integration-tests/viewmodelapp/build.gradle
index 03144dd..52649d5 100644
--- a/hilt/integration-tests/viewmodelapp/build.gradle
+++ b/hilt/integration-tests/viewmodelapp/build.gradle
@@ -36,7 +36,7 @@
 }
 
 dependencies {
-    implementation(project(":activity:activity"))
+    implementation(project(":activity:activity-ktx"))
     implementation(project(":fragment:fragment-ktx"))
     implementation(libs.kotlinStdlib)
     implementation(libs.hiltAndroid)
diff --git a/libraryversions.toml b/libraryversions.toml
index fa05e1c..6515425 100644
--- a/libraryversions.toml
+++ b/libraryversions.toml
@@ -1,5 +1,5 @@
 [versions]
-ACTIVITY = "1.7.0-beta02"
+ACTIVITY = "1.7.0-rc01"
 ADS_IDENTIFIER = "1.0.0-alpha05"
 ANNOTATION = "1.7.0-alpha01"
 ANNOTATION_KMP = "1.6.0-dev01"
@@ -55,7 +55,7 @@
 DYNAMICANIMATION = "1.1.0-alpha04"
 DYNAMICANIMATION_KTX = "1.0.0-alpha04"
 EMOJI = "1.2.0-alpha03"
-EMOJI2 = "1.3.0-beta03"
+EMOJI2 = "1.3.0-rc01"
 EMOJI2_QUARANTINE = "1.0.0-alpha02"
 ENTERPRISE = "1.1.0-rc01"
 EXIFINTERFACE = "1.4.0-alpha01"
diff --git a/mediarouter/mediarouter/build.gradle b/mediarouter/mediarouter/build.gradle
index fcaf016..2cb2454 100644
--- a/mediarouter/mediarouter/build.gradle
+++ b/mediarouter/mediarouter/build.gradle
@@ -36,6 +36,7 @@
     testImplementation(libs.truth)
     testImplementation(libs.robolectric)
 
+    androidTestImplementation(libs.multidex)
     androidTestImplementation(libs.testExtJunit)
     androidTestImplementation(libs.testCore)
     androidTestImplementation(libs.testRunner)
@@ -49,6 +50,9 @@
     buildTypes.all {
         consumerProguardFiles "proguard-rules.pro"
     }
+    defaultConfig {
+        multiDexEnabled true
+    }
     namespace "androidx.mediarouter"
 }
 
diff --git a/mediarouter/mediarouter/src/androidTest/AndroidManifest.xml b/mediarouter/mediarouter/src/androidTest/AndroidManifest.xml
index 6cce57a..9e185e220 100644
--- a/mediarouter/mediarouter/src/androidTest/AndroidManifest.xml
+++ b/mediarouter/mediarouter/src/androidTest/AndroidManifest.xml
@@ -16,7 +16,9 @@
   -->
 <manifest xmlns:android="http://schemas.android.com/apk/res/android">
 
-    <application android:supportsRtl="true">
+    <application
+        android:supportsRtl="true"
+        android:name="androidx.multidex.MultiDexApplication">
         <activity
             android:name="androidx.mediarouter.app.MediaRouteChooserDialogTestActivity"
             android:label="MediaRouteChooserDialogTestActivity"
diff --git a/navigation/navigation-common/build.gradle b/navigation/navigation-common/build.gradle
index bf8a159..c75d69f 100644
--- a/navigation/navigation-common/build.gradle
+++ b/navigation/navigation-common/build.gradle
@@ -34,11 +34,11 @@
 
 dependencies {
     api("androidx.annotation:annotation:1.1.0")
-    api(project(":lifecycle:lifecycle-common"))
-    api(project(":lifecycle:lifecycle-runtime-ktx"))
-    api(project(":lifecycle:lifecycle-viewmodel-ktx"))
+    api("androidx.lifecycle:lifecycle-common:2.6.0-rc01")
+    api("androidx.lifecycle:lifecycle-runtime-ktx:2.6.0-rc01")
+    api("androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.0-rc01")
     api("androidx.savedstate:savedstate-ktx:1.2.0")
-    api(project(":lifecycle:lifecycle-viewmodel-savedstate"))
+    api("androidx.lifecycle:lifecycle-viewmodel-savedstate:2.6.0-rc01")
     implementation("androidx.core:core-ktx:1.1.0")
     implementation("androidx.collection:collection-ktx:1.1.0")
     implementation("androidx.profileinstaller:profileinstaller:1.2.1")
diff --git a/navigation/navigation-compose/build.gradle b/navigation/navigation-compose/build.gradle
index 57e32a3..bdb8361 100644
--- a/navigation/navigation-compose/build.gradle
+++ b/navigation/navigation-compose/build.gradle
@@ -28,12 +28,12 @@
 
     implementation(libs.kotlinStdlib)
     implementation("androidx.compose.foundation:foundation-layout:1.0.1")
-    api(project(":activity:activity-compose"))
+    api(projectOrArtifact(":activity:activity-compose"))
     api("androidx.compose.animation:animation:1.0.1")
     api("androidx.compose.runtime:runtime:1.0.1")
     api("androidx.compose.runtime:runtime-saveable:1.0.1")
     api("androidx.compose.ui:ui:1.0.1")
-    api("androidx.lifecycle:lifecycle-viewmodel-compose:2.5.1")
+    api("androidx.lifecycle:lifecycle-viewmodel-compose:2.6.0-rc01")
     // old version of common-java8 conflicts with newer version, because both have
     // DefaultLifecycleEventObserver.
     // Outside of androidx this is resolved via constraint added to lifecycle-common,
diff --git a/navigation/navigation-fragment/build.gradle b/navigation/navigation-fragment/build.gradle
index 0aadd2e..f8284f5 100644
--- a/navigation/navigation-fragment/build.gradle
+++ b/navigation/navigation-fragment/build.gradle
@@ -23,7 +23,7 @@
 }
 
 dependencies {
-    api(project(":fragment:fragment-ktx"))
+    api(projectOrArtifact(":fragment:fragment-ktx"))
     api(project(":navigation:navigation-runtime"))
     api("androidx.slidingpanelayout:slidingpanelayout:1.2.0")
     api(libs.kotlinStdlib)
diff --git a/navigation/navigation-runtime/build.gradle b/navigation/navigation-runtime/build.gradle
index 06b831b..27ffd43 100644
--- a/navigation/navigation-runtime/build.gradle
+++ b/navigation/navigation-runtime/build.gradle
@@ -26,8 +26,8 @@
 dependencies {
     api(project(":navigation:navigation-common"))
     api("androidx.activity:activity-ktx:1.6.1")
-    api(project(":lifecycle:lifecycle-runtime-ktx"))
-    api(project(":lifecycle:lifecycle-viewmodel-ktx"))
+    api("androidx.lifecycle:lifecycle-runtime-ktx:2.6.0-rc01")
+    api("androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.0-rc01")
     api("androidx.annotation:annotation-experimental:1.1.0")
     implementation('androidx.collection:collection:1.0.0')
 
diff --git a/privacysandbox/ads/ads-adservices-java/src/androidTest/AndroidManifest.xml b/privacysandbox/ads/ads-adservices-java/src/androidTest/AndroidManifest.xml
index faff43e..90665d4 100644
--- a/privacysandbox/ads/ads-adservices-java/src/androidTest/AndroidManifest.xml
+++ b/privacysandbox/ads/ads-adservices-java/src/androidTest/AndroidManifest.xml
@@ -18,6 +18,7 @@
     <uses-permission android:name="android.permission.ACCESS_ADSERVICES_TOPICS" />
     <uses-permission android:name="android.permission.ACCESS_ADSERVICES_AD_ID" />
     <uses-permission android:name="android.permission.ACCESS_ADSERVICES_ATTRIBUTION" />
+    <uses-permission android:name="android.permission.ACCESS_ADSERVICES_CUSTOM_AUDIENCE" />
     <application>
         <property android:name="android.adservices.AD_SERVICES_CONFIG"
             android:resource="@xml/ad_services_config" />
diff --git a/privacysandbox/ads/ads-adservices-java/src/androidTest/java/androidx/privacysandbox/ads/adservices/java/endtoend/FledgeCtsDebuggableTest.java b/privacysandbox/ads/ads-adservices-java/src/androidTest/java/androidx/privacysandbox/ads/adservices/java/endtoend/FledgeCtsDebuggableTest.java
new file mode 100644
index 0000000..d1563e8
--- /dev/null
+++ b/privacysandbox/ads/ads-adservices-java/src/androidTest/java/androidx/privacysandbox/ads/adservices/java/endtoend/FledgeCtsDebuggableTest.java
@@ -0,0 +1,910 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.privacysandbox.ads.adservices.java.endtoend;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.junit.Assert.assertThrows;
+
+import android.content.Context;
+import android.net.Uri;
+
+import androidx.annotation.RequiresApi;
+import androidx.privacysandbox.ads.adservices.adselection.AdSelectionConfig;
+import androidx.privacysandbox.ads.adservices.adselection.AdSelectionManager;
+import androidx.privacysandbox.ads.adservices.adselection.AdSelectionOutcome;
+import androidx.privacysandbox.ads.adservices.adselection.ReportImpressionRequest;
+import androidx.privacysandbox.ads.adservices.common.AdData;
+import androidx.privacysandbox.ads.adservices.common.AdSelectionSignals;
+import androidx.privacysandbox.ads.adservices.common.AdTechIdentifier;
+import androidx.privacysandbox.ads.adservices.customaudience.CustomAudience;
+import androidx.privacysandbox.ads.adservices.customaudience.JoinCustomAudienceRequest;
+import androidx.privacysandbox.ads.adservices.customaudience.TrustedBiddingData;
+import androidx.privacysandbox.ads.adservices.internal.AdServicesInfo;
+import androidx.privacysandbox.ads.adservices.java.adselection.AdSelectionManagerFutures;
+import androidx.privacysandbox.ads.adservices.java.customaudience.CustomAudienceManagerFutures;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.filters.SdkSuppress;
+import androidx.test.platform.app.InstrumentationRegistry;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.util.concurrent.ListenableFuture;
+
+import kotlin.Unit;
+
+import org.junit.AfterClass;
+import org.junit.Assert;
+import org.junit.Assume;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+import java.io.IOException;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+@SdkSuppress(minSdkVersion = 26)
+public class FledgeCtsDebuggableTest {
+    protected static final Context sContext = ApplicationProvider.getApplicationContext();
+    private static final String TAG = "FledgeCtsDebuggableTest";
+
+    // Configuration constants
+    private static final int SDK_MAX_REQUEST_PERMITS_PER_SECOND = 1000;
+    private static final String DISABLE_MEASUREMENT_ENROLLMENT_CHECK = "1";
+
+    // Time allowed by current test setup for APIs to respond
+    private static final int API_RESPONSE_TIMEOUT_SECONDS = 5;
+
+    // This is used to check actual API timeout conditions; note that the default overall timeout
+    // for ad selection is 10 seconds
+    private static final int API_RESPONSE_LONGER_TIMEOUT_SECONDS = 12;
+
+    private static final AdTechIdentifier SELLER = new AdTechIdentifier("performance-fledge"
+            + "-static-5jyy5ulagq-uc.a.run.app");
+
+    private static final AdTechIdentifier BUYER_1 = new AdTechIdentifier("performance-fledge"
+            + "-static-5jyy5ulagq-uc.a.run.app");
+    private static final AdTechIdentifier BUYER_2 = new AdTechIdentifier("performance-fledge"
+            + "-static-2-5jyy5ulagq-uc.a.run.app");
+
+    private static final AdSelectionSignals AD_SELECTION_SIGNALS =
+            new AdSelectionSignals("{\"ad_selection_signals\":1}");
+
+    private static final AdSelectionSignals SELLER_SIGNALS =
+            new AdSelectionSignals("{\"test_seller_signals\":1}");
+
+    private static final Map<AdTechIdentifier, AdSelectionSignals> PER_BUYER_SIGNALS =
+            new HashMap<>();
+    static {
+        PER_BUYER_SIGNALS.put(BUYER_1,
+                new AdSelectionSignals("{\"buyer_signals\":1}"));
+        PER_BUYER_SIGNALS.put(BUYER_2,
+                new AdSelectionSignals("{\"buyer_signals\":2}"));
+    }
+
+    private static final String VALID_TRUSTED_BIDDING_URI_PATH = "/trusted/biddingsignals/simple";
+
+    private static final ImmutableList<String> VALID_TRUSTED_BIDDING_KEYS =
+            ImmutableList.of("key1", "key2");
+
+    private static final AdSelectionSignals VALID_USER_BIDDING_SIGNALS =
+            new AdSelectionSignals("{'valid': 'yep', 'opaque': 'definitely'}");
+
+    private static final long FLEDGE_CUSTOM_AUDIENCE_DEFAULT_EXPIRE_IN_MS =
+            60L * 24L * 60L * 60L * 1000L; // 60 days
+    private static final long FLEDGE_CUSTOM_AUDIENCE_MAX_ACTIVATION_DELAY_IN_MS =
+            60L * 24L * 60L * 60L * 1000L; // 60 days
+
+    private static final long DAY_IN_SECONDS = 60 * 60 * 24;
+
+    private static final String VALID_NAME = "testCustomAudienceName";
+
+    private static final String AD_URI_PREFIX = "/adverts/123/";
+
+    private static final String SELLER_DECISION_LOGIC_URI_PATH = "/seller/decision/simple_logic";
+    private static final String BUYER_BIDDING_LOGIC_URI_PATH = "/buyer/bidding/simple_logic";
+    private static final String SELLER_TRUSTED_SIGNAL_URI_PATH = "/trusted/scoringsignals/simple";
+    // To mock malformed logic, use paths that return an empty response
+    private static final String SELLER_MALFORMED_DECISION_LOGIC_URI_PATH = "/reporting/seller";
+    private static final String BUYER_MALFORMED_BIDDING_LOGIC_URI_PATH = "/reporting/buyer";
+
+    private static final AdSelectionConfig DEFAULT_AD_SELECTION_CONFIG = new AdSelectionConfig(
+            SELLER,
+            Uri.parse(
+                    String.format(
+                            "https://%s%s",
+                            SELLER,
+                            SELLER_DECISION_LOGIC_URI_PATH)),
+            Arrays.asList(BUYER_1, BUYER_2),
+            AD_SELECTION_SIGNALS,
+            SELLER_SIGNALS,
+            PER_BUYER_SIGNALS,
+            Uri.parse(
+                    String.format(
+                            "https://%s%s",
+                            SELLER,
+                            SELLER_TRUSTED_SIGNAL_URI_PATH)));
+
+    private AdSelectionClient mAdSelectionClient;
+    private CustomAudienceClient mCustomAudienceClient;
+
+    @BeforeClass
+    public static void configure() {
+        TestUtil testUtil = new TestUtil(InstrumentationRegistry.getInstrumentation(), TAG);
+
+        testUtil.overrideAdIdKillSwitch(true);
+        testUtil.overrideAppSetIdKillSwitch(true);
+        testUtil.overrideKillSwitches(true);
+        testUtil.overrideAllowlists(true);
+        testUtil.overrideConsentManagerDebugMode(true);
+        testUtil.overrideMeasurementKillSwitches(true);
+        testUtil.overrideDisableMeasurementEnrollmentCheck(DISABLE_MEASUREMENT_ENROLLMENT_CHECK);
+        testUtil.enableEnrollmentCheck(true);
+        testUtil.overrideFledgeSelectAdsKillSwitch(true);
+        testUtil.overrideFledgeCustomAudienceServiceKillSwitch(true);
+        testUtil.overrideSdkRequestPermitsPerSecond(SDK_MAX_REQUEST_PERMITS_PER_SECOND);
+        testUtil.disableDeviceConfigSyncForTests(true);
+        testUtil.disableFledgeEnrollmentCheck(true);
+        testUtil.enableAdServiceSystemService(true);
+        testUtil.enforceFledgeJsIsolateMaxHeapSize(false);
+    }
+
+    @AfterClass
+    public static void resetConfiguration() {
+        TestUtil testUtil = new TestUtil(InstrumentationRegistry.getInstrumentation(), TAG);
+
+        testUtil.overrideAdIdKillSwitch(false);
+        testUtil.overrideAppSetIdKillSwitch(false);
+        testUtil.overrideKillSwitches(false);
+        testUtil.overrideAllowlists(false);
+        testUtil.overrideConsentManagerDebugMode(false);
+        testUtil.overrideMeasurementKillSwitches(false);
+        testUtil.resetOverrideDisableMeasurementEnrollmentCheck();
+        testUtil.enableEnrollmentCheck(false);
+        testUtil.overrideFledgeSelectAdsKillSwitch(true);
+        testUtil.overrideFledgeCustomAudienceServiceKillSwitch(true);
+        testUtil.overrideSdkRequestPermitsPerSecond(1);
+        testUtil.disableDeviceConfigSyncForTests(false);
+        testUtil.disableFledgeEnrollmentCheck(false);
+        testUtil.enableAdServiceSystemService(false);
+        testUtil.enforceFledgeJsIsolateMaxHeapSize(true);
+    }
+
+    @Before
+    public void setup() throws IOException {
+        mAdSelectionClient =
+                new AdSelectionClient(sContext);
+        mCustomAudienceClient =
+                new CustomAudienceClient(sContext);
+    }
+
+    @Test
+    public void testFledgeAuctionSelectionFlow_overall_Success() throws Exception {
+        // Skip the test if SDK extension 4 is not present.
+        Assume.assumeTrue(AdServicesInfo.INSTANCE.version() >= 4);
+
+        List<Double> bidsForBuyer1 = ImmutableList.of(1.1, 2.2);
+        List<Double> bidsForBuyer2 = ImmutableList.of(4.5, 6.7, 10.0);
+
+        CustomAudience customAudience1 = createCustomAudience(BUYER_1, bidsForBuyer1);
+
+        CustomAudience customAudience2 = createCustomAudience(BUYER_2, bidsForBuyer2);
+        // Joining custom audiences, no result to do assertion on. Failures will generate an
+        // exception."
+        mCustomAudienceClient
+                .joinCustomAudience(customAudience1)
+                .get(API_RESPONSE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+
+        mCustomAudienceClient
+                .joinCustomAudience(customAudience2)
+                .get(API_RESPONSE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+
+        // Running ad selection and asserting that the outcome is returned in < 10 seconds
+        AdSelectionOutcome outcome =
+                mAdSelectionClient
+                        .selectAds(DEFAULT_AD_SELECTION_CONFIG)
+                        .get(API_RESPONSE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+
+        // Assert that ad3 from BUYER_2 is rendered, since it had the highest bid and score
+        Assert.assertEquals(
+                getUri(BUYER_2, AD_URI_PREFIX + "/ad3"), outcome.getRenderUri());
+
+        ReportImpressionRequest reportImpressionRequest =
+                new ReportImpressionRequest(outcome.getAdSelectionId(),
+                        DEFAULT_AD_SELECTION_CONFIG);
+
+        // Performing reporting, and asserting that no exception is thrown
+        mAdSelectionClient
+                .reportImpression(reportImpressionRequest)
+                .get(API_RESPONSE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+    }
+
+    @Test
+    public void testAdSelection_etldViolation_failure() throws Exception {
+        // Skip the test if SDK extension 4 is not present.
+        Assume.assumeTrue(AdServicesInfo.INSTANCE.version() >= 4);
+
+        List<Double> bidsForBuyer1 = ImmutableList.of(1.1, 2.2);
+        List<Double> bidsForBuyer2 = ImmutableList.of(4.5, 6.7, 10.0);
+
+        CustomAudience customAudience1 = createCustomAudience(BUYER_1, bidsForBuyer1);
+
+        CustomAudience customAudience2 = createCustomAudience(BUYER_2, bidsForBuyer2);
+
+        AdSelectionConfig adSelectionConfigWithEtldViolations =
+                new AdSelectionConfig(
+                        SELLER,
+                        Uri.parse(
+                                String.format(
+                                        "https://%s%s",
+                                        SELLER + "etld_noise",
+                                        SELLER_DECISION_LOGIC_URI_PATH)),
+                        Arrays.asList(BUYER_1, BUYER_2),
+                        AD_SELECTION_SIGNALS,
+                        SELLER_SIGNALS,
+                        PER_BUYER_SIGNALS,
+                        Uri.parse(
+                                String.format(
+                                        "https://%s%s",
+                                        SELLER + "etld_noise",
+                                        SELLER_TRUSTED_SIGNAL_URI_PATH)));
+
+        // Joining custom audiences, no result to do assertion on. Failures will generate an
+        // exception."
+        mCustomAudienceClient
+                .joinCustomAudience(customAudience1)
+                .get(API_RESPONSE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+
+        mCustomAudienceClient
+                .joinCustomAudience(customAudience2)
+                .get(API_RESPONSE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+
+        // Running ad selection and asserting that exception is thrown when decision and signals
+        // URIs are not etld+1 compliant
+        Exception selectAdsException =
+                assertThrows(
+                        ExecutionException.class,
+                        () ->
+                                mAdSelectionClient
+                                        .selectAds(adSelectionConfigWithEtldViolations)
+                                        .get(API_RESPONSE_TIMEOUT_SECONDS, TimeUnit.SECONDS));
+        assertThat(selectAdsException.getCause()).isInstanceOf(IllegalArgumentException.class);
+    }
+
+    @Test
+    public void testReportImpression_etldViolation_failure() throws Exception {
+        // Skip the test if SDK extension 4 is not present.
+        Assume.assumeTrue(AdServicesInfo.INSTANCE.version() >= 4);
+
+        List<Double> bidsForBuyer1 = ImmutableList.of(1.1, 2.2);
+        List<Double> bidsForBuyer2 = ImmutableList.of(4.5, 6.7, 10.0);
+
+        CustomAudience customAudience1 = createCustomAudience(BUYER_1, bidsForBuyer1);
+
+        CustomAudience customAudience2 = createCustomAudience(BUYER_2, bidsForBuyer2);
+
+        AdSelectionConfig adSelectionConfigWithEtldViolations =
+                new AdSelectionConfig(
+                        SELLER,
+                        Uri.parse(
+                                String.format(
+                                        "https://%s%s",
+                                        SELLER + "etld_noise",
+                                        SELLER_DECISION_LOGIC_URI_PATH)),
+                        Arrays.asList(BUYER_1, BUYER_2),
+                        AD_SELECTION_SIGNALS,
+                        SELLER_SIGNALS,
+                        PER_BUYER_SIGNALS,
+                        Uri.parse(
+                                String.format(
+                                        "https://%s%s",
+                                        SELLER + "etld_noise",
+                                        SELLER_TRUSTED_SIGNAL_URI_PATH)));
+
+        // Joining custom audiences, no result to do assertion on. Failures will generate an
+        // exception."
+        mCustomAudienceClient
+                .joinCustomAudience(customAudience1)
+                .get(API_RESPONSE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+
+        mCustomAudienceClient
+                .joinCustomAudience(customAudience2)
+                .get(API_RESPONSE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+
+        // Running ad selection and asserting that the outcome is returned in < 10 seconds
+        AdSelectionOutcome outcome =
+                mAdSelectionClient
+                        .selectAds(DEFAULT_AD_SELECTION_CONFIG)
+                        .get(API_RESPONSE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+
+        // Assert that the ad3 from buyer 2 is rendered, since it had the highest bid and score
+        Assert.assertEquals(
+                getUri(BUYER_2, AD_URI_PREFIX + "/ad3"), outcome.getRenderUri());
+
+        ReportImpressionRequest reportImpressionRequest =
+                new ReportImpressionRequest(
+                        outcome.getAdSelectionId(), adSelectionConfigWithEtldViolations);
+
+        // Running report Impression and asserting that exception is thrown when decision and
+        // signals URIs are not etld+1 compliant
+        Exception selectAdsException =
+                assertThrows(
+                        ExecutionException.class,
+                        () ->
+                                mAdSelectionClient
+                                        .reportImpression(reportImpressionRequest)
+                                        .get(API_RESPONSE_TIMEOUT_SECONDS, TimeUnit.SECONDS));
+        assertThat(selectAdsException.getCause()).isInstanceOf(IllegalArgumentException.class);
+    }
+
+    @Test
+    public void testAdSelection_skipAdsMalformedBiddingLogic_success() throws Exception {
+        // Skip the test if SDK extension 4 is not present.
+        Assume.assumeTrue(AdServicesInfo.INSTANCE.version() >= 4);
+
+        List<Double> bidsForBuyer1 = ImmutableList.of(1.1, 2.2);
+        List<Double> bidsForBuyer2 = ImmutableList.of(4.5, 6.7, 10.0);
+
+        CustomAudience customAudience1 = createCustomAudience(BUYER_1, bidsForBuyer1);
+
+        CustomAudience customAudience2 = createCustomAudience(
+                BUYER_2,
+                bidsForBuyer2,
+                getValidActivationTime(),
+                getValidExpirationTime(),
+                BUYER_MALFORMED_BIDDING_LOGIC_URI_PATH);
+
+
+        // Joining custom audiences, no result to do assertion on. Failures will generate an
+        // exception."
+        mCustomAudienceClient
+                .joinCustomAudience(customAudience1)
+                .get(API_RESPONSE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+
+        mCustomAudienceClient
+                .joinCustomAudience(customAudience2)
+                .get(API_RESPONSE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+
+        // Running ad selection and asserting that the outcome is returned in < 10 seconds
+        AdSelectionOutcome outcome =
+                mAdSelectionClient
+                        .selectAds(DEFAULT_AD_SELECTION_CONFIG)
+                        .get(API_RESPONSE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+
+        // Assert that the ad3 from buyer 2 is skipped despite having the highest bid, since it has
+        // malformed bidding logic
+        // The winner should come from buyer1 with the highest bid i.e. ad2
+        Assert.assertEquals(
+                getUri(BUYER_1, AD_URI_PREFIX + "/ad2"), outcome.getRenderUri());
+
+        ReportImpressionRequest reportImpressionRequest =
+                new ReportImpressionRequest(outcome.getAdSelectionId(),
+                        DEFAULT_AD_SELECTION_CONFIG);
+
+        // Performing reporting, and asserting that no exception is thrown
+        mAdSelectionClient
+                .reportImpression(reportImpressionRequest)
+                .get(API_RESPONSE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+    }
+
+    @Test
+    public void testAdSelection_malformedScoringLogic_failure() throws Exception {
+        // Skip the test if SDK extension 4 is not present.
+        Assume.assumeTrue(AdServicesInfo.INSTANCE.version() >= 4);
+
+        List<Double> bidsForBuyer1 = ImmutableList.of(1.1, 2.2);
+        List<Double> bidsForBuyer2 = ImmutableList.of(4.5, 6.7, 10.0);
+
+        CustomAudience customAudience1 = createCustomAudience(BUYER_1, bidsForBuyer1);
+
+        CustomAudience customAudience2 = createCustomAudience(BUYER_2, bidsForBuyer2);
+
+        // Joining custom audiences, no result to do assertion on. Failures will generate an
+        // exception."
+        mCustomAudienceClient
+                .joinCustomAudience(customAudience1)
+                .get(API_RESPONSE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+
+        mCustomAudienceClient
+                .joinCustomAudience(customAudience2)
+                .get(API_RESPONSE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+
+        // Ad Selection will fail due to scoring logic malformed
+        AdSelectionConfig adSelectionConfig = new AdSelectionConfig(
+                SELLER,
+                Uri.parse(
+                        String.format(
+                                "https://%s%s",
+                                SELLER,
+                                SELLER_MALFORMED_DECISION_LOGIC_URI_PATH)),
+                Arrays.asList(BUYER_1, BUYER_2),
+                AD_SELECTION_SIGNALS,
+                SELLER_SIGNALS,
+                PER_BUYER_SIGNALS,
+                Uri.parse(
+                        String.format(
+                                "https://%s%s",
+                                SELLER,
+                                SELLER_TRUSTED_SIGNAL_URI_PATH)));
+
+        Exception selectAdsException =
+                assertThrows(
+                        ExecutionException.class,
+                        () ->
+                                mAdSelectionClient
+                                        .selectAds(adSelectionConfig)
+                                        .get(API_RESPONSE_TIMEOUT_SECONDS, TimeUnit.SECONDS));
+        assertThat(selectAdsException.getCause()).isInstanceOf(IllegalStateException.class);
+    }
+
+    @Test
+    public void testAdSelection_skipAdsFailedGettingBiddingLogic_success() throws Exception {
+        // Skip the test if SDK extension 4 is not present.
+        Assume.assumeTrue(AdServicesInfo.INSTANCE.version() >= 4);
+
+        List<Double> bidsForBuyer1 = ImmutableList.of(1.1, 2.2);
+        List<Double> bidsForBuyer2 = ImmutableList.of(4.5, 6.7, 10.0);
+
+        CustomAudience customAudience1 = createCustomAudience(BUYER_1, bidsForBuyer1);
+
+        CustomAudience customAudience2 = createCustomAudience(
+                BUYER_2,
+                bidsForBuyer2,
+                getValidActivationTime(),
+                getValidExpirationTime(),
+                "/invalid/bidding/logic/uri");
+
+        // Joining custom audiences, no result to do assertion on. Failures will generate an
+        // exception."
+        mCustomAudienceClient
+                .joinCustomAudience(customAudience1)
+                .get(API_RESPONSE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+
+        mCustomAudienceClient
+                .joinCustomAudience(customAudience2)
+                .get(API_RESPONSE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+
+        // Running ad selection and asserting that the outcome is returned in < 10 seconds
+        AdSelectionOutcome outcome =
+                mAdSelectionClient
+                        .selectAds(DEFAULT_AD_SELECTION_CONFIG)
+                        .get(API_RESPONSE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+
+        // Assert that the ad3 from buyer 2 is skipped despite having the highest bid, since it has
+        // missing bidding logic
+        // The winner should come from buyer1 with the highest bid i.e. ad2
+        Assert.assertEquals(getUri(BUYER_1, AD_URI_PREFIX + "/ad2"), outcome.getRenderUri());
+
+        ReportImpressionRequest reportImpressionRequest =
+                new ReportImpressionRequest(outcome.getAdSelectionId(),
+                        DEFAULT_AD_SELECTION_CONFIG);
+
+        // Performing reporting, and asserting that no exception is thrown
+        mAdSelectionClient
+                .reportImpression(reportImpressionRequest)
+                .get(API_RESPONSE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+    }
+
+    @Test
+    public void testAdSelection_errorGettingScoringLogic_failure() throws Exception {
+        // Skip the test if SDK extension 4 is not present.
+        Assume.assumeTrue(AdServicesInfo.INSTANCE.version() >= 4);
+
+        List<Double> bidsForBuyer1 = ImmutableList.of(1.1, 2.2);
+        List<Double> bidsForBuyer2 = ImmutableList.of(4.5, 6.7, 10.0);
+
+        CustomAudience customAudience1 = createCustomAudience(BUYER_1, bidsForBuyer1);
+
+        CustomAudience customAudience2 = createCustomAudience(BUYER_2, bidsForBuyer2);
+
+        // Joining custom audiences, no result to do assertion on. Failures will generate an
+        // exception."
+        mCustomAudienceClient
+                .joinCustomAudience(customAudience1)
+                .get(API_RESPONSE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+
+        mCustomAudienceClient
+                .joinCustomAudience(customAudience2)
+                .get(API_RESPONSE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+
+        // Ad Selection will fail due to scoring logic not found, because the URI that is used to
+        // fetch scoring logic does not exist
+        AdSelectionConfig adSelectionConfig = new AdSelectionConfig(
+                SELLER,
+                Uri.parse(
+                        String.format(
+                                "https://%s%s",
+                                SELLER,
+                                "/invalid/seller/decision/logic/uri")),
+                Arrays.asList(BUYER_1, BUYER_2),
+                AD_SELECTION_SIGNALS,
+                SELLER_SIGNALS,
+                PER_BUYER_SIGNALS,
+                Uri.parse(
+                        String.format(
+                                "https://%s%s",
+                                SELLER,
+                                SELLER_TRUSTED_SIGNAL_URI_PATH)));
+        Exception selectAdsException =
+                assertThrows(
+                        ExecutionException.class,
+                        () ->
+                                mAdSelectionClient
+                                        .selectAds(adSelectionConfig)
+                                        .get(API_RESPONSE_LONGER_TIMEOUT_SECONDS,
+                                                TimeUnit.SECONDS));
+        // Sometimes a 400 status code is returned (ISE) instead of the network fetch timing out
+        assertThat(
+                selectAdsException.getCause() instanceof TimeoutException
+                        || selectAdsException.getCause() instanceof IllegalStateException)
+                .isTrue();
+    }
+
+    @Test
+    public void testAdSelectionFlow_skipNonActivatedCA_Success() throws Exception {
+        // Skip the test if SDK extension 4 is not present.
+        Assume.assumeTrue(AdServicesInfo.INSTANCE.version() >= 4);
+
+        List<Double> bidsForBuyer1 = ImmutableList.of(1.1, 2.2);
+        List<Double> bidsForBuyer2 = ImmutableList.of(4.5, 6.7, 10.0);
+
+        CustomAudience customAudience1 = createCustomAudience(BUYER_1, bidsForBuyer1);
+
+        // CA 2 activated long in the future
+        CustomAudience customAudience2 =
+                createCustomAudience(
+                        BUYER_2,
+                        bidsForBuyer2,
+                        getValidDelayedActivationTime(),
+                        getValidDelayedExpirationTime(),
+                        BUYER_BIDDING_LOGIC_URI_PATH);
+
+        // Joining custom audiences, no result to do assertion on. Failures will generate an
+        // exception."
+        mCustomAudienceClient
+                .joinCustomAudience(customAudience1)
+                .get(API_RESPONSE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+
+        mCustomAudienceClient
+                .joinCustomAudience(customAudience2)
+                .get(API_RESPONSE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+
+        // Running ad selection and asserting that the outcome is returned in < 10 seconds
+        AdSelectionOutcome outcome =
+                mAdSelectionClient
+                        .selectAds(DEFAULT_AD_SELECTION_CONFIG)
+                        .get(API_RESPONSE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+
+        // Assert that the ad3 from buyer 2 is skipped despite having the highest bid, since it is
+        // not activated yet
+        // The winner should come from buyer1 with the highest bid i.e. ad2
+        Assert.assertEquals(
+                getUri(BUYER_1, AD_URI_PREFIX + "/ad2"), outcome.getRenderUri());
+
+        ReportImpressionRequest reportImpressionRequest =
+                new ReportImpressionRequest(outcome.getAdSelectionId(),
+                        DEFAULT_AD_SELECTION_CONFIG);
+
+        // Performing reporting, and asserting that no exception is thrown
+        mAdSelectionClient
+                .reportImpression(reportImpressionRequest)
+                .get(API_RESPONSE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+    }
+
+    @Test
+    public void testAdSelectionFlow_skipExpiredCA_Success() throws Exception {
+        // Skip the test if SDK extension 4 is not present.
+        Assume.assumeTrue(AdServicesInfo.INSTANCE.version() >= 4);
+
+        List<Double> bidsForBuyer1 = ImmutableList.of(1.1, 2.2);
+        List<Double> bidsForBuyer2 = ImmutableList.of(4.5, 6.7, 10.0);
+
+        CustomAudience customAudience1 = createCustomAudience(BUYER_1, bidsForBuyer1);
+
+        int caTimeToExpireSeconds = 2;
+        // Since we cannot create CA which is already expired, we create one which expires in few
+        // seconds
+        // We will then wait till this CA expires before we run Ad Selection
+        CustomAudience customAudience2 =
+                createCustomAudience(
+                        BUYER_2,
+                        bidsForBuyer2,
+                        getValidActivationTime(),
+                        Instant.now().plusSeconds(caTimeToExpireSeconds),
+                        BUYER_BIDDING_LOGIC_URI_PATH);
+
+        // Joining custom audiences, no result to do assertion on. Failures will generate an
+        // exception."
+        mCustomAudienceClient
+                .joinCustomAudience(customAudience1)
+                .get(API_RESPONSE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+
+        mCustomAudienceClient
+                .joinCustomAudience(customAudience2)
+                .get(API_RESPONSE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+
+        // Wait to ensure that CA2 gets expired
+        Thread.sleep(caTimeToExpireSeconds * 2 * 1000);
+
+        // Running ad selection and asserting that the outcome is returned in < 10 seconds
+        AdSelectionOutcome outcome =
+                mAdSelectionClient
+                        .selectAds(DEFAULT_AD_SELECTION_CONFIG)
+                        .get(API_RESPONSE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+
+        // Assert that the ad3 from buyer 2 is skipped despite having the highest bid, since it is
+        // expired
+        // The winner should come from buyer1 with the highest bid i.e. ad2
+        Assert.assertEquals(
+                getUri(BUYER_1, AD_URI_PREFIX + "/ad2"), outcome.getRenderUri());
+
+        ReportImpressionRequest reportImpressionRequest =
+                new ReportImpressionRequest(outcome.getAdSelectionId(),
+                        DEFAULT_AD_SELECTION_CONFIG);
+
+        // Performing reporting, and asserting that no exception is thrown
+        mAdSelectionClient
+                .reportImpression(reportImpressionRequest)
+                .get(API_RESPONSE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+    }
+
+    @Test
+    public void testAdSelectionFlow_skipCAsThatTimeoutDuringBidding_Success() throws Exception {
+        // Skip the test if SDK extension 4 is not present.
+        Assume.assumeTrue(AdServicesInfo.INSTANCE.version() >= 4);
+
+        List<Double> bidsForBuyer1 = ImmutableList.of(1.1, 2.2);
+        List<Double> bidsForBuyer2 = ImmutableList.of(4.5, 6.7, 10.0);
+
+        CustomAudience customAudience1 = createCustomAudience(BUYER_1, bidsForBuyer1);
+        CustomAudience customAudience2 = createCustomAudience(
+                BUYER_2,
+                bidsForBuyer2,
+                getValidActivationTime(),
+                getValidExpirationTime(),
+                BUYER_BIDDING_LOGIC_URI_PATH + "?delay=" + 5000);
+
+        // Joining custom audiences, no result to do assertion on. Failures will generate an
+        // exception.
+        mCustomAudienceClient
+                .joinCustomAudience(customAudience1)
+                .get(API_RESPONSE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+
+        mCustomAudienceClient
+                .joinCustomAudience(customAudience2)
+                .get(API_RESPONSE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+
+        // Running ad selection and asserting that the outcome is returned in < 10 seconds
+        AdSelectionOutcome outcome =
+                mAdSelectionClient
+                        .selectAds(DEFAULT_AD_SELECTION_CONFIG)
+                        .get(API_RESPONSE_LONGER_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+
+        // Assert that the ad3 from buyer 2 is skipped despite having the highest bid, since it
+        // timed out
+        // The winner should come from buyer1 with the highest bid i.e. ad2
+        Assert.assertEquals(
+                getUri(BUYER_1, AD_URI_PREFIX + "/ad2"), outcome.getRenderUri());
+
+        ReportImpressionRequest reportImpressionRequest =
+                new ReportImpressionRequest(outcome.getAdSelectionId(),
+                        DEFAULT_AD_SELECTION_CONFIG);
+
+        // Performing reporting, and asserting that no exception is thrown
+        mAdSelectionClient
+                .reportImpression(reportImpressionRequest)
+                .get(API_RESPONSE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+    }
+
+    @Test
+    public void testAdSelection_overallTimeout_Failure() throws Exception {
+        // Skip the test if SDK extension 4 is not present.
+        Assume.assumeTrue(AdServicesInfo.INSTANCE.version() >= 4);
+
+        List<Double> bidsForBuyer1 = ImmutableList.of(1.1, 2.2);
+        List<Double> bidsForBuyer2 = ImmutableList.of(4.5, 6.7, 10.0);
+
+        CustomAudience customAudience1 = createCustomAudience(BUYER_1, bidsForBuyer1);
+
+        CustomAudience customAudience2 = createCustomAudience(BUYER_2, bidsForBuyer2);
+
+        // Joining custom audiences, no result to do assertion on. Failures will generate an
+        // exception."
+        mCustomAudienceClient
+                .joinCustomAudience(customAudience1)
+                .get(API_RESPONSE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+        mCustomAudienceClient
+                .joinCustomAudience(customAudience2)
+                .get(API_RESPONSE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+
+        // Running ad selection and asserting that the outcome is returned in < 10 seconds
+        AdSelectionConfig adSelectionConfig = new AdSelectionConfig(
+                SELLER,
+                Uri.parse(
+                        String.format(
+                                "https://%s%s",
+                                SELLER,
+                                SELLER_DECISION_LOGIC_URI_PATH + "?delay=" + 10000)),
+                Arrays.asList(BUYER_1, BUYER_2),
+                AD_SELECTION_SIGNALS,
+                SELLER_SIGNALS,
+                PER_BUYER_SIGNALS,
+                Uri.parse(
+                        String.format(
+                                "https://%s%s",
+                                SELLER,
+                                SELLER_TRUSTED_SIGNAL_URI_PATH)));
+        Exception selectAdsException =
+                assertThrows(
+                        ExecutionException.class,
+                        () ->
+                                mAdSelectionClient
+                                        .selectAds(adSelectionConfig)
+                                        .get(API_RESPONSE_LONGER_TIMEOUT_SECONDS,
+                                                TimeUnit.SECONDS));
+        assertThat(selectAdsException.getCause()).isInstanceOf(TimeoutException.class);
+    }
+
+    private static Uri getUri(String authority, String path) {
+        return Uri.parse("https://" + authority + path);
+    }
+
+    private static Uri getUri(AdTechIdentifier authority, String path) {
+        return getUri(authority.toString(), path);
+    }
+
+    private static Uri getValidDailyUpdateUriByBuyer(AdTechIdentifier buyer) {
+        return getUri(buyer, "/update");
+    }
+
+    private static Uri getValidTrustedBiddingUriByBuyer(AdTechIdentifier buyer) {
+        return getUri(buyer, VALID_TRUSTED_BIDDING_URI_PATH);
+    }
+
+    private static List<String> getValidTrustedBiddingKeys() {
+        return new ArrayList<>(VALID_TRUSTED_BIDDING_KEYS);
+    }
+
+    private static TrustedBiddingData getValidTrustedBiddingDataByBuyer(AdTechIdentifier buyer) {
+        return new TrustedBiddingData(
+                getValidTrustedBiddingUriByBuyer(buyer),
+                getValidTrustedBiddingKeys());
+    }
+
+    @RequiresApi(26)
+    private Instant getValidDelayedActivationTime() {
+        Duration maxActivationDelayIn =
+                Duration.ofMillis(FLEDGE_CUSTOM_AUDIENCE_MAX_ACTIVATION_DELAY_IN_MS);
+
+        return Instant.now()
+                .truncatedTo(ChronoUnit.MILLIS)
+                .plus(maxActivationDelayIn.dividedBy(2));
+    }
+
+    @RequiresApi(26)
+    private Instant getValidDelayedExpirationTime() {
+        return getValidDelayedActivationTime().plusSeconds(DAY_IN_SECONDS);
+    }
+
+    @RequiresApi(26)
+    private Instant getValidActivationTime() {
+        return Instant.now()
+                .truncatedTo(ChronoUnit.MILLIS);
+    }
+
+    @RequiresApi(26)
+    private Instant getValidExpirationTime() {
+        return getValidActivationTime()
+                .plus(Duration.ofMillis(FLEDGE_CUSTOM_AUDIENCE_DEFAULT_EXPIRE_IN_MS));
+    }
+
+
+    /**
+     * @param buyer The name of the buyer for this Custom Audience
+     * @param bids these bids, are added to its metadata. Our JS logic then picks this value and
+     *     creates ad with the provided value as bid
+     * @return a real Custom Audience object that can be persisted and used in bidding and scoring
+     */
+    private CustomAudience createCustomAudience(final AdTechIdentifier buyer, List<Double> bids) {
+        return createCustomAudience(
+                buyer,
+                bids,
+                getValidActivationTime(),
+                getValidExpirationTime(),
+                BUYER_BIDDING_LOGIC_URI_PATH);
+    }
+
+    private CustomAudience createCustomAudience(
+            final AdTechIdentifier buyer,
+            List<Double> bids,
+            Instant activationTime,
+            Instant expirationTime,
+            String biddingLogicUri) {
+        // Generate ads for with bids provided
+        List<AdData> ads = new ArrayList<>();
+
+        // Create ads with the buyer name and bid number as the ad URI
+        // Add the bid value to the metadata
+        for (int i = 0; i < bids.size(); i++) {
+            ads.add(
+                    new AdData(getUri(buyer, AD_URI_PREFIX + "/ad" + (i + 1)),
+                            "{\"bid\":" + bids.get(i) + "}"));
+        }
+
+        return new CustomAudience.Builder(
+                buyer,
+                buyer + VALID_NAME,
+                getValidDailyUpdateUriByBuyer(buyer),
+                getUri(buyer, biddingLogicUri),
+                ads)
+                .setActivationTime(activationTime)
+                .setExpirationTime(expirationTime)
+                .setUserBiddingSignals(VALID_USER_BIDDING_SIGNALS)
+                .setTrustedBiddingData(
+                        getValidTrustedBiddingDataByBuyer(buyer))
+                .build();
+    }
+
+    private static class CustomAudienceClient {
+        private final CustomAudienceManagerFutures mCustomAudienceManager;
+
+        CustomAudienceClient(Context context) {
+            mCustomAudienceManager = CustomAudienceManagerFutures.from(context);
+        }
+
+        public ListenableFuture<Unit> joinCustomAudience(CustomAudience customAudience) {
+            JoinCustomAudienceRequest request = new JoinCustomAudienceRequest(customAudience);
+            return mCustomAudienceManager
+                    .joinCustomAudienceAsync(request);
+        }
+    }
+
+    private static class AdSelectionClient {
+
+        private final AdSelectionManagerFutures mAdSelectionManager;
+
+        AdSelectionClient(Context context) {
+            mAdSelectionManager = AdSelectionManagerFutures.from(context);
+        }
+
+        /**
+         *  Invokes the {@code selectAds} method of {@link AdSelectionManager} and
+         *  returns a future with {@link AdSelectionOutcome}
+         */
+        public ListenableFuture<AdSelectionOutcome> selectAds(AdSelectionConfig adSelectionConfig)
+                throws Exception {
+            return mAdSelectionManager.selectAdsAsync(adSelectionConfig);
+        }
+
+        /**
+         * Invokes the {@code reportImpression} method of {@link AdSelectionManager} and returns
+         * a future with Unit
+         */
+        public ListenableFuture<Unit> reportImpression(ReportImpressionRequest input) {
+            return mAdSelectionManager.reportImpressionAsync(input);
+        }
+    }
+}
diff --git a/privacysandbox/ads/ads-adservices-java/src/androidTest/java/androidx/privacysandbox/ads/adservices/java/endtoend/TestUtil.java b/privacysandbox/ads/ads-adservices-java/src/androidTest/java/androidx/privacysandbox/ads/adservices/java/endtoend/TestUtil.java
index 6da172a..cb16955 100644
--- a/privacysandbox/ads/ads-adservices-java/src/androidTest/java/androidx/privacysandbox/ads/adservices/java/endtoend/TestUtil.java
+++ b/privacysandbox/ads/ads-adservices-java/src/androidTest/java/androidx/privacysandbox/ads/adservices/java/endtoend/TestUtil.java
@@ -104,6 +104,14 @@
         }
     }
 
+    public void overrideAppSetIdKillSwitch(boolean override) {
+        if (override) {
+            runShellCommand("setprop debug.adservices.appsetid_kill_switch " + false);
+        } else {
+            runShellCommand("setprop debug.adservices.appsetid_kill_switch " + null);
+        }
+    }
+
     // Override measurement related kill switch to ignore the effect of actual PH values.
     // If isOverride = true, override measurement related kill switch to OFF to allow adservices
     // If isOverride = false, override measurement related kill switch to meaningless value so that
@@ -150,6 +158,64 @@
         runShellCommand("setprop log.tag.adservices VERBOSE");
     }
 
+    public void overrideFledgeSelectAdsKillSwitch(boolean override) {
+        if (override) {
+            runShellCommand("setprop debug.adservices.fledge_select_ads_kill_switch " + false);
+        } else {
+            runShellCommand("setprop debug.adservices.fledge_select_ads_kill_switch " + null);
+        }
+    }
+
+    public void overrideFledgeCustomAudienceServiceKillSwitch(boolean override) {
+        if (override) {
+            runShellCommand("setprop debug.adservices.fledge_custom_audience_service_kill_switch "
+                    + false);
+        } else {
+            runShellCommand("setprop debug.adservices.fledge_custom_audience_service_kill_switch "
+                    + null);
+        }
+    }
+
+    public void overrideSdkRequestPermitsPerSecond(long maxRequests) {
+        runShellCommand("setprop debug.adservices.sdk_request_permits_per_second " + maxRequests);
+    }
+
+    public void disableDeviceConfigSyncForTests(boolean disabled) {
+        if (disabled) {
+            runShellCommand("device_config set_sync_disabled_for_tests persistent");
+        } else {
+            runShellCommand("device_config set_sync_disabled_for_tests none");
+        }
+    }
+
+    public void disableFledgeEnrollmentCheck(boolean disabled) {
+        if (disabled) {
+            runShellCommand("device_config put adservices disable_fledge_enrollment_check true");
+        } else {
+            runShellCommand("device_config put adservices disable_fledge_enrollment_check false");
+        }
+    }
+
+    public void enableAdServiceSystemService(boolean enabled) {
+        if (enabled) {
+            runShellCommand("device_config put adservices adservice_system_service_enabled "
+                    + "\"true\"");
+        } else {
+            runShellCommand("device_config put adservices adservice_system_service_enabled "
+                    + "\"false\"");
+        }
+    }
+
+    public void enforceFledgeJsIsolateMaxHeapSize(boolean enforce) {
+        if (enforce) {
+            runShellCommand("device_config put adservices fledge_js_isolate_enforce_max_heap_size"
+                    + " true");
+        } else {
+            runShellCommand("device_config put adservices fledge_js_isolate_enforce_max_heap_size"
+                    + " false");
+        }
+    }
+
     @SuppressWarnings("deprecation")
     // Used to get the package name. Copied over from com.android.adservices.AndroidServiceBinder
     public String getAdServicesPackageName() {
diff --git a/privacysandbox/ads/ads-adservices-java/src/androidTest/java/androidx/privacysandbox/ads/adservices/java/endtoend/appsetid/AppSetIdManagerTest.java b/privacysandbox/ads/ads-adservices-java/src/androidTest/java/androidx/privacysandbox/ads/adservices/java/endtoend/appsetid/AppSetIdManagerTest.java
new file mode 100644
index 0000000..88973750
--- /dev/null
+++ b/privacysandbox/ads/ads-adservices-java/src/androidTest/java/androidx/privacysandbox/ads/adservices/java/endtoend/appsetid/AppSetIdManagerTest.java
@@ -0,0 +1,66 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.privacysandbox.ads.adservices.java.endtoend.appsetid;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import androidx.privacysandbox.ads.adservices.appsetid.AppSetId;
+import androidx.privacysandbox.ads.adservices.internal.AdServicesInfo;
+import androidx.privacysandbox.ads.adservices.java.appsetid.AppSetIdManagerFutures;
+import androidx.privacysandbox.ads.adservices.java.endtoend.TestUtil;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.platform.app.InstrumentationRegistry;
+
+import org.junit.After;
+import org.junit.Assume;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+// TODO: Consider refactoring so that we're not duplicating code.
+public class AppSetIdManagerTest {
+    private static final String TAG = "AppSetIdManagerTest";
+    TestUtil mTestUtil = new TestUtil(InstrumentationRegistry.getInstrumentation(), TAG);
+
+    @Before
+    public void setup() throws Exception {
+        mTestUtil.overrideAppSetIdKillSwitch(true);
+        mTestUtil.overrideKillSwitches(true);
+        mTestUtil.overrideAllowlists(true);
+    }
+
+    @After
+    public void teardown() {
+        mTestUtil.overrideAppSetIdKillSwitch(false);
+        mTestUtil.overrideKillSwitches(false);
+        mTestUtil.overrideAllowlists(false);
+    }
+
+    @Test
+    public void testAppSetId() throws Exception {
+        // Skip the test if SDK extension 4 is not present.
+        Assume.assumeTrue(AdServicesInfo.INSTANCE.version() >= 4);
+
+        AppSetIdManagerFutures appSetIdManager =
+                AppSetIdManagerFutures.from(ApplicationProvider.getApplicationContext());
+        AppSetId appSetId = appSetIdManager.getAppSetIdAsync().get();
+        assertThat(appSetId.getId()).isNotEmpty();
+        assertThat(appSetId.getScope()).isNotNull();
+    }
+}
diff --git a/privacysandbox/ads/ads-adservices/src/androidTest/java/androidx/privacysandbox/ads/adservices/common/AdTechIdentifierTest.kt b/privacysandbox/ads/ads-adservices/src/androidTest/java/androidx/privacysandbox/ads/adservices/common/AdTechIdentifierTest.kt
index 00a98bb..229df56 100644
--- a/privacysandbox/ads/ads-adservices/src/androidTest/java/androidx/privacysandbox/ads/adservices/common/AdTechIdentifierTest.kt
+++ b/privacysandbox/ads/ads-adservices/src/androidTest/java/androidx/privacysandbox/ads/adservices/common/AdTechIdentifierTest.kt
@@ -29,7 +29,7 @@
 
     @Test
     fun testToString() {
-        val result = "AdTechIdentifier: $identifier"
+        val result = "$identifier"
         val request = AdTechIdentifier(identifier)
         Truth.assertThat(request.toString()).isEqualTo(result)
     }
diff --git a/privacysandbox/ads/ads-adservices/src/androidTest/java/androidx/privacysandbox/ads/adservices/customaudience/LeaveCustomAudienceTest.kt b/privacysandbox/ads/ads-adservices/src/androidTest/java/androidx/privacysandbox/ads/adservices/customaudience/LeaveCustomAudienceTest.kt
index 409f780..dc39bcd 100644
--- a/privacysandbox/ads/ads-adservices/src/androidTest/java/androidx/privacysandbox/ads/adservices/customaudience/LeaveCustomAudienceTest.kt
+++ b/privacysandbox/ads/ads-adservices/src/androidTest/java/androidx/privacysandbox/ads/adservices/customaudience/LeaveCustomAudienceTest.kt
@@ -30,7 +30,7 @@
     private val name = "abc"
     @Test
     fun testToString() {
-        val result = "LeaveCustomAudience: buyer=AdTechIdentifier: 1234, name=abc"
+        val result = "LeaveCustomAudience: buyer=1234, name=abc"
         val leaveCustomAudienceRequest = LeaveCustomAudienceRequest(adTechIdentifier, name)
         Truth.assertThat(leaveCustomAudienceRequest.toString()).isEqualTo(result)
     }
diff --git a/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/common/AdTechIdentifier.kt b/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/common/AdTechIdentifier.kt
index 775e14e..4180e13 100644
--- a/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/common/AdTechIdentifier.kt
+++ b/privacysandbox/ads/ads-adservices/src/main/java/androidx/privacysandbox/ads/adservices/common/AdTechIdentifier.kt
@@ -55,6 +55,6 @@
     /** @return The identifier in String form.
      */
     override fun toString(): String {
-        return "AdTechIdentifier: $identifier"
+        return "$identifier"
     }
 }
diff --git a/privacysandbox/ui/ui-client/src/androidTest/java/androidx/privacysandbox/ui/client/test/SandboxedSdkViewTest.kt b/privacysandbox/ui/ui-client/src/androidTest/java/androidx/privacysandbox/ui/client/test/SandboxedSdkViewTest.kt
index 34dc5ba..411ea68 100644
--- a/privacysandbox/ui/ui-client/src/androidTest/java/androidx/privacysandbox/ui/client/test/SandboxedSdkViewTest.kt
+++ b/privacysandbox/ui/ui-client/src/androidTest/java/androidx/privacysandbox/ui/client/test/SandboxedSdkViewTest.kt
@@ -19,6 +19,7 @@
 import android.content.Context
 import android.os.Build
 import android.view.View
+import android.view.ViewGroup
 import android.widget.LinearLayout
 import androidx.annotation.RequiresApi
 import androidx.privacysandbox.ui.client.view.SandboxedSdkView
@@ -32,6 +33,7 @@
 import java.util.concurrent.Executor
 import java.util.concurrent.TimeUnit
 import java.util.function.Consumer
+import org.junit.Assert.assertFalse
 import org.junit.Assert.assertTrue
 import org.junit.Assume.assumeTrue
 import org.junit.Before
@@ -148,7 +150,6 @@
         val testSandboxedUiAdapter = FailingTestSandboxedUiAdapter()
         val testErrorConsumer = TestErrorConsumer(latch)
         view.setSdkErrorConsumer(testErrorConsumer)
-        view.layoutParams = LinearLayout.LayoutParams(100, 100)
         view.setAdapter(testSandboxedUiAdapter)
 
         activity.runOnUiThread(Runnable {
@@ -192,4 +193,22 @@
             assertTrue(view.childCount == 0)
         })
     }
+
+    @Test
+    fun sandboxedSdkViewIsTransitionGroup() {
+        val view = SandboxedSdkView(context)
+        assertTrue("SandboxedSdkView isTransitionGroup by default", view.isTransitionGroup)
+    }
+
+    @Test
+    fun sandboxedSdkViewInflatesTransitionGroup() {
+        val activity = activityScenarioRule.withActivity { this }
+        val view = activity.layoutInflater.inflate(
+            R.layout.sandboxedsdkview_transition_group_false,
+            null
+        ) as ViewGroup
+        assertFalse(
+            "XML overrides SandboxedSdkView.isTransitionGroup", view.isTransitionGroup
+        )
+    }
 }
\ No newline at end of file
diff --git a/privacysandbox/ui/ui-client/src/androidTest/res/layout/sandboxedsdkview_transition_group_false.xml b/privacysandbox/ui/ui-client/src/androidTest/res/layout/sandboxedsdkview_transition_group_false.xml
new file mode 100644
index 0000000..c989f85
--- /dev/null
+++ b/privacysandbox/ui/ui-client/src/androidTest/res/layout/sandboxedsdkview_transition_group_false.xml
@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+  Copyright 2023 The Android Open Source Project
+
+  Licensed under the Apache License, Version 2.0 (the "License");
+  you may not use this file except in compliance with the License.
+  You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+  -->
+
+<androidx.privacysandbox.ui.client.view.SandboxedSdkView xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:transitionGroup="false">
+
+</androidx.privacysandbox.ui.client.view.SandboxedSdkView>
\ No newline at end of file
diff --git a/privacysandbox/ui/ui-client/src/main/java/androidx/privacysandbox/ui/client/view/SandboxedSdkView.kt b/privacysandbox/ui/ui-client/src/main/java/androidx/privacysandbox/ui/client/view/SandboxedSdkView.kt
index 161d618..d8c915b 100644
--- a/privacysandbox/ui/ui-client/src/main/java/androidx/privacysandbox/ui/client/view/SandboxedSdkView.kt
+++ b/privacysandbox/ui/ui-client/src/main/java/androidx/privacysandbox/ui/client/view/SandboxedSdkView.kt
@@ -38,6 +38,7 @@
     private var isZOrderOnTop = true
     private var errorConsumer: Consumer<Throwable>? = null
     private var contentView: View? = null
+    private var isTransitionGroupSet = false
 
     fun setAdapter(sandboxedUiAdapter: SandboxedUiAdapter) {
         if (this.adapter === sandboxedUiAdapter) return
@@ -51,6 +52,13 @@
         this.errorConsumer = errorConsumer
     }
 
+    override fun isTransitionGroup(): Boolean = !isTransitionGroupSet || super.isTransitionGroup()
+
+    override fun setTransitionGroup(isTransitionGroup: Boolean) {
+        super.setTransitionGroup(isTransitionGroup)
+        isTransitionGroupSet = true
+    }
+
     override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
         getChildAt(0)?.layout(left, top, right, bottom)
         checkClientOpenSession()
@@ -67,16 +75,6 @@
         super.onDetachedFromWindow()
     }
 
-    override fun onSizeChanged(
-        width: Int,
-        height: Int,
-        oldWidth: Int,
-        oldHeight: Int
-    ) {
-        super.onSizeChanged(width, height, oldWidth, oldHeight)
-        checkClientOpenSession()
-    }
-
     private fun checkClientOpenSession() {
         val adapter = adapter
         if (client == null && adapter != null && isAttachedToWindow && width > 0 && height > 0) {
diff --git a/recyclerview/recyclerview/src/androidTest/java/androidx/recyclerview/widget/AnchorTest.kt b/recyclerview/recyclerview/src/androidTest/java/androidx/recyclerview/widget/AnchorTest.kt
deleted file mode 100644
index 4f3f8e7..0000000
--- a/recyclerview/recyclerview/src/androidTest/java/androidx/recyclerview/widget/AnchorTest.kt
+++ /dev/null
@@ -1,144 +0,0 @@
-/*
- * Copyright 2022 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package androidx.recyclerview.widget
-
-import android.view.View
-import android.view.ViewGroup
-import android.widget.FrameLayout
-import androidx.test.ext.junit.runners.AndroidJUnit4
-import androidx.test.filters.MediumTest
-import androidx.test.platform.app.InstrumentationRegistry
-import com.google.common.truth.Truth.assertThat
-import kotlin.coroutines.resume
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.runBlocking
-import kotlinx.coroutines.suspendCancellableCoroutine
-import org.junit.Test
-import org.junit.runner.RunWith
-
-@MediumTest
-@RunWith(AndroidJUnit4::class)
-class AnchorTest : BaseRecyclerViewInstrumentationTest() {
-
-    @Test
-    fun noAnchoringWhenViewportNotFilled(): Unit = runBlocking(Dispatchers.Main) {
-        val mainAdapter = AnchorTestAdapter(0)
-        // This simulates a loading spinning added to the end via ConcatAdapter, such as Paging does
-        val suffixAdapter = AnchorTestAdapter(1)
-        val concatAdapter = ConcatAdapter(mainAdapter, suffixAdapter)
-
-        val context = InstrumentationRegistry.getInstrumentation().context
-        val layoutManager = LinearLayoutManager(
-            context,
-            LinearLayoutManager.VERTICAL,
-            false
-        )
-        mRecyclerView = RecyclerView(context)
-        mRecyclerView.layoutParams = TestedFrameLayout.FullControlLayoutParams(100, 100)
-        mRecyclerView.adapter = concatAdapter
-        mRecyclerView.layoutManager = layoutManager
-        activity.container.addView(mRecyclerView)
-        mRecyclerView.awaitLayout()
-
-        assertThat(layoutManager.findFirstVisibleItemPosition()).isEqualTo(0)
-        assertThat(layoutManager.findLastVisibleItemPosition()).isEqualTo(0)
-
-        mainAdapter.numItems = 20
-        mRecyclerView.awaitLayout()
-        // Before this was fixed, this would anchor to the "spinner" and end up at the
-        // bottom of the list (first visible item would be 11)
-        assertThat(layoutManager.findFirstVisibleItemPosition()).isEqualTo(0)
-        assertThat(layoutManager.findLastVisibleItemPosition()).isEqualTo(9)
-    }
-
-    @Test
-    fun noAnchoringWhenFillingWrapContentRecyclerView(): Unit = runBlocking(Dispatchers.Main) {
-        val mainAdapter = AnchorTestAdapter(0)
-        // This simulates a loading spinning added to the end via ConcatAdapter, such as Paging does
-        val suffixAdapter = AnchorTestAdapter(1)
-        val concatAdapter = ConcatAdapter(mainAdapter, suffixAdapter)
-
-        val context = InstrumentationRegistry.getInstrumentation().context
-        val layoutManager = LinearLayoutManager(
-            context,
-            LinearLayoutManager.VERTICAL,
-            false
-        )
-
-        val frame = FrameLayout(context)
-        frame.layoutParams = TestedFrameLayout.FullControlLayoutParams(100, 100)
-        mRecyclerView = RecyclerView(context)
-        mRecyclerView.layoutParams =
-            FrameLayout.LayoutParams(100, ViewGroup.LayoutParams.WRAP_CONTENT)
-        mRecyclerView.adapter = concatAdapter
-        mRecyclerView.layoutManager = layoutManager
-        frame.addView(mRecyclerView)
-        activity.container.addView(frame)
-        mRecyclerView.awaitLayout()
-
-        assertThat(layoutManager.findFirstVisibleItemPosition()).isEqualTo(0)
-        assertThat(layoutManager.findLastVisibleItemPosition()).isEqualTo(0)
-
-        mainAdapter.numItems = 20
-        mRecyclerView.awaitLayout()
-
-        // Before this was fixed, this would anchor to the "spinner" and end up at the
-        // bottom of the list (first visible item would be 11)
-        assertThat(layoutManager.findFirstVisibleItemPosition()).isEqualTo(0)
-        assertThat(layoutManager.findLastVisibleItemPosition()).isEqualTo(9)
-    }
-
-    private class AnchorTestAdapter(length: Int) :
-        RecyclerView.Adapter<AnchorTestAdapter.ViewHolder>() {
-        class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView)
-
-        var numItems: Int = length
-            set(value) {
-                val old = field
-                val diff = value - old
-                if (diff < 0) {
-                    TODO("Length reduction not supported")
-                }
-                field = value
-                notifyItemRangeInserted(old, diff)
-            }
-
-        override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
-            val lp = RecyclerView.LayoutParams(10, 10)
-            val v = View(parent.context)
-            v.layoutParams = lp
-            return ViewHolder(v)
-        }
-
-        override fun getItemCount(): Int = numItems
-
-        override fun onBindViewHolder(holder: ViewHolder, position: Int) {
-        }
-    }
-}
-
-private suspend fun View.awaitLayout() {
-    suspendCancellableCoroutine { continuation ->
-        val runnable = Runnable {
-            continuation.resume(Unit)
-        }
-        continuation.invokeOnCancellation {
-            this.removeCallbacks(runnable)
-        }
-        this.post(runnable)
-    }
-}
\ No newline at end of file
diff --git a/recyclerview/recyclerview/src/androidTest/java/androidx/recyclerview/widget/StaggeredGridLayoutManagerSnappingTest.java b/recyclerview/recyclerview/src/androidTest/java/androidx/recyclerview/widget/StaggeredGridLayoutManagerSnappingTest.java
index f602340..82c5bf9 100644
--- a/recyclerview/recyclerview/src/androidTest/java/androidx/recyclerview/widget/StaggeredGridLayoutManagerSnappingTest.java
+++ b/recyclerview/recyclerview/src/androidTest/java/androidx/recyclerview/widget/StaggeredGridLayoutManagerSnappingTest.java
@@ -26,6 +26,7 @@
 import androidx.annotation.Nullable;
 import androidx.test.filters.LargeTest;
 
+import org.junit.Ignore;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.junit.runners.Parameterized;
@@ -71,6 +72,7 @@
         }
     }
 
+    @Ignore // b/269351778
     @Test
     public void snapOnScrollSameViewFixedSize() throws Throwable {
         // This test is a special case for fixed sized children.
diff --git a/recyclerview/recyclerview/src/main/java/androidx/recyclerview/widget/LinearLayoutManager.java b/recyclerview/recyclerview/src/main/java/androidx/recyclerview/widget/LinearLayoutManager.java
index da23dcc..78e4f87 100644
--- a/recyclerview/recyclerview/src/main/java/androidx/recyclerview/widget/LinearLayoutManager.java
+++ b/recyclerview/recyclerview/src/main/java/androidx/recyclerview/widget/LinearLayoutManager.java
@@ -91,30 +91,6 @@
      */
     private boolean mLastStackFromEnd;
 
-    /**
-     * Whether the last layout filled the entire viewport
-     *
-     * If the last layout did not fill the viewport, we should not attempt to calculate an
-     * anchoring based on the current children (other than if one is focused), because there
-     * isn't any scrolling that could have occurred that would indicate a position in the list
-     * that needs to be preserved - and in fact, trying to do so could produce the wrong result,
-     * such as the case of anchoring to a loading spinner at the end of the list.
-     */
-    private boolean mLastLayoutFilledViewport = false;
-
-    /**
-     * Whether the *current* layout filled the entire viewport
-     *
-     * This is used to populate mLastLayoutFilledViewport. It exists as a separate variable
-     * because we need to populate it at the correct moment, which is tricky due to the
-     * LayoutManager layout being called multiple times.  We want to not set it in prelayout
-     * (because that's not the real layout), but we want to set it the *first* time that the
-     * actual layout is run, because for certain non-exact layout cases, there are two passes,
-     * with the second pass being provided an EXACTLY spec (when the actual spec was non-exact).
-     * This would otherwise incorrectly believe the viewport was filled, because it was provided
-     * just enough space to contain the content, and thus it would always fill the viewport.
-     */
-    private Boolean mThisLayoutFilledViewport = null;
 
     /**
      * Defines if layout should be calculated from end to start.
@@ -805,10 +781,6 @@
                 fixOffset = fixLayoutStartGap(startOffset, recycler, state, false);
                 startOffset += fixOffset;
                 endOffset += fixOffset;
-                if (!state.isPreLayout() && mThisLayoutFilledViewport == null) {
-                    mThisLayoutFilledViewport =
-                            (startOffset <= mOrientationHelper.getStartAfterPadding());
-                }
             } else {
                 int fixOffset = fixLayoutStartGap(startOffset, recycler, state, true);
                 startOffset += fixOffset;
@@ -816,10 +788,6 @@
                 fixOffset = fixLayoutEndGap(endOffset, recycler, state, false);
                 startOffset += fixOffset;
                 endOffset += fixOffset;
-                if (!state.isPreLayout() && mThisLayoutFilledViewport == null) {
-                    mThisLayoutFilledViewport =
-                            (endOffset >= mOrientationHelper.getEndAfterPadding());
-                }
             }
         }
         layoutForPredictiveAnimations(recycler, state, startOffset, endOffset);
@@ -841,8 +809,6 @@
         mPendingSavedState = null; // we don't need this anymore
         mPendingScrollPosition = RecyclerView.NO_POSITION;
         mPendingScrollPositionOffset = INVALID_OFFSET;
-        mLastLayoutFilledViewport = mThisLayoutFilledViewport != null && mThisLayoutFilledViewport;
-        mThisLayoutFilledViewport = null;
         mAnchorInfo.reset();
     }
 
@@ -952,19 +918,11 @@
         if (getChildCount() == 0) {
             return false;
         }
-
         final View focused = getFocusedChild();
         if (focused != null && anchorInfo.isViewValidAsAnchor(focused, state)) {
             anchorInfo.assignFromViewAndKeepVisibleRect(focused, getPosition(focused));
             return true;
         }
-
-        // If we did not fill the layout, don't anchor. This prevents, for example,
-        // anchoring to the bottom of the list when there is a loading indicator.
-        if (!mLastLayoutFilledViewport) {
-            return false;
-        }
-
         if (mLastStackFromEnd != mStackFromEnd) {
             return false;
         }
diff --git a/room/room-compiler-processing/src/test/java/androidx/room/compiler/processing/XAnnotationTest.kt b/room/room-compiler-processing/src/test/java/androidx/room/compiler/processing/XAnnotationTest.kt
index 2b79b8b..973e82a 100644
--- a/room/room-compiler-processing/src/test/java/androidx/room/compiler/processing/XAnnotationTest.kt
+++ b/room/room-compiler-processing/src/test/java/androidx/room/compiler/processing/XAnnotationTest.kt
@@ -28,6 +28,7 @@
 import androidx.room.compiler.processing.testcode.MainAnnotation
 import androidx.room.compiler.processing.testcode.OtherAnnotation
 import androidx.room.compiler.processing.testcode.RepeatableJavaAnnotation
+import androidx.room.compiler.processing.testcode.RepeatableKotlinAnnotation
 import androidx.room.compiler.processing.testcode.TestSuppressWarnings
 import androidx.room.compiler.processing.util.Source
 import androidx.room.compiler.processing.util.XTestInvocation
@@ -833,7 +834,7 @@
     }
 
     @Test
-    fun javaRepeatableAnnotation() {
+    fun repeatableAnnotation() {
         val javaSrc = Source.java(
             "JavaSubject",
             """
@@ -847,14 +848,10 @@
         val kotlinSrc = Source.kotlin(
             "KotlinSubject.kt",
             """
-            import ${RepeatableJavaAnnotation::class.qualifiedName}
-            // TODO update when https://youtrack.jetbrains.com/issue/KT-12794 is fixed.
-            // right now, kotlin does not support repeatable annotations.
-            @RepeatableJavaAnnotation.List(
-                RepeatableJavaAnnotation("x"),
-                RepeatableJavaAnnotation("y"),
-                RepeatableJavaAnnotation("z")
-            )
+            import ${RepeatableKotlinAnnotation::class.qualifiedName}
+            @RepeatableKotlinAnnotation("x")
+            @RepeatableKotlinAnnotation("y")
+            @RepeatableKotlinAnnotation("z")
             public class KotlinSubject
             """.trimIndent()
         )
@@ -865,7 +862,8 @@
                 .map(invocation.processingEnv::requireTypeElement)
                 .forEach { subject ->
                     val annotations = subject.getAllAnnotations().filter {
-                        it.name == "RepeatableJavaAnnotation"
+                        it.name == "RepeatableJavaAnnotation" ||
+                            it.name == "RepeatableKotlinAnnotation"
                     }
                     val values = annotations.map { it.get<String>("value") }
                     assertWithMessage(subject.qualifiedName)
@@ -911,6 +909,33 @@
     }
 
     @Test
+    fun kotlinRepeatableAnnotation_notRepeated() {
+        val kotlinSrc = Source.kotlin(
+            "KotlinSubject.kt",
+            """
+            import ${RepeatableKotlinAnnotation::class.qualifiedName}
+            @RepeatableKotlinAnnotation("x")
+            public class KotlinSubject
+            """.trimIndent()
+        )
+        runTest(
+            sources = listOf(kotlinSrc)
+        ) { invocation ->
+            listOf("KotlinSubject")
+                .map(invocation.processingEnv::requireTypeElement)
+                .forEach { subject ->
+                    val annotations = subject.getAllAnnotations().filter {
+                        it.name == "RepeatableKotlinAnnotation"
+                    }
+                    val values = annotations.map { it.get<String>("value") }
+                    assertWithMessage(subject.qualifiedName)
+                        .that(values)
+                        .containsExactly("x")
+                }
+        }
+    }
+
+    @Test
     fun typealiasAnnotation() {
         val source = Source.kotlin(
             "Subject.kt",
diff --git a/room/room-compiler-processing/src/test/java/androidx/room/compiler/processing/testcode/RepeatableKotlinAnnotation.kt b/room/room-compiler-processing/src/test/java/androidx/room/compiler/processing/testcode/RepeatableKotlinAnnotation.kt
new file mode 100644
index 0000000..39ab436
--- /dev/null
+++ b/room/room-compiler-processing/src/test/java/androidx/room/compiler/processing/testcode/RepeatableKotlinAnnotation.kt
@@ -0,0 +1,5 @@
+package androidx.room.compiler.processing.testcode
+
+@Retention(AnnotationRetention.RUNTIME)
+@Repeatable
+annotation class RepeatableKotlinAnnotation(val value: String)
diff --git a/settings.gradle b/settings.gradle
index 016191f..3cb42bb 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -945,6 +945,7 @@
 includeProject(":wear:compose:compose-material3-samples", "wear/compose/compose-material3/samples", [BuildType.COMPOSE])
 includeProject(":wear:compose:compose-navigation", [BuildType.COMPOSE])
 includeProject(":wear:compose:compose-navigation-samples", "wear/compose/compose-navigation/samples", [BuildType.COMPOSE])
+includeProject(":wear:compose:compose-ui-tooling", [BuildType.COMPOSE])
 includeProject(":wear:compose:integration-tests:demos", [BuildType.COMPOSE])
 includeProject(":wear:compose:integration-tests:macrobenchmark", [BuildType.COMPOSE])
 includeProject(":wear:compose:integration-tests:macrobenchmark-target", [BuildType.COMPOSE])
diff --git a/stableaidl/stableaidl-gradle-plugin/build.gradle b/stableaidl/stableaidl-gradle-plugin/build.gradle
index 4d56951..9066c1c 100644
--- a/stableaidl/stableaidl-gradle-plugin/build.gradle
+++ b/stableaidl/stableaidl-gradle-plugin/build.gradle
@@ -32,6 +32,7 @@
     implementation(libs.androidToolsRepository)
     implementation(libs.androidToolsSdkCommon)
     implementation(libs.apacheCommonIo)
+    implementation(libs.apacheAnt)
     implementation(libs.guava)
     implementation(libs.kotlinStdlib)
 
diff --git a/stableaidl/stableaidl-gradle-plugin/src/main/java/androidx/stableaidl/StableAidlPlugin.kt b/stableaidl/stableaidl-gradle-plugin/src/main/java/androidx/stableaidl/StableAidlPlugin.kt
index ea681e8..a0a6848 100644
--- a/stableaidl/stableaidl-gradle-plugin/src/main/java/androidx/stableaidl/StableAidlPlugin.kt
+++ b/stableaidl/stableaidl-gradle-plugin/src/main/java/androidx/stableaidl/StableAidlPlugin.kt
@@ -17,6 +17,7 @@
 package androidx.stableaidl
 
 import androidx.stableaidl.tasks.StableAidlCompile
+import com.android.build.api.dsl.SdkComponents
 import com.android.build.api.variant.AndroidComponentsExtension
 import com.android.build.api.variant.DslExtension
 import com.android.build.gradle.AppExtension
@@ -27,11 +28,13 @@
 import org.gradle.api.Plugin
 import org.gradle.api.Project
 import org.gradle.api.file.Directory
+import org.gradle.api.file.RegularFile
 import org.gradle.api.provider.Provider
 import org.gradle.api.tasks.TaskProvider
 
-private const val PLUGIN_DIRNAME = "stable-aidl"
+private const val PLUGIN_DIRNAME = "stable_aidl"
 private const val GENERATED_PATH = "generated/source/$PLUGIN_DIRNAME"
+private const val INTERMEDIATES_PATH = "intermediates/${PLUGIN_DIRNAME}_parcelable"
 
 @Suppress("unused", "UnstableApiUsage")
 abstract class StableAidlPlugin : Plugin<Project> {
@@ -41,6 +44,8 @@
             ?: throw GradleException("Stable AIDL plugin requires Android Gradle Plugin")
         val base = project.extensions.getByType(BaseExtension::class.java)
             ?: throw GradleException("Stable AIDL plugin requires Android Gradle Plugin")
+        val aidlExecutable = androidComponents.sdkComponents.aidl(base)
+        val aidlFramework = androidComponents.sdkComponents.aidlFramework(base)
 
         // Extend the android sourceSet.
         androidComponents.registerSourceType(SOURCE_TYPE_STABLE_AIDL)
@@ -74,7 +79,11 @@
         androidComponents.onVariants { variant ->
             val sourceDir = variant.sources.getByName(SOURCE_TYPE_STABLE_AIDL)
             val importsDir = variant.sources.getByName(SOURCE_TYPE_STABLE_AIDL_IMPORTS)
-            val outputDir = project.layout.buildDirectory.dir("$GENERATED_PATH/${variant.name}")
+            val outputDir = project.layout.buildDirectory.dir(
+                "$GENERATED_PATH/${variant.name}")
+            val packagedDir = project.layout.buildDirectory.dir(
+                "$INTERMEDIATES_PATH/${variant.name}/out")
+
             val apiDirName = "$API_DIR/aidl${variant.name.usLocaleCapitalize()}"
             val builtApiDir = project.layout.buildDirectory.dir(apiDirName)
             val lastReleasedApiDir =
@@ -84,16 +93,28 @@
 
             val compileAidlApiTask = registerCompileAidlApi(
                 project,
-                base,
                 variant,
+                aidlExecutable,
+                aidlFramework,
                 sourceDir,
+                packagedDir,
                 importsDir,
                 outputDir
             )
+
+            // To avoid using the same output directory as AGP's AidlCompile task, we need to
+            // register a post-processing task to copy packaged parcelable headers into the AAR.
+            registerPackageAidlApi(
+                project,
+                variant,
+                compileAidlApiTask
+            )
+
             val generateAidlApiTask = registerGenerateAidlApi(
                 project,
-                base,
                 variant,
+                aidlExecutable,
+                aidlFramework,
                 sourceDir,
                 importsDir,
                 builtApiDir,
@@ -101,16 +122,18 @@
             )
             val checkAidlApiReleaseTask = registerCheckApiAidlRelease(
                 project,
-                base,
                 variant,
+                aidlExecutable,
+                aidlFramework,
                 importsDir,
                 lastReleasedApiDir,
                 generateAidlApiTask
             )
             registerCheckAidlApi(
                 project,
-                base,
                 variant,
+                aidlExecutable,
+                aidlFramework,
                 importsDir,
                 lastCheckedInApiDir,
                 generateAidlApiTask,
@@ -172,3 +195,21 @@
         "androidx.stableaidl plugin must be used with Android app, library or feature plugin"
     )
 }
+
+internal fun SdkComponents.aidl(baseExtension: BaseExtension): Provider<RegularFile> =
+    sdkDirectory.map {
+        it.dir("build-tools").dir(baseExtension.buildToolsVersion).file(
+            if (java.lang.System.getProperty("os.name").startsWith("Windows")) {
+                "aidl.exe"
+            } else {
+                "aidl"
+            }
+        )
+    }
+
+internal fun SdkComponents.aidlFramework(baseExtension: BaseExtension): Provider<RegularFile> =
+    sdkDirectory.map {
+        it.dir("platforms")
+            .dir(baseExtension.compileSdkVersion!!)
+            .file("framework.aidl")
+    }
diff --git a/stableaidl/stableaidl-gradle-plugin/src/main/java/androidx/stableaidl/StableAidlTasks.kt b/stableaidl/stableaidl-gradle-plugin/src/main/java/androidx/stableaidl/StableAidlTasks.kt
index ff8b9ee..733c9eb 100644
--- a/stableaidl/stableaidl-gradle-plugin/src/main/java/androidx/stableaidl/StableAidlTasks.kt
+++ b/stableaidl/stableaidl-gradle-plugin/src/main/java/androidx/stableaidl/StableAidlTasks.kt
@@ -16,17 +16,19 @@
 
 package androidx.stableaidl
 
+import androidx.stableaidl.tasks.StableAidlPackageApi
 import androidx.stableaidl.tasks.StableAidlCheckApi
 import androidx.stableaidl.tasks.StableAidlCompile
 import androidx.stableaidl.tasks.UpdateStableAidlApiTask
+import com.android.build.api.artifact.SingleArtifact
 import com.android.build.api.variant.SourceDirectories
 import com.android.build.api.variant.Variant
-import com.android.build.gradle.BaseExtension
 import com.android.utils.usLocaleCapitalize
 import java.io.File
 import org.gradle.api.Project
 import org.gradle.api.Task
 import org.gradle.api.file.Directory
+import org.gradle.api.file.RegularFile
 import org.gradle.api.provider.Provider
 import org.gradle.api.tasks.TaskProvider
 
@@ -35,9 +37,11 @@
 @Suppress("UnstableApiUsage") // SourceDirectories.Flat
 fun registerCompileAidlApi(
     project: Project,
-    baseExtension: BaseExtension,
     variant: Variant,
+    aidlExecutable: Provider<RegularFile>,
+    aidlFramework: Provider<RegularFile>,
     sourceDir: SourceDirectories.Flat,
+    packagedDir: Provider<Directory>,
     importsDir: SourceDirectories.Flat,
     outputDir: Provider<Directory>
 ): TaskProvider<StableAidlCompile> = project.tasks.register(
@@ -47,10 +51,11 @@
     task.group = TASK_GROUP_API
     task.description = "Compiles AIDL source code"
     task.variantName = variant.name
-    task.configureBuildToolsFrom(baseExtension)
-    task.configurePackageDirFrom(project, variant)
+    task.aidlExecutable.set(aidlExecutable)
+    task.aidlFrameworkProvider.set(aidlFramework)
     task.sourceDirs.set(sourceDir.all)
     task.sourceOutputDir.set(outputDir)
+    task.packagedDir.set(packagedDir)
     task.importDirs.set(importsDir.all)
     task.extraArgs.set(
         listOf(
@@ -59,11 +64,30 @@
     )
 }
 
+fun registerPackageAidlApi(
+    project: Project,
+    variant: Variant,
+    compileAidlApiTask: TaskProvider<StableAidlCompile>
+): TaskProvider<StableAidlPackageApi> = project.tasks.register(
+    computeTaskName("package", variant, "AidlApi"),
+    StableAidlPackageApi::class.java
+) { task ->
+    task.packagedDir.set(compileAidlApiTask.flatMap { it.packagedDir })
+}.also { taskProvider ->
+    variant.artifacts.use(taskProvider)
+        .wiredWithFiles(
+            StableAidlPackageApi::aarFile,
+            StableAidlPackageApi::updatedAarFile,
+        )
+        .toTransform(SingleArtifact.AAR)
+}
+
 @Suppress("UnstableApiUsage") // SourceDirectories.Flat
 fun registerGenerateAidlApi(
     project: Project,
-    baseExtension: BaseExtension,
     variant: Variant,
+    aidlExecutable: Provider<RegularFile>,
+    aidlFramework: Provider<RegularFile>,
     sourceDir: SourceDirectories.Flat,
     importsDir: SourceDirectories.Flat,
     builtApiDir: Provider<Directory>,
@@ -75,8 +99,8 @@
     task.group = TASK_GROUP_API
     task.description = "Generates API files from AIDL source code"
     task.variantName = variant.name
-    task.configureBuildToolsFrom(baseExtension)
-    task.configurePackageDirFrom(project, variant)
+    task.aidlExecutable.set(aidlExecutable)
+    task.aidlFrameworkProvider.set(aidlFramework)
     task.sourceDirs.set(sourceDir.all)
     task.sourceOutputDir.set(builtApiDir)
     task.importDirs.set(importsDir.all)
@@ -95,8 +119,9 @@
 @Suppress("UnstableApiUsage") // SourceDirectories.Flat
 fun registerCheckApiAidlRelease(
     project: Project,
-    baseExtension: BaseExtension,
     variant: Variant,
+    aidlExecutable: Provider<RegularFile>,
+    aidlFramework: Provider<RegularFile>,
     importsDir: SourceDirectories.Flat,
     lastReleasedApiDir: Directory,
     generateAidlTask: Provider<StableAidlCompile>
@@ -108,7 +133,8 @@
     task.description = "Checks the AIDL source code API surface against the " +
         "stabilized AIDL API files"
     task.variantName = variant.name
-    task.configureBuildToolsFrom(baseExtension)
+    task.aidlExecutable.set(aidlExecutable)
+    task.aidlFrameworkProvider.set(aidlFramework)
     task.importDirs.set(importsDir.all)
     task.checkApiMode.set(StableAidlCheckApi.MODE_COMPATIBLE)
     task.expectedApiDir.set(lastReleasedApiDir)
@@ -123,8 +149,9 @@
 @Suppress("UnstableApiUsage") // SourceDirectories.Flat
 fun registerCheckAidlApi(
     project: Project,
-    baseExtension: BaseExtension,
     variant: Variant,
+    aidlExecutable: Provider<RegularFile>,
+    aidlFramework: Provider<RegularFile>,
     importsDir: SourceDirectories.Flat,
     lastCheckedInApiFile: Directory,
     generateAidlTask: Provider<StableAidlCompile>,
@@ -137,7 +164,8 @@
     task.description = "Checks the AIDL source code API surface against the checked-in " +
         "AIDL API files"
     task.variantName = variant.name
-    task.configureBuildToolsFrom(baseExtension)
+    task.aidlExecutable.set(aidlExecutable)
+    task.aidlFrameworkProvider.set(aidlFramework)
     task.importDirs.set(importsDir.all)
     task.checkApiMode.set(StableAidlCheckApi.MODE_EQUAL)
     task.expectedApiDir.set(lastCheckedInApiFile)
diff --git a/stableaidl/stableaidl-gradle-plugin/src/main/java/androidx/stableaidl/tasks/StableAidlCheckApi.kt b/stableaidl/stableaidl-gradle-plugin/src/main/java/androidx/stableaidl/tasks/StableAidlCheckApi.kt
index da56750..5b52a95 100644
--- a/stableaidl/stableaidl-gradle-plugin/src/main/java/androidx/stableaidl/tasks/StableAidlCheckApi.kt
+++ b/stableaidl/stableaidl-gradle-plugin/src/main/java/androidx/stableaidl/tasks/StableAidlCheckApi.kt
@@ -18,9 +18,6 @@
 
 import androidx.stableaidl.internal.LoggerWrapper
 import androidx.stableaidl.internal.process.GradleProcessExecutor
-import com.android.build.gradle.BaseExtension
-import com.android.build.gradle.internal.BuildToolsExecutableInput
-import com.android.build.gradle.internal.services.getBuildService
 import com.android.ide.common.process.LoggedProcessOutputHandler
 import com.google.common.annotations.VisibleForTesting
 import java.io.File
@@ -33,14 +30,12 @@
 import org.gradle.api.file.RegularFileProperty
 import org.gradle.api.provider.ListProperty
 import org.gradle.api.provider.Property
-import org.gradle.api.provider.Provider
 import org.gradle.api.tasks.CacheableTask
 import org.gradle.api.tasks.Input
 import org.gradle.api.tasks.InputDirectory
 import org.gradle.api.tasks.InputFile
 import org.gradle.api.tasks.InputFiles
 import org.gradle.api.tasks.Internal
-import org.gradle.api.tasks.Nested
 import org.gradle.api.tasks.Optional
 import org.gradle.api.tasks.PathSensitive
 import org.gradle.api.tasks.PathSensitivity
@@ -66,10 +61,13 @@
     @get:PathSensitive(PathSensitivity.RELATIVE)
     abstract val importDirs: ListProperty<Directory>
 
-    @InputFile
-    @PathSensitive(PathSensitivity.NONE)
-    fun getAidlFrameworkProvider(): Provider<File> =
-        buildTools.aidlFrameworkProvider()
+    @get:InputFile
+    @get:PathSensitive(PathSensitivity.NONE)
+    abstract val aidlFrameworkProvider: RegularFileProperty
+
+    @get:InputFile
+    @get:PathSensitive(PathSensitivity.NONE)
+    abstract val aidlExecutable: RegularFileProperty
 
     // We cannot use InputDirectory here because the directory may not exist yet.
     @get:InputFiles
@@ -83,9 +81,6 @@
     @get:Input
     abstract val checkApiMode: Property<String>
 
-    @get:Nested
-    abstract val buildTools: BuildToolsExecutableInput
-
     @get:Input
     @get:Optional
     abstract val failOnMissingExpected: Property<Boolean>
@@ -97,23 +92,8 @@
     @get:Inject
     abstract val workerExecutor: WorkerExecutor
 
-    /**
-     * Configures build tools based on AGP's [BaseExtension].
-     */
-    fun configureBuildToolsFrom(baseExtension: BaseExtension) {
-        buildTools.buildToolsRevision.set(baseExtension.buildToolsRevision)
-        buildTools.compileSdkVersion.set(baseExtension.compileSdkVersion)
-        buildTools.sdkBuildService.set(getBuildService(project.gradle.sharedServices))
-    }
-
     @TaskAction
     fun checkApi() {
-        val aidlExecutable = buildTools
-            .aidlExecutableProvider()
-            .get()
-            .absoluteFile
-        val frameworkLocation = getAidlFrameworkProvider().get().absoluteFile
-
         val checkApiMode = checkApiMode.get()
         val expectedApiDir = expectedApiDir.get()
         val actualApiDir = actualApiDir.get()
@@ -133,8 +113,8 @@
 
         aidlCheckApiDelegate(
             workerExecutor,
-            aidlExecutable,
-            frameworkLocation,
+            aidlExecutable.get().asFile,
+            aidlFrameworkProvider.get().asFile,
             extraArgs,
             importDirs.get()
         )
diff --git a/stableaidl/stableaidl-gradle-plugin/src/main/java/androidx/stableaidl/tasks/StableAidlCompile.kt b/stableaidl/stableaidl-gradle-plugin/src/main/java/androidx/stableaidl/tasks/StableAidlCompile.kt
index dfc62a9..6dd4c09 100644
--- a/stableaidl/stableaidl-gradle-plugin/src/main/java/androidx/stableaidl/tasks/StableAidlCompile.kt
+++ b/stableaidl/stableaidl-gradle-plugin/src/main/java/androidx/stableaidl/tasks/StableAidlCompile.kt
@@ -19,16 +19,12 @@
 import androidx.stableaidl.internal.DirectoryWalker
 import androidx.stableaidl.internal.LoggerWrapper
 import androidx.stableaidl.internal.process.GradleProcessExecutor
-import com.android.build.api.variant.Variant
-import com.android.build.gradle.BaseExtension
 import com.android.build.gradle.internal.BuildToolsExecutableInput
 import com.android.build.gradle.internal.services.getBuildService
-import com.android.build.gradle.tasks.AidlCompile
 import com.android.builder.compiling.DependencyFileProcessor
 import com.android.builder.internal.incremental.DependencyData
 import com.android.ide.common.process.LoggedProcessOutputHandler
 import com.android.utils.FileUtils
-import com.android.utils.usLocaleCapitalize
 import com.google.common.annotations.VisibleForTesting
 import java.io.File
 import java.io.IOException
@@ -36,20 +32,17 @@
 import java.nio.file.Path
 import javax.inject.Inject
 import org.gradle.api.DefaultTask
-import org.gradle.api.Project
 import org.gradle.api.file.ConfigurableFileCollection
 import org.gradle.api.file.Directory
 import org.gradle.api.file.DirectoryProperty
 import org.gradle.api.file.RegularFileProperty
 import org.gradle.api.provider.ListProperty
 import org.gradle.api.provider.Property
-import org.gradle.api.provider.Provider
 import org.gradle.api.tasks.CacheableTask
 import org.gradle.api.tasks.Input
 import org.gradle.api.tasks.InputFile
 import org.gradle.api.tasks.InputFiles
 import org.gradle.api.tasks.Internal
-import org.gradle.api.tasks.Nested
 import org.gradle.api.tasks.Optional
 import org.gradle.api.tasks.OutputDirectory
 import org.gradle.api.tasks.PathSensitive
@@ -93,10 +86,13 @@
     @get:PathSensitive(PathSensitivity.RELATIVE)
     abstract val importDirs: ListProperty<Directory>
 
-    @InputFile
-    @PathSensitive(PathSensitivity.NONE)
-    fun getAidlFrameworkProvider(): Provider<File> =
-        buildTools.aidlFrameworkProvider()
+    @get:InputFile
+    @get:PathSensitive(PathSensitivity.NONE)
+    abstract val aidlFrameworkProvider: RegularFileProperty
+
+    @get:InputFile
+    @get:PathSensitive(PathSensitivity.NONE)
+    abstract val aidlExecutable: RegularFileProperty
 
     /**
      * Directory for storing AIDL-generated Java sources.
@@ -106,16 +102,11 @@
 
     /**
      * Directory for storing Parcelable headers for consumers.
-     *
-     * These are copied directly from AIDL sources in [sourceDirs].
      */
     @get:OutputDirectory
     @get:Optional
     abstract val packagedDir: DirectoryProperty
 
-    @get:Nested
-    abstract val buildTools: BuildToolsExecutableInput
-
     @get:Input
     @get:Optional
     abstract val extraArgs: ListProperty<String>
@@ -129,40 +120,10 @@
         }
     }
 
-    /**
-     * Configures packaged output directory based on AGP's [AidlCompile] task for the [variant].
-     */
-    fun configurePackageDirFrom(project: Project, variant: Variant) {
-        val compileAidlTask = project.tasks.named(
-            "compile${variant.name.usLocaleCapitalize()}Aidl", AidlCompile::class.java)
-        // Packaged output directory is configured in AGP using:
-        // if (creationConfig.componentType.isAar) {
-        //   creationConfig.artifacts.setInitialProvider(
-        //     taskProvider,
-        //     aidlCompile::packagedDir
-        //   ).withName("out").on(InternalArtifactType.AIDL_PARCELABLE)
-        // }
-        packagedDir.set(compileAidlTask.flatMap { it.packagedDir })
-    }
-
-    /**
-     * Configures build tools based on AGP's [BaseExtension].
-     */
-    fun configureBuildToolsFrom(baseExtension: BaseExtension) {
-        // These are all required by aidlExecutableProvider().
-        buildTools.buildToolsRevision.set(baseExtension.buildToolsRevision)
-        buildTools.compileSdkVersion.set(baseExtension.compileSdkVersion)
-        buildTools.sdkBuildService.set(getBuildService(project.gradle.sharedServices))
-    }
-
     @TaskAction
     fun compile() {
-        // this is full run, clean the previous output'
-        val aidlExecutable = buildTools
-            .aidlExecutableProvider()
-            .get()
-            .absoluteFile
-        val frameworkLocation = getAidlFrameworkProvider().get().absoluteFile
+        // this is full run, clean the previous output
+        // TODO: Is this actually necessary?
         val destinationDir = sourceOutputDir.get().asFile
         FileUtils.cleanOutputDir(destinationDir)
         if (!destinationDir.exists()) {
@@ -179,8 +140,8 @@
 
         aidlCompileDelegate(
             workerExecutor,
-            aidlExecutable,
-            frameworkLocation,
+            aidlExecutable.get().asFile,
+            aidlFrameworkProvider.get().asFile,
             destinationDir,
             parcelableDir?.asFile,
             extraArgs.get(),
diff --git a/stableaidl/stableaidl-gradle-plugin/src/main/java/androidx/stableaidl/tasks/StableAidlPackageApi.kt b/stableaidl/stableaidl-gradle-plugin/src/main/java/androidx/stableaidl/tasks/StableAidlPackageApi.kt
new file mode 100644
index 0000000..218b300
--- /dev/null
+++ b/stableaidl/stableaidl-gradle-plugin/src/main/java/androidx/stableaidl/tasks/StableAidlPackageApi.kt
@@ -0,0 +1,139 @@
+/*
+ * 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.stableaidl.tasks
+
+import java.io.File
+import java.nio.file.Files
+import org.apache.tools.zip.ZipEntry
+import org.apache.tools.zip.ZipFile
+import org.apache.tools.zip.ZipOutputStream
+import org.gradle.api.DefaultTask
+import org.gradle.api.file.DirectoryProperty
+import org.gradle.api.file.FileTree
+import org.gradle.api.file.RegularFileProperty
+import org.gradle.api.tasks.InputDirectory
+import org.gradle.api.tasks.InputFile
+import org.gradle.api.tasks.OutputFile
+import org.gradle.api.tasks.PathSensitive
+import org.gradle.api.tasks.PathSensitivity
+import org.gradle.api.tasks.TaskAction
+
+/**
+ * Transforms an AAR by adding parcelable headers.
+ */
+abstract class StableAidlPackageApi : DefaultTask() {
+    @get:InputFile
+    @get:PathSensitive(PathSensitivity.RELATIVE)
+    abstract val aarFile: RegularFileProperty
+
+    @get:InputDirectory
+    @get:PathSensitive(PathSensitivity.RELATIVE)
+    abstract val packagedDir: DirectoryProperty
+
+    @get:OutputFile
+    abstract val updatedAarFile: RegularFileProperty
+
+    @TaskAction
+    fun taskAction() {
+        aidlPackageApiDelegate(
+            aarFile.get().asFile,
+            updatedAarFile.get().asFile,
+            packagedDir.get().asFileTree,
+            name
+        )
+    }
+}
+
+internal fun aidlPackageApiDelegate(
+    aar: File,
+    updatedAar: File,
+    packagedTree: FileTree,
+    name: String,
+) {
+    val tempDir = Files.createTempDirectory("${name}Unzip").toFile()
+    tempDir.deleteOnExit()
+
+    ZipFile(aar).use { aarFile ->
+        aarFile.unzipTo(tempDir)
+    }
+
+    val aidlRoot = File(tempDir, "aidl")
+    if (!aidlRoot.exists()) {
+        aidlRoot.mkdir()
+    }
+
+    // Copy the directory structure and files.
+    packagedTree.visit { details ->
+        val target = File(aidlRoot, details.relativePath.pathString)
+        if (details.isDirectory) {
+            target.mkdir()
+        } else {
+            details.copyTo(target)
+        }
+    }
+
+    tempDir.zipTo(updatedAar)
+    tempDir.deleteRecursively()
+}
+
+internal fun ZipFile.unzipTo(tempDir: File) {
+    entries.iterator().forEach { entry ->
+        if (entry.isDirectory) {
+            File(tempDir, entry.name).mkdirs()
+        } else {
+            val file = File(tempDir, entry.name)
+            file.parentFile.mkdirs()
+            getInputStream(entry).use { stream ->
+                file.writeBytes(stream.readBytes())
+            }
+        }
+    }
+}
+
+internal fun File.zipTo(outZip: File) {
+    ZipOutputStream(outZip.outputStream()).use { stream ->
+        listFiles()!!.forEach { file ->
+            stream.addFileRecursive(null, file)
+        }
+    }
+}
+
+internal fun ZipOutputStream.addFileRecursive(parentPath: String?, file: File) {
+    val entryPath = if (parentPath != null) "$parentPath/${file.name}" else file.name
+    val entry = ZipEntry(file, entryPath)
+
+    // Reset creation time of entry to make it deterministic.
+    entry.time = 0
+    entry.creationTime = java.nio.file.attribute.FileTime.fromMillis(0)
+
+    if (file.isFile) {
+        putNextEntry(entry)
+        file.inputStream().use { stream ->
+            stream.copyTo(this)
+        }
+        closeEntry()
+    } else if (file.isDirectory) {
+        val listFiles = file.listFiles()
+        if (!listFiles.isNullOrEmpty()) {
+            putNextEntry(entry)
+            closeEntry()
+            listFiles.forEach { containedFile ->
+                addFileRecursive(entryPath, containedFile)
+            }
+        }
+    }
+}
diff --git a/stableaidl/stableaidl-gradle-plugin/src/test/java/androidx/stableaidl/tasks/StableAidlCheckApiTest.kt b/stableaidl/stableaidl-gradle-plugin/src/test/java/androidx/stableaidl/tasks/StableAidlCheckApiTest.kt
index 74fd01a..db34a74 100644
--- a/stableaidl/stableaidl-gradle-plugin/src/test/java/androidx/stableaidl/tasks/StableAidlCheckApiTest.kt
+++ b/stableaidl/stableaidl-gradle-plugin/src/test/java/androidx/stableaidl/tasks/StableAidlCheckApiTest.kt
@@ -21,7 +21,6 @@
 import com.android.build.gradle.internal.fixtures.FakeGradleWorkExecutor
 import com.android.build.gradle.internal.fixtures.FakeInjectableService
 import com.google.common.truth.Truth
-import java.io.File
 import kotlin.reflect.jvm.javaMethod
 import org.gradle.api.DefaultTask
 import org.gradle.testfixtures.ProjectBuilder
@@ -43,12 +42,6 @@
     private lateinit var workers: WorkerExecutor
     private lateinit var instantiatorTask: DefaultTask
 
-    private fun createFile(name: String, parent: File): File {
-        val newFile = parent.resolve(name)
-        newFile.createNewFile()
-        return newFile
-    }
-
     @Before
     fun setup() {
         with(ProjectBuilder.builder().withProjectDir(temporaryFolder.newFolder()).build()) {
diff --git a/stableaidl/stableaidl-gradle-plugin/src/test/java/androidx/stableaidl/tasks/StableAidlCompileTest.kt b/stableaidl/stableaidl-gradle-plugin/src/test/java/androidx/stableaidl/tasks/StableAidlCompileTest.kt
index 25c4133..6477c47 100644
--- a/stableaidl/stableaidl-gradle-plugin/src/test/java/androidx/stableaidl/tasks/StableAidlCompileTest.kt
+++ b/stableaidl/stableaidl-gradle-plugin/src/test/java/androidx/stableaidl/tasks/StableAidlCompileTest.kt
@@ -22,7 +22,6 @@
 import com.android.build.gradle.internal.fixtures.FakeGradleWorkExecutor
 import com.android.build.gradle.internal.fixtures.FakeInjectableService
 import com.google.common.truth.Truth
-import java.io.File
 import kotlin.reflect.jvm.javaMethod
 import org.gradle.api.DefaultTask
 import org.gradle.testfixtures.ProjectBuilder
@@ -44,12 +43,6 @@
     private lateinit var workers: WorkerExecutor
     private lateinit var instantiatorTask: DefaultTask
 
-    private fun createFile(name: String, parent: File): File {
-        val newFile = parent.resolve(name)
-        newFile.createNewFile()
-        return newFile
-    }
-
     @Before
     fun setup() {
         with(ProjectBuilder.builder().withProjectDir(temporaryFolder.newFolder()).build()) {
diff --git a/stableaidl/stableaidl-gradle-plugin/src/test/java/androidx/stableaidl/tasks/StableAidlPackageApiTest.kt b/stableaidl/stableaidl-gradle-plugin/src/test/java/androidx/stableaidl/tasks/StableAidlPackageApiTest.kt
new file mode 100644
index 0000000..418bb76
--- /dev/null
+++ b/stableaidl/stableaidl-gradle-plugin/src/test/java/androidx/stableaidl/tasks/StableAidlPackageApiTest.kt
@@ -0,0 +1,64 @@
+/*
+ * 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.stableaidl.tasks
+
+import java.util.zip.ZipFile
+import kotlin.test.assertContains
+import org.apache.tools.zip.ZipOutputStream
+import org.gradle.testfixtures.ProjectBuilder
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TemporaryFolder
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+
+@RunWith(JUnit4::class)
+class StableAidlPackageApiTest {
+    @get:Rule
+    val temporaryFolder = TemporaryFolder()
+
+    @Test
+    fun testPackageDirectoryIntoAar() {
+        val aarSourceDir = temporaryFolder.newFolder("aarSourceDir")
+        val aarContents = createFile("AndroidManifest.xml", aarSourceDir)
+
+        val packagedDir = temporaryFolder.newFolder("packagedDir")
+        createFile("android/os/MyParcelable.aidl", packagedDir)
+
+        val outputDir = temporaryFolder.newFolder("outputDir")
+        val aarFile = createFile("aarFile.aar", outputDir)
+        ZipOutputStream(aarFile.outputStream()).use { stream ->
+            stream.addFileRecursive(null, aarContents)
+        }
+        val updatedAarFile = createFile("updatedAarFile.aar", outputDir)
+
+        with(ProjectBuilder.builder().withProjectDir(temporaryFolder.newFolder()).build()) {
+            aidlPackageApiDelegate(
+                aarFile,
+                updatedAarFile,
+                project.fileTree(packagedDir),
+                "test"
+            )
+        }
+
+        ZipFile(updatedAarFile).use { zip ->
+            val zipEntryNames = zip.getEntryNames()
+            assertContains(zipEntryNames, "aidl/android/os/MyParcelable.aidl")
+            assertContains(zipEntryNames, "AndroidManifest.xml")
+        }
+    }
+}
diff --git a/stableaidl/stableaidl-gradle-plugin/src/test/java/androidx/stableaidl/tasks/TestUtils.kt b/stableaidl/stableaidl-gradle-plugin/src/test/java/androidx/stableaidl/tasks/TestUtils.kt
new file mode 100644
index 0000000..dd222cd
--- /dev/null
+++ b/stableaidl/stableaidl-gradle-plugin/src/test/java/androidx/stableaidl/tasks/TestUtils.kt
@@ -0,0 +1,35 @@
+/*
+ * 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.stableaidl.tasks
+
+import java.io.File
+import java.util.zip.ZipFile
+
+internal fun createFile(name: String, parent: File): File {
+    val newFile = parent.resolve(name)
+    newFile.parentFile.mkdirs()
+    newFile.createNewFile()
+    return newFile
+}
+
+internal fun ZipFile.getEntryNames(): List<String> {
+    val flattened = mutableListOf<String>()
+    entries().iterator().forEach { entry ->
+        flattened += entry.name
+    }
+    return flattened
+}
diff --git a/test/uiautomator/integration-tests/testapp/src/androidTest/java/androidx/test/uiautomator/testapp/UiObject2Test.java b/test/uiautomator/integration-tests/testapp/src/androidTest/java/androidx/test/uiautomator/testapp/UiObject2Test.java
index 3e6300b..7b87886 100644
--- a/test/uiautomator/integration-tests/testapp/src/androidTest/java/androidx/test/uiautomator/testapp/UiObject2Test.java
+++ b/test/uiautomator/integration-tests/testapp/src/androidTest/java/androidx/test/uiautomator/testapp/UiObject2Test.java
@@ -175,6 +175,7 @@
         assertEquals("drag_received", dragDestination.getText());
     }
 
+    @Ignore // b/270210522
     @Test
     @SdkSuppress(minSdkVersion = 24)
     public void testDrag_destAndSpeed() {
diff --git a/text/text/src/main/java/androidx/compose/ui/text/android/LayoutIntrinsics.kt b/text/text/src/main/java/androidx/compose/ui/text/android/LayoutIntrinsics.kt
index 2463908..bab2459 100644
--- a/text/text/src/main/java/androidx/compose/ui/text/android/LayoutIntrinsics.kt
+++ b/text/text/src/main/java/androidx/compose/ui/text/android/LayoutIntrinsics.kt
@@ -33,51 +33,71 @@
  */
 @InternalPlatformTextApi
 class LayoutIntrinsics(
-    charSequence: CharSequence,
-    textPaint: TextPaint,
-    @LayoutCompat.TextDirection textDirectionHeuristic: Int
+    private val charSequence: CharSequence,
+    private val textPaint: TextPaint,
+    @LayoutCompat.TextDirection private val textDirectionHeuristic: Int
 ) {
+
+    private var _maxIntrinsicWidth: Float = Float.NaN
+    private var _minIntrinsicWidth: Float = Float.NaN
+    private var _boringMetrics: BoringLayout.Metrics? = null
+    private var boringMetricsIsInit: Boolean = false
+
     /**
      * Compute Android platform BoringLayout metrics. A null value means the provided CharSequence
      * cannot be laid out using a BoringLayout.
      */
-    val boringMetrics: BoringLayout.Metrics? by lazy(LazyThreadSafetyMode.NONE) {
-        val frameworkTextDir = getTextDirectionHeuristic(textDirectionHeuristic)
-        BoringLayoutFactory.measure(charSequence, textPaint, frameworkTextDir)
-    }
+    val boringMetrics: BoringLayout.Metrics?
+        get() {
+            if (!boringMetricsIsInit) {
+                val frameworkTextDir = getTextDirectionHeuristic(textDirectionHeuristic)
+                _boringMetrics =
+                    BoringLayoutFactory.measure(charSequence, textPaint, frameworkTextDir)
+                boringMetricsIsInit = true
+            }
+            return _boringMetrics
+        }
 
     /**
      * Calculate minimum intrinsic width of the CharSequence.
      *
      * @see androidx.compose.ui.text.android.minIntrinsicWidth
      */
-    val minIntrinsicWidth: Float by lazy(LazyThreadSafetyMode.NONE) {
-        minIntrinsicWidth(charSequence, textPaint)
-    }
+    val minIntrinsicWidth: Float
+        get() = if (!_minIntrinsicWidth.isNaN()) {
+            _minIntrinsicWidth
+        } else {
+            _minIntrinsicWidth = minIntrinsicWidth(charSequence, textPaint)
+            _minIntrinsicWidth
+        }
 
     /**
      * Calculate maximum intrinsic width for the CharSequence. Maximum intrinsic width is the width
      * of text where no soft line breaks are applied.
      */
-    val maxIntrinsicWidth: Float by lazy(LazyThreadSafetyMode.NONE) {
-        var desiredWidth = boringMetrics?.width?.toFloat()
+    val maxIntrinsicWidth: Float
+        get() = if (!_maxIntrinsicWidth.isNaN()) {
+            _maxIntrinsicWidth
+        } else {
+            var desiredWidth = boringMetrics?.width?.toFloat()
 
-        // boring metrics doesn't cover RTL text so we fallback to different calculation when boring
-        // metrics can't be calculated
-        if (desiredWidth == null) {
-            // b/233856978, apply `ceil` function here to be consistent with the boring metrics
-            // width calculation that does it under the hood, too
-            desiredWidth = ceil(
-                Layout.getDesiredWidth(charSequence, 0, charSequence.length, textPaint)
-            )
+            // boring metrics doesn't cover RTL text so we fallback to different calculation when boring
+            // metrics can't be calculated
+            if (desiredWidth == null) {
+                // b/233856978, apply `ceil` function here to be consistent with the boring metrics
+                // width calculation that does it under the hood, too
+                desiredWidth = ceil(
+                    Layout.getDesiredWidth(charSequence, 0, charSequence.length, textPaint)
+                )
+            }
+            if (shouldIncreaseMaxIntrinsic(desiredWidth, charSequence, textPaint)) {
+                // b/173574230, increase maxIntrinsicWidth, so that StaticLayout won't form 2
+                // lines for the given maxIntrinsicWidth
+                desiredWidth += 0.5f
+            }
+            _maxIntrinsicWidth = desiredWidth
+            _maxIntrinsicWidth
         }
-        if (shouldIncreaseMaxIntrinsic(desiredWidth, charSequence, textPaint)) {
-            // b/173574230, increase maxIntrinsicWidth, so that StaticLayout won't form 2
-            // lines for the given maxIntrinsicWidth
-            desiredWidth += 0.5f
-        }
-        desiredWidth
-    }
 }
 
 /**
diff --git a/text/text/src/main/java/androidx/compose/ui/text/android/TextLayout.kt b/text/text/src/main/java/androidx/compose/ui/text/android/TextLayout.kt
index 0dbfb5d..f581e35 100644
--- a/text/text/src/main/java/androidx/compose/ui/text/android/TextLayout.kt
+++ b/text/text/src/main/java/androidx/compose/ui/text/android/TextLayout.kt
@@ -70,6 +70,11 @@
 import kotlin.math.min
 
 /**
+ * We swap canvas delegates, and can share the wrapper.
+ */
+private val SharedTextAndroidCanvas: TextAndroidCanvas = TextAndroidCanvas()
+
+/**
  * Wrapper for Static Text Layout classes.
  *
  * @param charSequence text to be laid out.
@@ -235,12 +240,6 @@
 
     private val rect: Rect = Rect()
 
-    /**
-     * Android Canvas object that overrides the `getClipBounds` method and delegates the rest
-     * to the Canvas object that it wraps. See [TextAndroidCanvas] for more details.
-     */
-    private val textCanvas = TextAndroidCanvas()
-
     init {
         val end = charSequence.length
         val frameworkTextDir = getTextDirectionHeuristic(textDirectionHeuristic)
@@ -735,8 +734,10 @@
             canvas.translate(0f, topPadding.toFloat())
         }
 
-        textCanvas.setCanvas(canvas)
-        layout.draw(textCanvas)
+        with(SharedTextAndroidCanvas) {
+            setCanvas(canvas)
+            layout.draw(this)
+        }
 
         if (topPadding != 0) {
             canvas.translate(0f, -1 * topPadding.toFloat())
diff --git a/tv/integration-tests/demos/build.gradle b/tv/integration-tests/demos/build.gradle
index 942dbb0..d457c55 100644
--- a/tv/integration-tests/demos/build.gradle
+++ b/tv/integration-tests/demos/build.gradle
@@ -26,8 +26,7 @@
 dependencies {
     implementation(libs.kotlinStdlib)
 
-    implementation("androidx.activity:activity-compose:1.6.1")
-    implementation(project(":activity:activity"))
+    implementation(project(":activity:activity-compose"))
 
     implementation(project(":compose:material3:material3"))
     implementation(project(":navigation:navigation-runtime"))
diff --git a/tv/integration-tests/demos/src/main/java/androidx/tv/integration/demos/TopNavigation.kt b/tv/integration-tests/demos/src/main/java/androidx/tv/integration/demos/TopNavigation.kt
index ba1423e..1efe4af 100644
--- a/tv/integration-tests/demos/src/main/java/androidx/tv/integration/demos/TopNavigation.kt
+++ b/tv/integration-tests/demos/src/main/java/androidx/tv/integration/demos/TopNavigation.kt
@@ -19,7 +19,6 @@
 import androidx.compose.foundation.layout.Spacer
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.width
-import androidx.compose.material3.Text
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.getValue
@@ -30,9 +29,9 @@
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.sp
 import androidx.tv.material3.ExperimentalTvMaterial3Api
-import androidx.tv.material3.LocalContentColor
 import androidx.tv.material3.Tab
 import androidx.tv.material3.TabRow
+import androidx.tv.material3.Text
 import kotlinx.coroutines.delay
 
 enum class Navigation(val displayName: String, val action: @Composable () -> Unit) {
@@ -87,7 +86,6 @@
         Text(
           text = tab,
           fontSize = 12.sp,
-          color = LocalContentColor.current,
           modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp)
         )
       }
diff --git a/tv/samples/src/main/java/androidx/tv/samples/TabRowSamples.kt b/tv/samples/src/main/java/androidx/tv/samples/TabRowSamples.kt
index 126c1e8..b9d11bd 100644
--- a/tv/samples/src/main/java/androidx/tv/samples/TabRowSamples.kt
+++ b/tv/samples/src/main/java/androidx/tv/samples/TabRowSamples.kt
@@ -23,7 +23,6 @@
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.width
-import androidx.compose.material3.Text
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.getValue
@@ -35,11 +34,11 @@
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.sp
 import androidx.tv.material3.ExperimentalTvMaterial3Api
-import androidx.tv.material3.LocalContentColor
 import androidx.tv.material3.Tab
 import androidx.tv.material3.TabDefaults
 import androidx.tv.material3.TabRow
 import androidx.tv.material3.TabRowDefaults
+import androidx.tv.material3.Text
 import kotlin.time.Duration.Companion.microseconds
 import kotlinx.coroutines.delay
 
@@ -65,7 +64,6 @@
         Text(
           text = tab,
           fontSize = 12.sp,
-          color = LocalContentColor.current,
           modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp)
         )
       }
@@ -101,7 +99,6 @@
         Text(
           text = tab,
           fontSize = 12.sp,
-          color = LocalContentColor.current,
           modifier = Modifier.padding(bottom = 4.dp)
         )
       }
@@ -140,7 +137,6 @@
         Text(
           text = tab,
           fontSize = 12.sp,
-          color = LocalContentColor.current,
           modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp)
         )
       }
@@ -198,7 +194,6 @@
           Text(
             text = "Tab ${it + 1}",
             fontSize = 12.sp,
-            color = LocalContentColor.current,
             modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp)
           )
         }
diff --git a/tv/tv-material/api/public_plus_experimental_current.txt b/tv/tv-material/api/public_plus_experimental_current.txt
index cb54025..6793ddd 100644
--- a/tv/tv-material/api/public_plus_experimental_current.txt
+++ b/tv/tv-material/api/public_plus_experimental_current.txt
@@ -36,35 +36,45 @@
     property public final int activeSlideIndex;
   }
 
-  @androidx.tv.material3.ExperimentalTvMaterial3Api public final class ClickableSurfaceColor {
-    method public long getDefaultColor();
+  @androidx.compose.runtime.Immutable @androidx.tv.material3.ExperimentalTvMaterial3Api public final class ClickableSurfaceColor {
+    method public long getColor();
     method public long getDisabledColor();
     method public long getFocusedColor();
     method public long getPressedColor();
-    property public final long defaultColor;
+    property public final long color;
     property public final long disabledColor;
     property public final long focusedColor;
     property public final long pressedColor;
   }
 
   @androidx.tv.material3.ExperimentalTvMaterial3Api public final class ClickableSurfaceDefaults {
-    method @androidx.compose.runtime.Composable @androidx.compose.runtime.ReadOnlyComposable public androidx.tv.material3.ClickableSurfaceColor color(optional long defaultColor, optional long focusedColor, optional long pressedColor, optional long disabledColor);
-    method @androidx.compose.runtime.Composable @androidx.compose.runtime.ReadOnlyComposable public androidx.tv.material3.ClickableSurfaceColor contentColor(optional long defaultColor, optional long focusedColor, optional long pressedColor, optional long disabledColor);
-    method @androidx.compose.runtime.Composable @androidx.compose.runtime.ReadOnlyComposable public androidx.tv.material3.ClickableSurfaceShape shape(optional androidx.compose.ui.graphics.Shape defaultShape, optional androidx.compose.ui.graphics.Shape focusedShape, optional androidx.compose.ui.graphics.Shape pressedShape, optional androidx.compose.ui.graphics.Shape disabledShape, optional androidx.compose.ui.graphics.Shape focusedDisabledShape);
+    method @androidx.compose.runtime.Composable @androidx.compose.runtime.ReadOnlyComposable public androidx.tv.material3.ClickableSurfaceColor color(optional long color, optional long focusedColor, optional long pressedColor, optional long disabledColor);
+    method @androidx.compose.runtime.Composable @androidx.compose.runtime.ReadOnlyComposable public androidx.tv.material3.ClickableSurfaceColor contentColor(optional long color, optional long focusedColor, optional long pressedColor, optional long disabledColor);
+    method public androidx.tv.material3.ClickableSurfaceGlow glow(optional androidx.tv.material3.Glow glow, optional androidx.tv.material3.Glow focusedGlow, optional androidx.tv.material3.Glow pressedGlow);
+    method @androidx.compose.runtime.Composable @androidx.compose.runtime.ReadOnlyComposable public androidx.tv.material3.ClickableSurfaceShape shape(optional androidx.compose.ui.graphics.Shape shape, optional androidx.compose.ui.graphics.Shape focusedShape, optional androidx.compose.ui.graphics.Shape pressedShape, optional androidx.compose.ui.graphics.Shape disabledShape, optional androidx.compose.ui.graphics.Shape focusedDisabledShape);
     field public static final androidx.tv.material3.ClickableSurfaceDefaults INSTANCE;
   }
 
-  @androidx.tv.material3.ExperimentalTvMaterial3Api public final class ClickableSurfaceShape {
-    method public androidx.compose.ui.graphics.Shape getDefaultShape();
+  @androidx.compose.runtime.Immutable @androidx.tv.material3.ExperimentalTvMaterial3Api public final class ClickableSurfaceGlow {
+    method public androidx.tv.material3.Glow getFocusedGlow();
+    method public androidx.tv.material3.Glow getGlow();
+    method public androidx.tv.material3.Glow getPressedGlow();
+    property public final androidx.tv.material3.Glow focusedGlow;
+    property public final androidx.tv.material3.Glow glow;
+    property public final androidx.tv.material3.Glow pressedGlow;
+  }
+
+  @androidx.compose.runtime.Immutable @androidx.tv.material3.ExperimentalTvMaterial3Api public final class ClickableSurfaceShape {
     method public androidx.compose.ui.graphics.Shape getDisabledShape();
     method public androidx.compose.ui.graphics.Shape getFocusedDisabledShape();
     method public androidx.compose.ui.graphics.Shape getFocusedShape();
     method public androidx.compose.ui.graphics.Shape getPressedShape();
-    property public final androidx.compose.ui.graphics.Shape defaultShape;
+    method public androidx.compose.ui.graphics.Shape getShape();
     property public final androidx.compose.ui.graphics.Shape disabledShape;
     property public final androidx.compose.ui.graphics.Shape focusedDisabledShape;
     property public final androidx.compose.ui.graphics.Shape focusedShape;
     property public final androidx.compose.ui.graphics.Shape pressedShape;
+    property public final androidx.compose.ui.graphics.Shape shape;
   }
 
   @androidx.compose.runtime.Stable @androidx.tv.material3.ExperimentalTvMaterial3Api public final class ColorScheme {
@@ -146,6 +156,25 @@
   @kotlin.RequiresOptIn(message="This tv-material API is experimental and likely to change or be removed in the future.") @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.BINARY) public @interface ExperimentalTvMaterial3Api {
   }
 
+  @androidx.compose.runtime.Immutable @androidx.tv.material3.ExperimentalTvMaterial3Api public final class Glow {
+    ctor public Glow(long elevationColor, float elevation);
+    method public androidx.tv.material3.Glow copy(optional androidx.compose.ui.graphics.Color? glowColor, optional androidx.compose.ui.unit.Dp? glowElevation);
+    method public float getElevation();
+    method public long getElevationColor();
+    property public final float elevation;
+    property public final long elevationColor;
+    field public static final androidx.tv.material3.Glow.Companion Companion;
+  }
+
+  public static final class Glow.Companion {
+    method public androidx.tv.material3.Glow getNone();
+    property public final androidx.tv.material3.Glow None;
+  }
+
+  @androidx.compose.runtime.Stable @androidx.tv.material3.ExperimentalTvMaterial3Api public final class GlowIndication implements androidx.compose.foundation.Indication {
+    method @androidx.compose.runtime.Composable public androidx.compose.foundation.IndicationInstance rememberUpdatedInstance(androidx.compose.foundation.interaction.InteractionSource interactionSource);
+  }
+
   @androidx.compose.runtime.Immutable @androidx.tv.material3.ExperimentalTvMaterial3Api public final class ImmersiveListBackgroundScope implements androidx.compose.foundation.layout.BoxScope {
     method @androidx.compose.animation.ExperimentalAnimationApi @androidx.compose.runtime.Composable public void AnimatedContent(int targetState, optional androidx.compose.ui.Modifier modifier, optional kotlin.jvm.functions.Function1<? super androidx.compose.animation.AnimatedContentScope<java.lang.Integer>,androidx.compose.animation.ContentTransform> transitionSpec, optional androidx.compose.ui.Alignment contentAlignment, kotlin.jvm.functions.Function2<? super androidx.compose.animation.AnimatedVisibilityScope,? super java.lang.Integer,kotlin.Unit> content);
     method @androidx.compose.runtime.Composable public void AnimatedVisibility(boolean visible, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.animation.EnterTransition enter, optional androidx.compose.animation.ExitTransition exit, optional String label, kotlin.jvm.functions.Function1<? super androidx.compose.animation.AnimatedVisibilityScope,kotlin.Unit> content);
@@ -167,6 +196,10 @@
     method public androidx.compose.ui.Modifier immersiveListItem(androidx.compose.ui.Modifier, int index);
   }
 
+  public final class IndicationsKt {
+    method @androidx.compose.runtime.Composable @androidx.tv.material3.ExperimentalTvMaterial3Api public static androidx.tv.material3.GlowIndication rememberGlowIndication(optional long color, optional androidx.compose.ui.graphics.Shape shape, optional float glowBlurRadius, optional float offsetX, optional float offsetY);
+  }
+
   public final class MaterialTheme {
     method @androidx.compose.runtime.Composable @androidx.compose.runtime.ReadOnlyComposable public androidx.tv.material3.ColorScheme getColorScheme();
     method @androidx.compose.runtime.Composable @androidx.compose.runtime.ReadOnlyComposable public androidx.tv.material3.Shapes getShapes();
@@ -215,7 +248,7 @@
   }
 
   public final class SurfaceKt {
-    method @androidx.compose.runtime.Composable @androidx.compose.runtime.NonRestartableComposable @androidx.tv.material3.ExperimentalTvMaterial3Api public static void Surface(kotlin.jvm.functions.Function0<kotlin.Unit> onClick, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional androidx.tv.material3.ClickableSurfaceShape shape, optional androidx.tv.material3.ClickableSurfaceColor color, optional androidx.tv.material3.ClickableSurfaceColor contentColor, optional androidx.compose.foundation.BorderStroke? border, optional float tonalElevation, optional androidx.compose.ui.semantics.Role? role, optional float shadowElevation, optional androidx.compose.foundation.interaction.MutableInteractionSource interactionSource, kotlin.jvm.functions.Function0<kotlin.Unit> content);
+    method @androidx.compose.runtime.Composable @androidx.compose.runtime.NonRestartableComposable @androidx.tv.material3.ExperimentalTvMaterial3Api public static void Surface(kotlin.jvm.functions.Function0<kotlin.Unit> onClick, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional float tonalElevation, optional androidx.tv.material3.ClickableSurfaceShape shape, optional androidx.tv.material3.ClickableSurfaceColor color, optional androidx.tv.material3.ClickableSurfaceColor contentColor, optional androidx.tv.material3.ClickableSurfaceGlow glow, optional androidx.compose.foundation.interaction.MutableInteractionSource interactionSource, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.BoxScope,kotlin.Unit> content);
     method public static androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.ui.unit.Dp> getLocalAbsoluteTonalElevation();
     property public static final androidx.compose.runtime.ProvidableCompositionLocal<androidx.compose.ui.unit.Dp> LocalAbsoluteTonalElevation;
   }
diff --git a/tv/tv-material/src/androidTest/java/androidx/tv/material3/SurfaceTest.kt b/tv/tv-material/src/androidTest/java/androidx/tv/material3/SurfaceTest.kt
index 22dd6ab..6976d6d 100644
--- a/tv/tv-material/src/androidTest/java/androidx/tv/material3/SurfaceTest.kt
+++ b/tv/tv-material/src/androidTest/java/androidx/tv/material3/SurfaceTest.kt
@@ -17,6 +17,7 @@
 package androidx.tv.material3
 
 import android.os.Build
+import androidx.annotation.RequiresApi
 import androidx.compose.foundation.gestures.awaitEachGesture
 import androidx.compose.foundation.interaction.FocusInteraction
 import androidx.compose.foundation.interaction.Interaction
@@ -32,6 +33,7 @@
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.rememberCoroutineScope
 import androidx.compose.runtime.setValue
+import androidx.compose.testutils.assertContainsColor
 import androidx.compose.testutils.assertShape
 import androidx.compose.ui.ExperimentalComposeUiApi
 import androidx.compose.ui.Modifier
@@ -42,13 +44,9 @@
 import androidx.compose.ui.input.pointer.PointerEventPass
 import androidx.compose.ui.input.pointer.pointerInput
 import androidx.compose.ui.platform.testTag
-import androidx.compose.ui.semantics.Role
 import androidx.compose.ui.semantics.SemanticsActions
-import androidx.compose.ui.semantics.SemanticsProperties
 import androidx.compose.ui.semantics.semantics
 import androidx.compose.ui.test.ExperimentalTestApi
-import androidx.compose.ui.test.SemanticsMatcher
-import androidx.compose.ui.test.assert
 import androidx.compose.ui.test.assertHasClickAction
 import androidx.compose.ui.test.assertIsEnabled
 import androidx.compose.ui.test.assertIsFocused
@@ -62,8 +60,10 @@
 import androidx.compose.ui.test.pressKey
 import androidx.compose.ui.unit.Dp
 import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.FlakyTest
 import androidx.test.filters.MediumTest
 import androidx.test.filters.SdkSuppress
+import androidx.tv.material3.tokens.Elevation
 import com.google.common.truth.Truth
 import kotlin.math.abs
 import kotlinx.coroutines.CoroutineScope
@@ -102,10 +102,10 @@
                 Surface(
                     onClick = {},
                     shape = ClickableSurfaceDefaults.shape(
-                        defaultShape = RectangleShape
+                        shape = RectangleShape
                     ),
                     color = ClickableSurfaceDefaults.color(
-                        defaultColor = Color.Yellow
+                        color = Color.Yellow
                     )
                 ) {
                     Box(Modifier.fillMaxSize())
@@ -113,10 +113,10 @@
                 Surface(
                     onClick = {},
                     shape = ClickableSurfaceDefaults.shape(
-                        defaultShape = RectangleShape
+                        shape = RectangleShape
                     ),
                     color = ClickableSurfaceDefaults.color(
-                        defaultColor = Color.Green
+                        color = Color.Green
                     )
                 ) {
                     Box(Modifier.fillMaxSize())
@@ -172,7 +172,7 @@
                     },
                     onClick = {},
                     tonalElevation = 2.toDp(),
-                    contentColor = ClickableSurfaceDefaults.color(defaultColor = expectedColor)
+                    contentColor = ClickableSurfaceDefaults.color(color = expectedColor)
                 ) {}
             }
         }
@@ -213,7 +213,6 @@
                 modifier = Modifier
                     .testTag("surface"),
                 onClick = { count.value += 1 },
-                role = Role.Checkbox
             ) {
                 Text("${count.value}")
                 Spacer(Modifier.size(30.toDp()))
@@ -222,7 +221,6 @@
         rule.onNodeWithTag("surface")
             .performSemanticsAction(SemanticsActions.RequestFocus)
             .assertHasClickAction()
-            .assert(SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Checkbox))
             .assertIsEnabled()
             // since we merge descendants we should have text on the same node
             .assertTextEquals("0")
@@ -396,4 +394,42 @@
 
         Truth.assertThat(isPressed).isTrue()
     }
+
+    @FlakyTest(bugId = 269229262)
+    @RequiresApi(Build.VERSION_CODES.O)
+    @Test
+    fun clickableSurface_onFocus_changesGlowColor() {
+        rule.setContent {
+            Surface(
+                modifier = Modifier
+                    .testTag("surface")
+                    .size(100.toDp()),
+                onClick = {},
+                color = ClickableSurfaceDefaults.color(
+                    color = Color.Transparent,
+                    focusedColor = Color.Transparent
+                ),
+                glow = ClickableSurfaceDefaults.glow(
+                    glow = Glow(
+                        elevationColor = Color.Magenta,
+                        elevation = Elevation.Level5
+                    ),
+                    focusedGlow = Glow(
+                        elevationColor = Color.Green,
+                        elevation = Elevation.Level5
+                    )
+                )
+            ) {}
+        }
+        rule.onNodeWithTag("surface")
+            .captureToImage()
+            .assertContainsColor(Color.Magenta)
+
+        rule.onNodeWithTag("surface")
+            .performSemanticsAction(SemanticsActions.RequestFocus)
+
+        rule.onNodeWithTag("surface")
+            .captureToImage()
+            .assertContainsColor(Color.Green)
+    }
 }
diff --git a/tv/tv-material/src/main/java/androidx/tv/material3/Indications.kt b/tv/tv-material/src/main/java/androidx/tv/material3/Indications.kt
new file mode 100644
index 0000000..7202baa
--- /dev/null
+++ b/tv/tv-material/src/main/java/androidx/tv/material3/Indications.kt
@@ -0,0 +1,154 @@
+/*
+ * 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.tv.material3
+
+import androidx.compose.animation.core.animateDpAsState
+import androidx.compose.foundation.Indication
+import androidx.compose.foundation.IndicationInstance
+import androidx.compose.foundation.interaction.InteractionSource
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Outline
+import androidx.compose.ui.graphics.Paint
+import androidx.compose.ui.graphics.RectangleShape
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.graphics.drawscope.ContentDrawScope
+import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
+import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+
+/**
+ * GlowIndication is an [Indication] that displays a diffused shadow behind the component it is
+ * applied to. It takes in parameters like [Color], [Shape], blur radius, and Offset to let users
+ * customise it to their brand personality.
+ */
+@ExperimentalTvMaterial3Api
+@Stable
+class GlowIndication internal constructor(
+    private val color: Color,
+    private val shape: Shape,
+    private val glowBlurRadius: Dp,
+    private val offsetX: Dp,
+    private val offsetY: Dp
+) : Indication {
+    @Composable
+    override fun rememberUpdatedInstance(interactionSource: InteractionSource): IndicationInstance {
+        val animatedGlowBlurRadius by animateDpAsState(targetValue = glowBlurRadius)
+        return GlowIndicationInstance(
+            color = color,
+            shape = shape,
+            glowBlurRadius = animatedGlowBlurRadius,
+            offsetX = offsetX,
+            offsetY = offsetY,
+            density = LocalDensity.current
+        )
+    }
+}
+
+@ExperimentalTvMaterial3Api
+private class GlowIndicationInstance(
+    color: Color,
+    private val shape: Shape,
+    private val density: Density,
+    private val glowBlurRadius: Dp,
+    private val offsetX: Dp,
+    private val offsetY: Dp
+) : IndicationInstance {
+    val shadowColor = color.toArgb()
+    val transparentColor = color.copy(alpha = 0f).toArgb()
+
+    val paint = Paint()
+    val frameworkPaint = paint.asFrameworkPaint()
+
+    init {
+        frameworkPaint.color = transparentColor
+
+        with(density) {
+            frameworkPaint.setShadowLayer(
+                glowBlurRadius.toPx(),
+                offsetX.toPx(),
+                offsetY.toPx(),
+                shadowColor
+            )
+        }
+    }
+
+    override fun ContentDrawScope.drawIndication() {
+        drawIntoCanvas { canvas ->
+            when (
+                val shapeOutline = shape.createOutline(
+                    size = size,
+                    layoutDirection = layoutDirection,
+                    density = this@GlowIndicationInstance.density
+                )
+            ) {
+                is Outline.Rectangle -> canvas.drawRect(shapeOutline.rect, paint)
+
+                is Outline.Rounded -> {
+                    val shapeCornerRadiusX = shapeOutline.roundRect.topLeftCornerRadius.x
+                    val shapeCornerRadiusY = shapeOutline.roundRect.topLeftCornerRadius.y
+
+                    canvas.drawRoundRect(
+                        0f,
+                        0f,
+                        size.width,
+                        size.height,
+                        shapeCornerRadiusX,
+                        shapeCornerRadiusY,
+                        paint
+                    )
+                }
+
+                is Outline.Generic -> canvas.drawPath(shapeOutline.path, paint)
+            }
+        }
+        drawContent()
+    }
+}
+
+/**
+ * Creates and remembers an instance of [GlowIndication].
+ * @param color describes the color of the background glow.
+ * @param shape describes the shape on which the glow will be clipped.
+ * @param glowBlurRadius describes how long and blurred would the glow shadow be.
+ * @param offsetX describes the horizontal offset of the glow from the composable.
+ * @param offsetY describes the vertical offset of the glow from the composable.
+ * @return A remembered instance of [GlowIndication].
+ */
+@ExperimentalTvMaterial3Api
+@Composable
+fun rememberGlowIndication(
+    color: Color = MaterialTheme.colorScheme.primaryContainer,
+    shape: Shape = RectangleShape,
+    glowBlurRadius: Dp = 0.dp,
+    offsetX: Dp = 0.dp,
+    offsetY: Dp = 0.dp
+) = remember(color, shape, glowBlurRadius, offsetX, offsetY) {
+    GlowIndication(
+        color = color,
+        shape = shape,
+        glowBlurRadius = glowBlurRadius,
+        offsetX = offsetY,
+        offsetY = offsetX
+    )
+}
diff --git a/tv/tv-material/src/main/java/androidx/tv/material3/Surface.kt b/tv/tv-material/src/main/java/androidx/tv/material3/Surface.kt
index ffa3dc4..3b7fdb4 100644
--- a/tv/tv-material/src/main/java/androidx/tv/material3/Surface.kt
+++ b/tv/tv-material/src/main/java/androidx/tv/material3/Surface.kt
@@ -16,16 +16,16 @@
 
 package androidx.tv.material3
 
-import androidx.compose.foundation.BorderStroke
-import androidx.compose.foundation.background
-import androidx.compose.foundation.border
+import androidx.compose.animation.core.animateFloatAsState
 import androidx.compose.foundation.focusable
+import androidx.compose.foundation.indication
 import androidx.compose.foundation.interaction.Interaction
 import androidx.compose.foundation.interaction.MutableInteractionSource
 import androidx.compose.foundation.interaction.PressInteraction
 import androidx.compose.foundation.interaction.collectIsFocusedAsState
 import androidx.compose.foundation.interaction.collectIsPressedAsState
 import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxScope
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.CompositionLocalProvider
 import androidx.compose.runtime.NonRestartableComposable
@@ -37,22 +37,24 @@
 import androidx.compose.runtime.setValue
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.composed
-import androidx.compose.ui.draw.clip
-import androidx.compose.ui.draw.shadow
+import androidx.compose.ui.draw.drawWithCache
 import androidx.compose.ui.geometry.Offset
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.RectangleShape
 import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.graphics.drawOutline
+import androidx.compose.ui.graphics.graphicsLayer
 import androidx.compose.ui.input.key.NativeKeyEvent
 import androidx.compose.ui.input.key.onKeyEvent
+import androidx.compose.ui.layout.layout
 import androidx.compose.ui.platform.debugInspectorInfo
-import androidx.compose.ui.semantics.Role
 import androidx.compose.ui.semantics.disabled
 import androidx.compose.ui.semantics.onClick
-import androidx.compose.ui.semantics.role
 import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.unit.Density
 import androidx.compose.ui.unit.Dp
 import androidx.compose.ui.unit.dp
+import androidx.compose.ui.zIndex
 import kotlinx.coroutines.launch
 
 /**
@@ -69,14 +71,10 @@
  * clickable or focusable.
  * @param tonalElevation When [color] is [ColorScheme.surface], a higher the elevation will result
  * in a darker color in light theme and lighter color in dark theme.
- * @param shadowElevation The size of the shadow below the surface. Note that It will not affect z
- * index of the Surface. If you want to change the drawing order you can use `Modifier.zIndex`.
- * @param role The type of user interface element. Accessibility services might use this to describe
- * the element or do customizations.
  * @param shape Defines the surface's shape.
  * @param color Color to be used on background of the Surface
  * @param contentColor The preferred content color provided by this Surface to its children.
- * @param border Optional border to draw on top of the surface
+ * @param glow Diffused shadow to be shown behind the Surface.
  * @param interactionSource the [MutableInteractionSource] representing the stream of [Interaction]s
  * for this Surface. You can create and pass in your own remembered [MutableInteractionSource] if
  * you want to observe [Interaction]s and customize the appearance / behavior of this Surface in
@@ -90,15 +88,13 @@
     onClick: () -> Unit,
     modifier: Modifier = Modifier,
     enabled: Boolean = true,
+    tonalElevation: Dp = 0.dp,
     shape: ClickableSurfaceShape = ClickableSurfaceDefaults.shape(),
     color: ClickableSurfaceColor = ClickableSurfaceDefaults.color(),
     contentColor: ClickableSurfaceColor = ClickableSurfaceDefaults.contentColor(),
-    border: BorderStroke? = null,
-    tonalElevation: Dp = 0.dp,
-    role: Role? = null,
-    shadowElevation: Dp = 0.dp,
+    glow: ClickableSurfaceGlow = ClickableSurfaceDefaults.glow(),
     interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
-    content: @Composable () -> Unit
+    content: @Composable (BoxScope.() -> Unit)
 ) {
     val focused by interactionSource.collectIsFocusedAsState()
     val pressed by interactionSource.collectIsPressedAsState()
@@ -106,9 +102,10 @@
         modifier = modifier.tvClickable(
             enabled = enabled,
             onClick = onClick,
-            interactionSource = interactionSource,
-            role = role
+            interactionSource = interactionSource
         ),
+        enabled = enabled,
+        tonalElevation = tonalElevation,
         shape = ClickableSurfaceDefaults.shape(
             enabled = enabled,
             focused = focused,
@@ -127,9 +124,13 @@
             pressed = pressed,
             color = contentColor
         ),
-        tonalElevation = tonalElevation,
-        shadowElevation = shadowElevation,
-        border = border,
+        glow = ClickableSurfaceDefaults.glow(
+            enabled = enabled,
+            focused = focused,
+            pressed = pressed,
+            glow = glow
+        ),
+        interactionSource = interactionSource,
         content = content
     )
 }
@@ -138,48 +139,92 @@
 @Composable
 private fun SurfaceImpl(
     modifier: Modifier = Modifier,
+    selected: Boolean = false,
+    enabled: Boolean,
     shape: Shape = RectangleShape,
-    color: Color = MaterialTheme.colorScheme.surface,
-    contentColor: Color = contentColorFor(color),
+    color: Color,
+    contentColor: Color,
+    glow: Glow,
     tonalElevation: Dp = 0.dp,
-    shadowElevation: Dp = 0.dp,
-    border: BorderStroke? = null,
-    content: @Composable () -> Unit
+    interactionSource: MutableInteractionSource,
+    content: @Composable (BoxScope.() -> Unit)
 ) {
+    val focused by interactionSource.collectIsFocusedAsState()
+    val pressed by interactionSource.collectIsPressedAsState()
+
+    val surfaceAlpha = stateAlpha(
+        enabled = enabled,
+        focused = focused,
+        pressed = pressed,
+        selected = selected
+    )
+
     val absoluteElevation = LocalAbsoluteTonalElevation.current + tonalElevation
+
     CompositionLocalProvider(
         LocalContentColor provides contentColor,
         LocalAbsoluteTonalElevation provides absoluteElevation
     ) {
+        val zIndex by animateFloatAsState(
+            targetValue = if (focused) FocusedZIndex else NonFocusedZIndex
+        )
+
+        val backgroundColorByState = surfaceColorAtElevation(
+            color = color,
+            elevation = LocalAbsoluteTonalElevation.current
+        )
+
         Box(
             modifier = modifier
-                .surface(
-                    shape = shape,
-                    backgroundColor = surfaceColorAtElevation(
-                        color = color,
-                        elevation = absoluteElevation
-                    ),
-                    border = border,
-                    shadowElevation = shadowElevation
-                ),
+                .indication(
+                    interactionSource = interactionSource,
+                    indication = rememberGlowIndication(
+                        color = surfaceColorAtElevation(
+                            color = glow.elevationColor,
+                            elevation = glow.elevation
+                        ),
+                        shape = shape,
+                        glowBlurRadius = glow.elevation
+                    )
+                )
+                // Increasing the zIndex of this Surface when it is in the focused state to
+                // avoid the glowIndication from being overlapped by subsequent items if
+                // this Surface is inside a list composable (like a Row/Column).
+                .layout { measurable, constraints ->
+                    val placeable = measurable.measure(constraints)
+                    layout(placeable.width, placeable.height) {
+                        placeable.place(0, 0, zIndex = zIndex)
+                    }
+                }
+                .drawWithCache {
+                    onDrawBehind {
+                        drawOutline(
+                            outline = shape.createOutline(
+                                size = size,
+                                layoutDirection = layoutDirection,
+                                density = Density(density, fontScale)
+                            ),
+                            color = backgroundColorByState
+                        )
+                    }
+                }
+                .graphicsLayer {
+                    this.alpha = surfaceAlpha
+                    this.shape = shape
+                    this.clip = true
+                },
             propagateMinConstraints = true
         ) {
-            content()
+            Box(
+                modifier = Modifier.graphicsLayer {
+                    this.alpha = if (!enabled) DisabledContentAlpha else EnabledContentAlpha
+                },
+                content = content
+            )
         }
     }
 }
 
-private fun Modifier.surface(
-    shape: Shape,
-    backgroundColor: Color,
-    border: BorderStroke?,
-    shadowElevation: Dp
-) = this
-    .shadow(shadowElevation, shape, clip = false)
-    .then(if (border != null) Modifier.border(border, shape) else Modifier)
-    .background(color = backgroundColor, shape = shape)
-    .clip(shape)
-
 /**
  * This modifier handles click, press, and focus events for a TV composable.
  * @param enabled decides whether [onClick] or [onValueChanged] is executed
@@ -187,15 +232,13 @@
  * @param value differentiates whether the current item is selected or unselected
  * @param onValueChanged executes the provided lambda while returning the inverse state of [value]
  * @param interactionSource used to emit [PressInteraction] events
- * @param role used to define this composable's semantic role (for Accessibility purposes)
  */
 private fun Modifier.tvClickable(
     enabled: Boolean,
     onClick: (() -> Unit)? = null,
     value: Boolean = false,
     onValueChanged: ((Boolean) -> Unit)? = null,
-    interactionSource: MutableInteractionSource,
-    role: Role?
+    interactionSource: MutableInteractionSource
 ) = this
     .handleDPadEnter(
         enabled = enabled,
@@ -216,7 +259,6 @@
             }
             false
         }
-        role?.let { nnRole -> this.role = nnRole }
         if (!enabled) {
             disabled()
         }
@@ -283,6 +325,36 @@
 }
 
 /**
+ * Returns the alpha value for Surface's background based on its current indication state. The
+ * value ranges between 0f and 1f.
+ */
+private fun stateAlpha(
+    enabled: Boolean,
+    focused: Boolean,
+    pressed: Boolean,
+    selected: Boolean
+): Float {
+    return when {
+        enabled -> EnabledContentAlpha
+        !enabled && pressed -> DisabledPressedStateAlpha
+        !enabled && focused -> DisabledFocusedStateAlpha
+        !enabled && selected -> DisabledSelectedStateAlpha
+        else -> DisabledDefaultStateAlpha
+    }
+}
+
+private const val DisabledPressedStateAlpha = 0.8f
+private const val DisabledFocusedStateAlpha = 0.8f
+private const val DisabledSelectedStateAlpha = 0.8f
+private const val DisabledDefaultStateAlpha = 0.6f
+
+private const val FocusedZIndex = 0.5f
+private const val NonFocusedZIndex = 0f
+
+private const val DisabledContentAlpha = 0.8f
+internal const val EnabledContentAlpha = 1f
+
+/**
  * CompositionLocal containing the current absolute elevation provided by Surface components. This
  * absolute elevation is a sum of all the previous elevations. Absolute elevation is only used for
  * calculating surface tonal colors, and is *not* used for drawing the shadow in a [SurfaceImpl].
diff --git a/tv/tv-material/src/main/java/androidx/tv/material3/SurfaceDefaults.kt b/tv/tv-material/src/main/java/androidx/tv/material3/SurfaceDefaults.kt
index 38a3b45..1a1ef5d 100644
--- a/tv/tv-material/src/main/java/androidx/tv/material3/SurfaceDefaults.kt
+++ b/tv/tv-material/src/main/java/androidx/tv/material3/SurfaceDefaults.kt
@@ -37,7 +37,7 @@
             pressed && enabled -> shape.pressedShape
             focused && enabled -> shape.focusedShape
             focused && !enabled -> shape.focusedDisabledShape
-            enabled -> shape.defaultShape
+            enabled -> shape.shape
             else -> shape.disabledShape
         }
     }
@@ -46,7 +46,7 @@
      * Creates a [ClickableSurfaceShape] that represents the default container shapes used in a
      * Surface.
      *
-     * @param defaultShape the shape used when the Surface is enabled, and has no other
+     * @param shape the shape used when the Surface is enabled, and has no other
      * [Interaction]s.
      * @param focusedShape the shape used when the Surface is enabled and focused.
      * @param pressedShape the shape used when the Surface is enabled pressed.
@@ -56,13 +56,13 @@
     @ReadOnlyComposable
     @Composable
     fun shape(
-        defaultShape: Shape = MaterialTheme.shapes.medium,
-        focusedShape: Shape = defaultShape,
-        pressedShape: Shape = defaultShape,
-        disabledShape: Shape = defaultShape,
+        shape: Shape = MaterialTheme.shapes.medium,
+        focusedShape: Shape = shape,
+        pressedShape: Shape = shape,
+        disabledShape: Shape = shape,
         focusedDisabledShape: Shape = disabledShape
     ) = ClickableSurfaceShape(
-        defaultShape = defaultShape,
+        shape = shape,
         focusedShape = focusedShape,
         pressedShape = pressedShape,
         disabledShape = disabledShape,
@@ -78,7 +78,7 @@
         return when {
             pressed && enabled -> color.pressedColor
             focused && enabled -> color.focusedColor
-            enabled -> color.defaultColor
+            enabled -> color.color
             else -> color.disabledColor
         }
     }
@@ -87,7 +87,7 @@
      * Creates a [ClickableSurfaceColor] that represents the default container colors used in a
      * Surface.
      *
-     * @param defaultColor the container color of this Surface when enabled
+     * @param color the container color of this Surface when enabled
      * @param focusedColor the container color of this Surface when enabled and focused
      * @param pressedColor the container color of this Surface when enabled and pressed
      * @param disabledColor the container color of this Surface when not enabled
@@ -95,14 +95,14 @@
     @ReadOnlyComposable
     @Composable
     fun color(
-        defaultColor: Color = MaterialTheme.colorScheme.surface,
+        color: Color = MaterialTheme.colorScheme.surface,
         focusedColor: Color = MaterialTheme.colorScheme.inverseSurface,
         pressedColor: Color = MaterialTheme.colorScheme.inverseSurface,
         disabledColor: Color = MaterialTheme.colorScheme.surfaceVariant.copy(
             alpha = DisabledBackgroundAlpha
         )
     ) = ClickableSurfaceColor(
-        defaultColor = defaultColor,
+        color = color,
         focusedColor = focusedColor,
         pressedColor = pressedColor,
         disabledColor = disabledColor
@@ -112,7 +112,7 @@
      * Creates a [ClickableSurfaceColor] that represents the default content colors used in a
      * Surface.
      *
-     * @param defaultColor the content color of this Surface when enabled
+     * @param color the content color of this Surface when enabled
      * @param focusedColor the content color of this Surface when enabled and focused
      * @param pressedColor the content color of this Surface when enabled and pressed
      * @param disabledColor the content color of this Surface when not enabled
@@ -120,16 +120,51 @@
     @ReadOnlyComposable
     @Composable
     fun contentColor(
-        defaultColor: Color = MaterialTheme.colorScheme.onSurface,
+        color: Color = MaterialTheme.colorScheme.onSurface,
         focusedColor: Color = MaterialTheme.colorScheme.inverseOnSurface,
         pressedColor: Color = MaterialTheme.colorScheme.inverseOnSurface,
         disabledColor: Color = MaterialTheme.colorScheme.onSurface
     ) = ClickableSurfaceColor(
-        defaultColor = defaultColor,
+        color = color,
         focusedColor = focusedColor,
         pressedColor = pressedColor,
         disabledColor = disabledColor
     )
+
+    internal fun glow(
+        enabled: Boolean,
+        focused: Boolean,
+        pressed: Boolean,
+        glow: ClickableSurfaceGlow
+    ): Glow {
+        return if (enabled) {
+            when {
+                pressed -> glow.pressedGlow
+                focused -> glow.focusedGlow
+                else -> glow.glow
+            }
+        } else {
+            Glow.None
+        }
+    }
+
+    /**
+     * Creates a [ClickableSurfaceGlow] that represents the default [Glow]s used in a
+     * Surface.
+     *
+     * @param glow the Glow behind this Surface when enabled
+     * @param focusedGlow the Glow behind this Surface when focused
+     * @param pressedGlow the Glow behind this Surface when pressed
+     */
+    fun glow(
+        glow: Glow = Glow.None,
+        focusedGlow: Glow = glow,
+        pressedGlow: Glow = glow
+    ) = ClickableSurfaceGlow(
+        glow = glow,
+        focusedGlow = focusedGlow,
+        pressedGlow = pressedGlow
+    )
 }
 
 private const val DisabledBackgroundAlpha = 0.4f
diff --git a/tv/tv-material/src/main/java/androidx/tv/material3/SurfaceStyles.kt b/tv/tv-material/src/main/java/androidx/tv/material3/SurfaceStyles.kt
index 7d0b3cd..60c1a7b 100644
--- a/tv/tv-material/src/main/java/androidx/tv/material3/SurfaceStyles.kt
+++ b/tv/tv-material/src/main/java/androidx/tv/material3/SurfaceStyles.kt
@@ -17,12 +17,15 @@
 package androidx.tv.material3
 
 import androidx.compose.foundation.interaction.Interaction
+import androidx.compose.runtime.Immutable
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
 
 /**
  * Defines [Shape] for all TV [Interaction] states of a Clickable Surface.
- * @param defaultShape [Shape] to be applied when Clickable Surface is in the default state.
+ * @param shape [Shape] to be applied when Clickable Surface is in the default state.
  * @param focusedShape [Shape] to be applied when Clickable Surface is focused.
  * @param pressedShape [Shape] to be applied when Clickable Surface is pressed.
  * @param disabledShape [Shape] to be applied when Clickable Surface is disabled.
@@ -30,8 +33,9 @@
  * default state.
  */
 @ExperimentalTvMaterial3Api
+@Immutable
 class ClickableSurfaceShape internal constructor(
-    val defaultShape: Shape,
+    val shape: Shape,
     val focusedShape: Shape,
     val pressedShape: Shape,
     val disabledShape: Shape,
@@ -43,7 +47,7 @@
 
         other as ClickableSurfaceShape
 
-        if (defaultShape != other.defaultShape) return false
+        if (shape != other.shape) return false
         if (focusedShape != other.focusedShape) return false
         if (pressedShape != other.pressedShape) return false
         if (disabledShape != other.disabledShape) return false
@@ -53,7 +57,7 @@
     }
 
     override fun hashCode(): Int {
-        var result = defaultShape.hashCode()
+        var result = shape.hashCode()
         result = 31 * result + focusedShape.hashCode()
         result = 31 * result + pressedShape.hashCode()
         result = 31 * result + disabledShape.hashCode()
@@ -63,7 +67,7 @@
     }
 
     override fun toString(): String {
-        return "ClickableSurfaceShape(defaultShape=$defaultShape, focusedShape=$focusedShape, " +
+        return "ClickableSurfaceShape(shape=$shape, focusedShape=$focusedShape, " +
             "pressedShape=$pressedShape, disabledShape=$disabledShape, " +
             "focusedDisabledShape=$focusedDisabledShape)"
     }
@@ -71,14 +75,15 @@
 
 /**
  * Defines [Color] for all TV [Interaction] states of a Clickable Surface.
- * @param defaultColor [Color] to be applied when Clickable Surface is in the default state.
+ * @param color [Color] to be applied when Clickable Surface is in the default state.
  * @param focusedColor [Color] to be applied when Clickable Surface is focused.
  * @param pressedColor [Color] to be applied when Clickable Surface is pressed.
  * @param disabledColor [Color] to be applied when Clickable Surface is disabled.
  */
 @ExperimentalTvMaterial3Api
+@Immutable
 class ClickableSurfaceColor internal constructor(
-    val defaultColor: Color,
+    val color: Color,
     val focusedColor: Color,
     val pressedColor: Color,
     val disabledColor: Color
@@ -89,7 +94,7 @@
 
         other as ClickableSurfaceColor
 
-        if (defaultColor != other.defaultColor) return false
+        if (color != other.color) return false
         if (focusedColor != other.focusedColor) return false
         if (pressedColor != other.pressedColor) return false
         if (disabledColor != other.disabledColor) return false
@@ -98,7 +103,7 @@
     }
 
     override fun hashCode(): Int {
-        var result = defaultColor.hashCode()
+        var result = color.hashCode()
         result = 31 * result + focusedColor.hashCode()
         result = 31 * result + pressedColor.hashCode()
         result = 31 * result + disabledColor.hashCode()
@@ -107,7 +112,101 @@
     }
 
     override fun toString(): String {
-        return "ClickableSurfaceColor(defaultColor=$defaultColor, focusedColor=$focusedColor, " +
+        return "ClickableSurfaceColor(color=$color, focusedColor=$focusedColor, " +
             "pressedColor=$pressedColor, disabledColor=$disabledColor)"
     }
 }
+
+/**
+ * Defines [Glow] for all TV states of [Surface].
+ * @param glow [Glow] to be applied when [Surface] is in the default state.
+ * @param focusedGlow [Glow] to be applied when [Surface] is focused.
+ * @param pressedGlow [Glow] to be applied when [Surface] is pressed.
+ */
+@ExperimentalTvMaterial3Api
+@Immutable
+class ClickableSurfaceGlow internal constructor(
+    val glow: Glow,
+    val focusedGlow: Glow,
+    val pressedGlow: Glow
+) {
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        if (other == null || this::class != other::class) return false
+
+        other as ClickableSurfaceGlow
+
+        if (glow != other.glow) return false
+        if (focusedGlow != other.focusedGlow) return false
+        if (pressedGlow != other.pressedGlow) return false
+
+        return true
+    }
+
+    override fun hashCode(): Int {
+        var result = glow.hashCode()
+        result = 31 * result + focusedGlow.hashCode()
+        result = 31 * result + pressedGlow.hashCode()
+
+        return result
+    }
+
+    override fun toString(): String {
+        return "ClickableSurfaceGlow(glow=$glow, focusedGlow=$focusedGlow, " +
+            "pressedGlow=$pressedGlow)"
+    }
+}
+
+/**
+ * Defines the shadow for a TV component.
+ * @param elevationColor [Color] to be applied on the shadow
+ * @param elevation defines how strong should be the shadow. Larger its value, further the
+ * shadow goes from the center of the component.
+ */
+@ExperimentalTvMaterial3Api
+@Immutable
+class Glow(
+    val elevationColor: Color,
+    val elevation: Dp
+) {
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        if (other == null || this::class != other::class) return false
+
+        other as Glow
+
+        if (elevationColor != other.elevationColor) return false
+        if (elevation != other.elevation) return false
+
+        return true
+    }
+
+    override fun hashCode(): Int {
+        var result = elevationColor.hashCode()
+        result = 31 * result + elevation.hashCode()
+        return result
+    }
+
+    override fun toString(): String {
+        return "Glow(elevationColor=$elevationColor, elevation=$elevation)"
+    }
+
+    fun copy(
+        glowColor: Color? = null,
+        glowElevation: Dp? = null
+    ): Glow = Glow(
+        elevationColor = glowColor ?: this.elevationColor,
+        elevation = glowElevation ?: this.elevation
+    )
+
+    companion object {
+        /**
+         * Signifies the absence of a glow in TV Components. Use this if you do not want to display
+         * a glow indication in any of the Leanback TV Components.
+         */
+        val None = Glow(
+            elevationColor = Color.Transparent,
+            elevation = 0.dp
+        )
+    }
+}
diff --git a/wear/compose/compose-foundation/api/current.txt b/wear/compose/compose-foundation/api/current.txt
index 4e9e434..4c1a576 100644
--- a/wear/compose/compose-foundation/api/current.txt
+++ b/wear/compose/compose-foundation/api/current.txt
@@ -167,13 +167,6 @@
     property public final androidx.compose.ui.text.font.FontWeight? fontWeight;
   }
 
-  public final class HierarchicalFocusCoordinatorKt {
-    method @androidx.compose.runtime.Composable public static void HierarchicalFocusCoordinator(kotlin.jvm.functions.Function0<java.lang.Boolean> requiresFocus, kotlin.jvm.functions.Function0<kotlin.Unit> content);
-    method @androidx.compose.runtime.Composable public static void OnFocusChange(kotlin.jvm.functions.Function2<? super kotlinx.coroutines.CoroutineScope,? super java.lang.Boolean,kotlin.Unit> onFocusChanged);
-    method @androidx.compose.runtime.Composable public static void RequestFocusWhenActive(androidx.compose.ui.focus.FocusRequester focusRequester);
-    method @androidx.compose.runtime.Composable public static androidx.compose.ui.focus.FocusRequester rememberActiveFocusRequester();
-  }
-
 }
 
 package androidx.wear.compose.foundation.lazy {
diff --git a/wear/compose/compose-foundation/api/public_plus_experimental_current.txt b/wear/compose/compose-foundation/api/public_plus_experimental_current.txt
index 4e9e434..0f6e559 100644
--- a/wear/compose/compose-foundation/api/public_plus_experimental_current.txt
+++ b/wear/compose/compose-foundation/api/public_plus_experimental_current.txt
@@ -167,11 +167,14 @@
     property public final androidx.compose.ui.text.font.FontWeight? fontWeight;
   }
 
+  @kotlin.RequiresOptIn(message="This Wear Foundation API is experimental and is likely to change or to be removed in" + " the future.") @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.BINARY) public @interface ExperimentalWearFoundationApi {
+  }
+
   public final class HierarchicalFocusCoordinatorKt {
-    method @androidx.compose.runtime.Composable public static void HierarchicalFocusCoordinator(kotlin.jvm.functions.Function0<java.lang.Boolean> requiresFocus, kotlin.jvm.functions.Function0<kotlin.Unit> content);
-    method @androidx.compose.runtime.Composable public static void OnFocusChange(kotlin.jvm.functions.Function2<? super kotlinx.coroutines.CoroutineScope,? super java.lang.Boolean,kotlin.Unit> onFocusChanged);
-    method @androidx.compose.runtime.Composable public static void RequestFocusWhenActive(androidx.compose.ui.focus.FocusRequester focusRequester);
-    method @androidx.compose.runtime.Composable public static androidx.compose.ui.focus.FocusRequester rememberActiveFocusRequester();
+    method @androidx.compose.runtime.Composable @androidx.wear.compose.foundation.ExperimentalWearFoundationApi public static void HierarchicalFocusCoordinator(kotlin.jvm.functions.Function0<java.lang.Boolean> requiresFocus, kotlin.jvm.functions.Function0<kotlin.Unit> content);
+    method @androidx.compose.runtime.Composable @androidx.wear.compose.foundation.ExperimentalWearFoundationApi public static void OnFocusChange(kotlin.jvm.functions.Function2<? super kotlinx.coroutines.CoroutineScope,? super java.lang.Boolean,kotlin.Unit> onFocusChanged);
+    method @androidx.compose.runtime.Composable @androidx.wear.compose.foundation.ExperimentalWearFoundationApi public static void RequestFocusWhenActive(androidx.compose.ui.focus.FocusRequester focusRequester);
+    method @androidx.compose.runtime.Composable @androidx.wear.compose.foundation.ExperimentalWearFoundationApi public static androidx.compose.ui.focus.FocusRequester rememberActiveFocusRequester();
   }
 
 }
diff --git a/wear/compose/compose-foundation/api/restricted_current.txt b/wear/compose/compose-foundation/api/restricted_current.txt
index 4e9e434..4c1a576 100644
--- a/wear/compose/compose-foundation/api/restricted_current.txt
+++ b/wear/compose/compose-foundation/api/restricted_current.txt
@@ -167,13 +167,6 @@
     property public final androidx.compose.ui.text.font.FontWeight? fontWeight;
   }
 
-  public final class HierarchicalFocusCoordinatorKt {
-    method @androidx.compose.runtime.Composable public static void HierarchicalFocusCoordinator(kotlin.jvm.functions.Function0<java.lang.Boolean> requiresFocus, kotlin.jvm.functions.Function0<kotlin.Unit> content);
-    method @androidx.compose.runtime.Composable public static void OnFocusChange(kotlin.jvm.functions.Function2<? super kotlinx.coroutines.CoroutineScope,? super java.lang.Boolean,kotlin.Unit> onFocusChanged);
-    method @androidx.compose.runtime.Composable public static void RequestFocusWhenActive(androidx.compose.ui.focus.FocusRequester focusRequester);
-    method @androidx.compose.runtime.Composable public static androidx.compose.ui.focus.FocusRequester rememberActiveFocusRequester();
-  }
-
 }
 
 package androidx.wear.compose.foundation.lazy {
diff --git a/wear/compose/compose-foundation/samples/src/main/java/androidx/wear/compose/foundation/samples/HierarchicalFocusCoordinatorSample.kt b/wear/compose/compose-foundation/samples/src/main/java/androidx/wear/compose/foundation/samples/HierarchicalFocusCoordinatorSample.kt
index fd3403e..2ee2e3f 100644
--- a/wear/compose/compose-foundation/samples/src/main/java/androidx/wear/compose/foundation/samples/HierarchicalFocusCoordinatorSample.kt
+++ b/wear/compose/compose-foundation/samples/src/main/java/androidx/wear/compose/foundation/samples/HierarchicalFocusCoordinatorSample.kt
@@ -40,9 +40,11 @@
 import androidx.compose.ui.text.style.TextAlign
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.sp
+import androidx.wear.compose.foundation.ExperimentalWearFoundationApi
 import androidx.wear.compose.foundation.HierarchicalFocusCoordinator
 import androidx.wear.compose.foundation.rememberActiveFocusRequester
 
+@OptIn(ExperimentalWearFoundationApi::class)
 @Sampled
 @Composable
 fun HierarchicalFocusCoordinatorSample() {
diff --git a/wear/compose/compose-foundation/src/androidAndroidTest/kotlin/androidx/wear/compose/foundation/HierarchicalFocusCoordinatorTest.kt b/wear/compose/compose-foundation/src/androidAndroidTest/kotlin/androidx/wear/compose/foundation/HierarchicalFocusCoordinatorTest.kt
index 121a3f3..d071b24 100644
--- a/wear/compose/compose-foundation/src/androidAndroidTest/kotlin/androidx/wear/compose/foundation/HierarchicalFocusCoordinatorTest.kt
+++ b/wear/compose/compose-foundation/src/androidAndroidTest/kotlin/androidx/wear/compose/foundation/HierarchicalFocusCoordinatorTest.kt
@@ -33,6 +33,7 @@
 import org.junit.Rule
 import org.junit.Test
 
+@OptIn(ExperimentalWearFoundationApi::class)
 class HierarchicalFocusCoordinatorTest {
     @get:Rule
     val rule = createComposeRule()
@@ -213,6 +214,52 @@
         }
     }
 
+    @Test
+    public fun focus_not_required_reported_correctly() {
+        var focused = false
+        rule.setContent {
+            Box {
+                HierarchicalFocusCoordinator(
+                    requiresFocus = { false }
+                ) {
+                    FocusableTestItem { focused = it }
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            Assert.assertFalse(focused)
+        }
+    }
+
+    @Test
+    public fun updating_requiresFocus_lambda_works() {
+        var lambdaUpdated by mutableStateOf(false)
+        var focused = false
+        rule.setContent {
+            Box {
+                HierarchicalFocusCoordinator(
+                    // We switch between a lambda that always returns false and one that always
+                    // return true given the state of lambdaUpdated.
+                    requiresFocus = if (lambdaUpdated) {
+                        { true }
+                    } else {
+                        { false }
+                    }
+                ) {
+                    FocusableTestItem { focused = it }
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            lambdaUpdated = true
+        }
+
+        rule.runOnIdle {
+            Assert.assertTrue(focused)
+        }
+    }
     @Composable
     private fun FocusableTestItem(onFocusChanged: (Boolean) -> Unit) {
         val focusRequester = rememberActiveFocusRequester()
diff --git a/appactions/interaction/interaction-service/src/main/java/androidx/appactions/interaction/service/proto/package-info.java b/wear/compose/compose-foundation/src/commonMain/kotlin/androidx/wear/compose/foundation/ExperimentalWearFoundationApi.kt
similarity index 69%
copy from appactions/interaction/interaction-service/src/main/java/androidx/appactions/interaction/service/proto/package-info.java
copy to wear/compose/compose-foundation/src/commonMain/kotlin/androidx/wear/compose/foundation/ExperimentalWearFoundationApi.kt
index 93db9d1..e8458d2 100644
--- a/appactions/interaction/interaction-service/src/main/java/androidx/appactions/interaction/service/proto/package-info.java
+++ b/wear/compose/compose-foundation/src/commonMain/kotlin/androidx/wear/compose/foundation/ExperimentalWearFoundationApi.kt
@@ -14,8 +14,11 @@
  * limitations under the License.
  */
 
-/** @hide */
-@RestrictTo(RestrictTo.Scope.LIBRARY)
-package androidx.appactions.interaction.service.proto;
+package androidx.wear.compose.foundation
 
-import androidx.annotation.RestrictTo;
+@RequiresOptIn(
+    "This Wear Foundation API is experimental and is likely to change or to be removed in" +
+        " the future."
+)
+@Retention(AnnotationRetention.BINARY)
+annotation class ExperimentalWearFoundationApi
\ No newline at end of file
diff --git a/wear/compose/compose-foundation/src/commonMain/kotlin/androidx/wear/compose/foundation/HierarchicalFocusCoordinator.kt b/wear/compose/compose-foundation/src/commonMain/kotlin/androidx/wear/compose/foundation/HierarchicalFocusCoordinator.kt
index 737d9f5..4a7a3dc 100644
--- a/wear/compose/compose-foundation/src/commonMain/kotlin/androidx/wear/compose/foundation/HierarchicalFocusCoordinator.kt
+++ b/wear/compose/compose-foundation/src/commonMain/kotlin/androidx/wear/compose/foundation/HierarchicalFocusCoordinator.kt
@@ -54,6 +54,7 @@
  * @param content The content of this component.
  */
 @Composable
+@ExperimentalWearFoundationApi
 public fun HierarchicalFocusCoordinator(
     requiresFocus: () -> Boolean,
     content: @Composable () -> Unit
@@ -74,6 +75,7 @@
  * new state (if true, we are becoming active and should request focus).
  */
 @Composable
+@ExperimentalWearFoundationApi
 public fun OnFocusChange(onFocusChanged: CoroutineScope.(Boolean) -> Unit) {
     FocusComposableImpl(
         focusEnabled = { true },
@@ -91,6 +93,7 @@
  * @param focusRequester The associated [FocusRequester] to request focus on.
  */
 @Composable
+@ExperimentalWearFoundationApi
 public fun RequestFocusWhenActive(focusRequester: FocusRequester) {
     OnFocusChange {
         if (it) focusRequester.requestFocus()
@@ -106,6 +109,7 @@
  * Composable that is part of the composition.
  */
 @Composable
+@ExperimentalWearFoundationApi
 public fun rememberActiveFocusRequester() =
     remember { FocusRequester() }.also { RequestFocusWhenActive(it) }
 
@@ -123,11 +127,12 @@
     onFocusChanged: CoroutineScope.(Boolean) -> Unit,
     content: @Composable () -> Unit
 ) {
+    val updatedFocusEnabled by rememberUpdatedState(focusEnabled)
     val parent by rememberUpdatedState(LocalFocusNodeParent.current)
 
     // Node in our internal tree representation of the FocusComposableImpl
     val node = remember { FocusNode(focused = derivedStateOf {
-        (parent?.focused?.value ?: true) && focusEnabled()
+        (parent?.focused?.value ?: true) && updatedFocusEnabled()
     }) }
 
     // Attach our node to our parent's (and remove if we leave the composition).
diff --git a/wear/compose/compose-material/samples/build.gradle b/wear/compose/compose-material/samples/build.gradle
index 64aa22f..55ec414 100644
--- a/wear/compose/compose-material/samples/build.gradle
+++ b/wear/compose/compose-material/samples/build.gradle
@@ -35,9 +35,11 @@
     implementation(project(":compose:ui:ui"))
     implementation(project(":compose:ui:ui-util"))
     implementation(project(":compose:ui:ui-text"))
+    implementation(project(":compose:ui:ui-tooling-preview"))
     implementation(project(":compose:material:material-icons-core"))
     implementation(project(":wear:compose:compose-material"))
     implementation(project(":wear:compose:compose-foundation"))
+    implementation(project(":wear:compose:compose-ui-tooling"))
 
     androidTestImplementation(project(":compose:ui:ui-test"))
     androidTestImplementation(project(":compose:ui:ui-test-junit4"))
diff --git a/wear/compose/compose-material/samples/src/main/java/androidx/wear/compose/material/samples/PickerGroupSample.kt b/wear/compose/compose-material/samples/src/main/java/androidx/wear/compose/material/samples/PickerGroupSample.kt
new file mode 100644
index 0000000..3278a4d
--- /dev/null
+++ b/wear/compose/compose-material/samples/src/main/java/androidx/wear/compose/material/samples/PickerGroupSample.kt
@@ -0,0 +1,110 @@
+/*
+ * 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.material.samples
+
+import androidx.annotation.Sampled
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.size
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.wear.compose.material.PickerGroup
+import androidx.wear.compose.material.PickerGroupItem
+import androidx.wear.compose.material.Text
+import androidx.wear.compose.material.rememberPickerGroupState
+import androidx.wear.compose.material.rememberPickerState
+
+@Sampled
+@Composable
+fun PickerGroup24Hours() {
+    val pickerGroupState = rememberPickerGroupState()
+    val pickerStateHour = rememberPickerState(initialNumberOfOptions = 24)
+    val pickerStateMinute = rememberPickerState(initialNumberOfOptions = 60)
+    Column(
+        modifier = Modifier.fillMaxWidth(),
+        verticalArrangement = Arrangement.Center,
+        horizontalAlignment = Alignment.CenterHorizontally
+    ) {
+        Spacer(modifier = Modifier.size(30.dp))
+        Text(text = if (pickerGroupState.selectedIndex == 0) "Hours" else "Minutes")
+        Spacer(modifier = Modifier.size(10.dp))
+        PickerGroup(
+            PickerGroupItem(
+                pickerState = pickerStateHour,
+                option = { optionIndex, _ -> Text(text = "%02d".format(optionIndex)) },
+                modifier = Modifier.size(80.dp, 100.dp)
+            ),
+            PickerGroupItem(
+                pickerState = pickerStateMinute,
+                option = { optionIndex, _ -> Text(text = "%02d".format(optionIndex)) },
+                modifier = Modifier.size(80.dp, 100.dp)
+            ),
+            pickerGroupState = pickerGroupState,
+            autoCenter = false
+        )
+    }
+}
+
+@Sampled
+@Composable
+fun AutoCenteringPickerGroup() {
+    val pickerGroupState = rememberPickerGroupState()
+    val pickerStateHour = rememberPickerState(initialNumberOfOptions = 24)
+    val pickerStateMinute = rememberPickerState(initialNumberOfOptions = 60)
+    val pickerStateSeconds = rememberPickerState(initialNumberOfOptions = 60)
+    val pickerStateMilliSeconds = rememberPickerState(initialNumberOfOptions = 1000)
+    Column(
+        modifier = Modifier.fillMaxWidth(),
+        verticalArrangement = Arrangement.Center,
+        horizontalAlignment = Alignment.CenterHorizontally
+    ) {
+        val headingText = mapOf(
+            0 to "Hours", 1 to "Minutes", 2 to "Seconds", 3 to "Milli"
+        )
+        Spacer(modifier = Modifier.size(30.dp))
+        Text(text = headingText[pickerGroupState.selectedIndex]!!)
+        Spacer(modifier = Modifier.size(10.dp))
+        PickerGroup(
+            PickerGroupItem(
+                pickerState = pickerStateHour,
+                option = { optionIndex, _ -> Text(text = "%02d".format(optionIndex)) },
+                modifier = Modifier.size(80.dp, 100.dp)
+            ),
+            PickerGroupItem(
+                pickerState = pickerStateMinute,
+                option = { optionIndex, _ -> Text(text = "%02d".format(optionIndex)) },
+                modifier = Modifier.size(80.dp, 100.dp)
+            ),
+            PickerGroupItem(
+                pickerState = pickerStateSeconds,
+                option = { optionIndex, _ -> Text(text = "%02d".format(optionIndex)) },
+                modifier = Modifier.size(80.dp, 100.dp)
+            ),
+            PickerGroupItem(
+                pickerState = pickerStateMilliSeconds,
+                option = { optionIndex, _ -> Text(text = "%03d".format(optionIndex)) },
+                modifier = Modifier.size(80.dp, 100.dp)
+            ),
+            pickerGroupState = pickerGroupState,
+            autoCenter = true
+        )
+    }
+}
\ No newline at end of file
diff --git a/wear/compose/compose-material/samples/src/main/java/androidx/wear/compose/material/samples/PreviewSample.kt b/wear/compose/compose-material/samples/src/main/java/androidx/wear/compose/material/samples/PreviewSample.kt
new file mode 100644
index 0000000..ab01a42
--- /dev/null
+++ b/wear/compose/compose-material/samples/src/main/java/androidx/wear/compose/material/samples/PreviewSample.kt
@@ -0,0 +1,99 @@
+/*
+ * 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.material.samples
+
+import androidx.annotation.Sampled
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.wrapContentSize
+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.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.wear.compose.material.Button
+import androidx.wear.compose.material.ButtonDefaults
+import androidx.wear.compose.material.CardDefaults
+import androidx.wear.compose.material.Icon
+import androidx.wear.compose.material.MaterialTheme
+import androidx.wear.compose.material.Text
+import androidx.wear.compose.material.TitleCard
+import androidx.wear.compose.material.ToggleButton
+import androidx.wear.compose.material.ToggleButtonDefaults
+import androidx.wear.compose.ui.tooling.preview.WearPreviewDevices
+import androidx.wear.compose.ui.tooling.preview.WearPreviewFontScales
+import androidx.wear.compose.ui.tooling.preview.WearPreviewLargeRound
+import androidx.wear.compose.ui.tooling.preview.WearPreviewSmallRound
+import androidx.wear.compose.ui.tooling.preview.WearPreviewSquare
+
+@WearPreviewSmallRound
+@WearPreviewLargeRound
+@WearPreviewSquare
+@Sampled
+@Composable
+fun ButtonWithIconPreview() {
+    Button(
+        onClick = { /* Do something */ },
+        enabled = true,
+    ) {
+        Icon(
+            painter = painterResource(id = R.drawable.ic_airplanemode_active_24px),
+            contentDescription = "airplane",
+            modifier = Modifier
+                .size(ButtonDefaults.DefaultIconSize).wrapContentSize(align = Alignment.Center),
+        )
+    }
+}
+
+@WearPreviewFontScales
+@Sampled
+@Composable
+fun TitleCardWithImagePreview() {
+    TitleCard(
+        onClick = { /* Do something */ },
+        title = { Text("TitleCard With an ImageBackground") },
+        backgroundPainter = CardDefaults.imageWithScrimBackgroundPainter(
+            backgroundImagePainter = painterResource(id = R.drawable.backgroundimage)
+        ),
+        contentColor = MaterialTheme.colors.onSurface,
+        titleColor = MaterialTheme.colors.onSurface,
+    ) {
+        Text("Text coloured to stand out on the image")
+    }
+}
+
+@WearPreviewDevices
+@Sampled
+@Composable
+fun ToggleButtonWithIconPreview() {
+    var checked by remember { mutableStateOf(true) }
+    ToggleButton(
+        checked = checked,
+        onCheckedChange = { checked = it },
+        enabled = true,
+    ) {
+        Icon(
+            painter = painterResource(id = R.drawable.ic_airplanemode_active_24px),
+            contentDescription = "airplane",
+            modifier = Modifier
+                .size(ToggleButtonDefaults.DefaultIconSize)
+                .wrapContentSize(align = Alignment.Center),
+        )
+    }
+}
\ No newline at end of file
diff --git a/wear/compose/compose-material/src/commonMain/kotlin/androidx/wear/compose/material/PickerGroup.kt b/wear/compose/compose-material/src/commonMain/kotlin/androidx/wear/compose/material/PickerGroup.kt
index 845c4685..114c40e 100644
--- a/wear/compose/compose-material/src/commonMain/kotlin/androidx/wear/compose/material/PickerGroup.kt
+++ b/wear/compose/compose-material/src/commonMain/kotlin/androidx/wear/compose/material/PickerGroup.kt
@@ -39,6 +39,7 @@
 import androidx.compose.ui.layout.Placeable
 import androidx.compose.ui.unit.Constraints
 import androidx.compose.ui.unit.Density
+import androidx.wear.compose.foundation.ExperimentalWearFoundationApi
 import androidx.wear.compose.foundation.HierarchicalFocusCoordinator
 import androidx.wear.compose.foundation.rememberActiveFocusRequester
 import kotlin.math.roundToInt
@@ -56,6 +57,12 @@
  * It is recommended to ensure that a [Picker] in non read only mode should have user scroll enabled
  * when touch exploration services are running.
  *
+ * Example of a sample picker group with an hour and minute picker (24 hour format)
+ * @sample androidx.wear.compose.material.samples.PickerGroup24Hours
+ *
+ * Example of an auto centering picker group where the total width exceeds screen's width
+ * @sample androidx.wear.compose.material.samples.AutoCenteringPickerGroup
+ *
  * @param pickers List of [Picker]s represented using [PickerGroupItem] in the same order of
  * display from left to right.
  * @param modifier Modifier to be applied to the PickerGroup
@@ -73,6 +80,7 @@
  * The integer parameter to the composable depicts the index where it will be kept. For example, 0
  * would represent the separator between the first and second picker.
  */
+@OptIn(ExperimentalWearFoundationApi::class)
 @Composable
 public fun PickerGroup(
     vararg pickers: PickerGroupItem,
diff --git a/wear/compose/compose-navigation/samples/build.gradle b/wear/compose/compose-navigation/samples/build.gradle
index e7f2dd4..25a4b70 100644
--- a/wear/compose/compose-navigation/samples/build.gradle
+++ b/wear/compose/compose-navigation/samples/build.gradle
@@ -57,6 +57,8 @@
     resolutionStrategy.dependencySubstitution {
         substitute(module("androidx.lifecycle:lifecycle-viewmodel:")).
                 using project(":lifecycle:lifecycle-viewmodel")
+        substitute(module("androidx.lifecycle:lifecycle-viewmodel-ktx:")).
+                using project(":lifecycle:lifecycle-viewmodel-ktx")
         substitute(module("androidx.lifecycle:lifecycle-runtime:")).
                 using project(":lifecycle:lifecycle-runtime")
         substitute(module("androidx.lifecycle:lifecycle-runtime-ktx:")).
diff --git a/wear/compose/compose-ui-tooling/api/current.txt b/wear/compose/compose-ui-tooling/api/current.txt
new file mode 100644
index 0000000..00f94c1
--- /dev/null
+++ b/wear/compose/compose-ui-tooling/api/current.txt
@@ -0,0 +1,20 @@
+// Signature format: 4.0
+package androidx.wear.compose.ui.tooling.preview {
+
+  @androidx.compose.ui.tooling.preview.Preview(device=androidx.compose.ui.tooling.preview.Devices.WEAR_OS_SQUARE, backgroundColor=4278190080L, showBackground=true, group="Devices - Small Square", showSystemUi=true) @androidx.compose.ui.tooling.preview.Preview(device=androidx.compose.ui.tooling.preview.Devices.WEAR_OS_LARGE_ROUND, backgroundColor=4278190080L, showBackground=true, group="Devices - Large Round", showSystemUi=true) @androidx.compose.ui.tooling.preview.Preview(device=androidx.compose.ui.tooling.preview.Devices.WEAR_OS_SMALL_ROUND, backgroundColor=4278190080L, showBackground=true, group="Devices - Small Round", showSystemUi=true) public @interface WearPreviewDevices {
+  }
+
+  @androidx.compose.ui.tooling.preview.Preview(device=androidx.compose.ui.tooling.preview.Devices.WEAR_OS_SMALL_ROUND, showSystemUi=true, backgroundColor=4278190080L, showBackground=true, group="Fonts - Small", fontScale=0.94f) @androidx.compose.ui.tooling.preview.Preview(device=androidx.compose.ui.tooling.preview.Devices.WEAR_OS_SMALL_ROUND, showSystemUi=true, backgroundColor=4278190080L, showBackground=true, group="Fonts - Normal", fontScale=1.0f) @androidx.compose.ui.tooling.preview.Preview(device=androidx.compose.ui.tooling.preview.Devices.WEAR_OS_SMALL_ROUND, showSystemUi=true, backgroundColor=4278190080L, showBackground=true, group="Fonts - Medium", fontScale=1.06f) @androidx.compose.ui.tooling.preview.Preview(device=androidx.compose.ui.tooling.preview.Devices.WEAR_OS_SMALL_ROUND, showSystemUi=true, backgroundColor=4278190080L, showBackground=true, group="Fonts - Large", fontScale=1.12f) @androidx.compose.ui.tooling.preview.Preview(device=androidx.compose.ui.tooling.preview.Devices.WEAR_OS_SMALL_ROUND, showSystemUi=true, backgroundColor=4278190080L, showBackground=true, group="Fonts - Larger", fontScale=1.18f) @androidx.compose.ui.tooling.preview.Preview(device=androidx.compose.ui.tooling.preview.Devices.WEAR_OS_SMALL_ROUND, showSystemUi=true, backgroundColor=4278190080L, showBackground=true, group="Fonts - Largest", fontScale=1.24f) public @interface WearPreviewFontScales {
+  }
+
+  @androidx.compose.ui.tooling.preview.Preview(device=androidx.compose.ui.tooling.preview.Devices.WEAR_OS_LARGE_ROUND, backgroundColor=4278190080L, showBackground=true, group="Devices - Large Round", showSystemUi=true) public @interface WearPreviewLargeRound {
+  }
+
+  @androidx.compose.ui.tooling.preview.Preview(device=androidx.compose.ui.tooling.preview.Devices.WEAR_OS_SMALL_ROUND, backgroundColor=4278190080L, showBackground=true, group="Devices - Small Round", showSystemUi=true) public @interface WearPreviewSmallRound {
+  }
+
+  @androidx.compose.ui.tooling.preview.Preview(device=androidx.compose.ui.tooling.preview.Devices.WEAR_OS_SQUARE, backgroundColor=4278190080L, showBackground=true, group="Devices - Small Square", showSystemUi=true) public @interface WearPreviewSquare {
+  }
+
+}
+
diff --git a/wear/compose/compose-ui-tooling/api/public_plus_experimental_current.txt b/wear/compose/compose-ui-tooling/api/public_plus_experimental_current.txt
new file mode 100644
index 0000000..00f94c1
--- /dev/null
+++ b/wear/compose/compose-ui-tooling/api/public_plus_experimental_current.txt
@@ -0,0 +1,20 @@
+// Signature format: 4.0
+package androidx.wear.compose.ui.tooling.preview {
+
+  @androidx.compose.ui.tooling.preview.Preview(device=androidx.compose.ui.tooling.preview.Devices.WEAR_OS_SQUARE, backgroundColor=4278190080L, showBackground=true, group="Devices - Small Square", showSystemUi=true) @androidx.compose.ui.tooling.preview.Preview(device=androidx.compose.ui.tooling.preview.Devices.WEAR_OS_LARGE_ROUND, backgroundColor=4278190080L, showBackground=true, group="Devices - Large Round", showSystemUi=true) @androidx.compose.ui.tooling.preview.Preview(device=androidx.compose.ui.tooling.preview.Devices.WEAR_OS_SMALL_ROUND, backgroundColor=4278190080L, showBackground=true, group="Devices - Small Round", showSystemUi=true) public @interface WearPreviewDevices {
+  }
+
+  @androidx.compose.ui.tooling.preview.Preview(device=androidx.compose.ui.tooling.preview.Devices.WEAR_OS_SMALL_ROUND, showSystemUi=true, backgroundColor=4278190080L, showBackground=true, group="Fonts - Small", fontScale=0.94f) @androidx.compose.ui.tooling.preview.Preview(device=androidx.compose.ui.tooling.preview.Devices.WEAR_OS_SMALL_ROUND, showSystemUi=true, backgroundColor=4278190080L, showBackground=true, group="Fonts - Normal", fontScale=1.0f) @androidx.compose.ui.tooling.preview.Preview(device=androidx.compose.ui.tooling.preview.Devices.WEAR_OS_SMALL_ROUND, showSystemUi=true, backgroundColor=4278190080L, showBackground=true, group="Fonts - Medium", fontScale=1.06f) @androidx.compose.ui.tooling.preview.Preview(device=androidx.compose.ui.tooling.preview.Devices.WEAR_OS_SMALL_ROUND, showSystemUi=true, backgroundColor=4278190080L, showBackground=true, group="Fonts - Large", fontScale=1.12f) @androidx.compose.ui.tooling.preview.Preview(device=androidx.compose.ui.tooling.preview.Devices.WEAR_OS_SMALL_ROUND, showSystemUi=true, backgroundColor=4278190080L, showBackground=true, group="Fonts - Larger", fontScale=1.18f) @androidx.compose.ui.tooling.preview.Preview(device=androidx.compose.ui.tooling.preview.Devices.WEAR_OS_SMALL_ROUND, showSystemUi=true, backgroundColor=4278190080L, showBackground=true, group="Fonts - Largest", fontScale=1.24f) public @interface WearPreviewFontScales {
+  }
+
+  @androidx.compose.ui.tooling.preview.Preview(device=androidx.compose.ui.tooling.preview.Devices.WEAR_OS_LARGE_ROUND, backgroundColor=4278190080L, showBackground=true, group="Devices - Large Round", showSystemUi=true) public @interface WearPreviewLargeRound {
+  }
+
+  @androidx.compose.ui.tooling.preview.Preview(device=androidx.compose.ui.tooling.preview.Devices.WEAR_OS_SMALL_ROUND, backgroundColor=4278190080L, showBackground=true, group="Devices - Small Round", showSystemUi=true) public @interface WearPreviewSmallRound {
+  }
+
+  @androidx.compose.ui.tooling.preview.Preview(device=androidx.compose.ui.tooling.preview.Devices.WEAR_OS_SQUARE, backgroundColor=4278190080L, showBackground=true, group="Devices - Small Square", showSystemUi=true) public @interface WearPreviewSquare {
+  }
+
+}
+
diff --git a/wear/compose/compose-ui-tooling/api/res-current.txt b/wear/compose/compose-ui-tooling/api/res-current.txt
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/wear/compose/compose-ui-tooling/api/res-current.txt
diff --git a/wear/compose/compose-ui-tooling/api/restricted_current.txt b/wear/compose/compose-ui-tooling/api/restricted_current.txt
new file mode 100644
index 0000000..00f94c1
--- /dev/null
+++ b/wear/compose/compose-ui-tooling/api/restricted_current.txt
@@ -0,0 +1,20 @@
+// Signature format: 4.0
+package androidx.wear.compose.ui.tooling.preview {
+
+  @androidx.compose.ui.tooling.preview.Preview(device=androidx.compose.ui.tooling.preview.Devices.WEAR_OS_SQUARE, backgroundColor=4278190080L, showBackground=true, group="Devices - Small Square", showSystemUi=true) @androidx.compose.ui.tooling.preview.Preview(device=androidx.compose.ui.tooling.preview.Devices.WEAR_OS_LARGE_ROUND, backgroundColor=4278190080L, showBackground=true, group="Devices - Large Round", showSystemUi=true) @androidx.compose.ui.tooling.preview.Preview(device=androidx.compose.ui.tooling.preview.Devices.WEAR_OS_SMALL_ROUND, backgroundColor=4278190080L, showBackground=true, group="Devices - Small Round", showSystemUi=true) public @interface WearPreviewDevices {
+  }
+
+  @androidx.compose.ui.tooling.preview.Preview(device=androidx.compose.ui.tooling.preview.Devices.WEAR_OS_SMALL_ROUND, showSystemUi=true, backgroundColor=4278190080L, showBackground=true, group="Fonts - Small", fontScale=0.94f) @androidx.compose.ui.tooling.preview.Preview(device=androidx.compose.ui.tooling.preview.Devices.WEAR_OS_SMALL_ROUND, showSystemUi=true, backgroundColor=4278190080L, showBackground=true, group="Fonts - Normal", fontScale=1.0f) @androidx.compose.ui.tooling.preview.Preview(device=androidx.compose.ui.tooling.preview.Devices.WEAR_OS_SMALL_ROUND, showSystemUi=true, backgroundColor=4278190080L, showBackground=true, group="Fonts - Medium", fontScale=1.06f) @androidx.compose.ui.tooling.preview.Preview(device=androidx.compose.ui.tooling.preview.Devices.WEAR_OS_SMALL_ROUND, showSystemUi=true, backgroundColor=4278190080L, showBackground=true, group="Fonts - Large", fontScale=1.12f) @androidx.compose.ui.tooling.preview.Preview(device=androidx.compose.ui.tooling.preview.Devices.WEAR_OS_SMALL_ROUND, showSystemUi=true, backgroundColor=4278190080L, showBackground=true, group="Fonts - Larger", fontScale=1.18f) @androidx.compose.ui.tooling.preview.Preview(device=androidx.compose.ui.tooling.preview.Devices.WEAR_OS_SMALL_ROUND, showSystemUi=true, backgroundColor=4278190080L, showBackground=true, group="Fonts - Largest", fontScale=1.24f) public @interface WearPreviewFontScales {
+  }
+
+  @androidx.compose.ui.tooling.preview.Preview(device=androidx.compose.ui.tooling.preview.Devices.WEAR_OS_LARGE_ROUND, backgroundColor=4278190080L, showBackground=true, group="Devices - Large Round", showSystemUi=true) public @interface WearPreviewLargeRound {
+  }
+
+  @androidx.compose.ui.tooling.preview.Preview(device=androidx.compose.ui.tooling.preview.Devices.WEAR_OS_SMALL_ROUND, backgroundColor=4278190080L, showBackground=true, group="Devices - Small Round", showSystemUi=true) public @interface WearPreviewSmallRound {
+  }
+
+  @androidx.compose.ui.tooling.preview.Preview(device=androidx.compose.ui.tooling.preview.Devices.WEAR_OS_SQUARE, backgroundColor=4278190080L, showBackground=true, group="Devices - Small Square", showSystemUi=true) public @interface WearPreviewSquare {
+  }
+
+}
+
diff --git a/wear/compose/compose-ui-tooling/build.gradle b/wear/compose/compose-ui-tooling/build.gradle
new file mode 100644
index 0000000..5c5e81d
--- /dev/null
+++ b/wear/compose/compose-ui-tooling/build.gradle
@@ -0,0 +1,47 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import androidx.build.AndroidXComposePlugin
+import androidx.build.LibraryType
+
+plugins {
+    id("AndroidXPlugin")
+    id("AndroidXComposePlugin")
+    id("com.android.library")
+}
+
+// Disable multi-platform.
+AndroidXComposePlugin.applyAndConfigureKotlinPlugin(project, /* isMultiplatformEnabled= */ false)
+
+dependencies {
+    api("androidx.annotation:annotation:1.5.0")
+
+    implementation(libs.kotlinStdlibCommon)
+    implementation(project(":compose:ui:ui-tooling-preview"))
+
+    samples(project(":wear:compose:compose-material-samples"))
+}
+
+android {
+    namespace "androidx.wear.compose.ui.tooling"
+}
+
+androidx {
+    name = "Wear Compose Tools"
+    type = LibraryType.PUBLISHED_LIBRARY
+    inceptionYear = "2023"
+    description = "Tools for Wear Composable"
+}
diff --git a/wear/compose/compose-ui-tooling/src/commonMain/kotlin/androidx/wear/compose/ui/tooling/preview/WearPreviewDevices.kt b/wear/compose/compose-ui-tooling/src/commonMain/kotlin/androidx/wear/compose/ui/tooling/preview/WearPreviewDevices.kt
new file mode 100644
index 0000000..5ff9952
--- /dev/null
+++ b/wear/compose/compose-ui-tooling/src/commonMain/kotlin/androidx/wear/compose/ui/tooling/preview/WearPreviewDevices.kt
@@ -0,0 +1,53 @@
+/*
+ * 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.ui.tooling.preview
+
+import androidx.compose.ui.tooling.preview.Devices
+import androidx.compose.ui.tooling.preview.Preview
+
+/**
+ * [WearPreviewDevices] is a multi-preview annotation for composables with different Wear device
+ * shapes and sizes. It supports [Devices.WEAR_OS_SMALL_ROUND], [Devices.WEAR_OS_LARGE_ROUND] and
+ * [Devices.WEAR_OS_SQUARE].
+ *
+ * @sample androidx.wear.compose.material.samples.ToggleButtonWithIconPreview
+ * @see Devices.WEAR_OS_SMALL_ROUND
+ * @see Devices.WEAR_OS_LARGE_ROUND
+ * @see Devices.WEAR_OS_SQUARE
+ */
+@Preview(
+    device = Devices.WEAR_OS_SQUARE,
+    backgroundColor = 0xff000000,
+    showBackground = true,
+    group = "Devices - Small Square",
+    showSystemUi = true
+)
+@Preview(
+    device = Devices.WEAR_OS_LARGE_ROUND,
+    backgroundColor = 0xff000000,
+    showBackground = true,
+    group = "Devices - Large Round",
+    showSystemUi = true
+)
+@Preview(
+    device = Devices.WEAR_OS_SMALL_ROUND,
+    backgroundColor = 0xff000000,
+    showBackground = true,
+    group = "Devices - Small Round",
+    showSystemUi = true
+)
+public annotation class WearPreviewDevices
diff --git a/wear/compose/compose-ui-tooling/src/commonMain/kotlin/androidx/wear/compose/ui/tooling/preview/WearPreviewFontScales.kt b/wear/compose/compose-ui-tooling/src/commonMain/kotlin/androidx/wear/compose/ui/tooling/preview/WearPreviewFontScales.kt
new file mode 100644
index 0000000..4df6b45
--- /dev/null
+++ b/wear/compose/compose-ui-tooling/src/commonMain/kotlin/androidx/wear/compose/ui/tooling/preview/WearPreviewFontScales.kt
@@ -0,0 +1,87 @@
+/*
+ * 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.ui.tooling.preview
+
+import androidx.compose.ui.tooling.preview.Devices
+import androidx.compose.ui.tooling.preview.Preview
+
+/**
+ * [WearPreviewFontScales] is a multi-preview annotation for the Wear devices of following font
+ * scales
+ * <ul>
+ *     <li> Fonts - Small: 0.94f </li>
+ *     <li> Fonts - Normal: 1f </li>
+ *     <li> Fonts - Medium: 1.06f </li>
+ *     <li> Fonts - Large: 1.12f </li>
+ *     <li> Fonts - Larger: 1.18f </li>
+ *     <li> Fonts - Largest: 1.24f </li>
+ * </ul>
+ * Font scales represent the scaling factor for fonts, relative to the base density scaling. Please
+ * note, the above list is not exhaustive. It previews the composables on a small round Wear device.
+ *
+ * @sample androidx.wear.compose.material.samples.TitleCardWithImagePreview
+ * @see [Preview.fontScale]
+ */
+@Preview(
+    device = Devices.WEAR_OS_SMALL_ROUND,
+    showSystemUi = true,
+    backgroundColor = 0xff000000,
+    showBackground = true,
+    group = "Fonts - Small",
+    fontScale = 0.94f
+)
+@Preview(
+    device = Devices.WEAR_OS_SMALL_ROUND,
+    showSystemUi = true,
+    backgroundColor = 0xff000000,
+    showBackground = true,
+    group = "Fonts - Normal",
+    fontScale = 1f
+)
+@Preview(
+    device = Devices.WEAR_OS_SMALL_ROUND,
+    showSystemUi = true,
+    backgroundColor = 0xff000000,
+    showBackground = true,
+    group = "Fonts - Medium",
+    fontScale = 1.06f
+)
+@Preview(
+    device = Devices.WEAR_OS_SMALL_ROUND,
+    showSystemUi = true,
+    backgroundColor = 0xff000000,
+    showBackground = true,
+    group = "Fonts - Large",
+    fontScale = 1.12f
+)
+@Preview(
+    device = Devices.WEAR_OS_SMALL_ROUND,
+    showSystemUi = true,
+    backgroundColor = 0xff000000,
+    showBackground = true,
+    group = "Fonts - Larger",
+    fontScale = 1.18f
+)
+@Preview(
+    device = Devices.WEAR_OS_SMALL_ROUND,
+    showSystemUi = true,
+    backgroundColor = 0xff000000,
+    showBackground = true,
+    group = "Fonts - Largest",
+    fontScale = 1.24f
+)
+public annotation class WearPreviewFontScales
diff --git a/wear/compose/compose-ui-tooling/src/commonMain/kotlin/androidx/wear/compose/ui/tooling/preview/WearPreviewLargeRound.kt b/wear/compose/compose-ui-tooling/src/commonMain/kotlin/androidx/wear/compose/ui/tooling/preview/WearPreviewLargeRound.kt
new file mode 100644
index 0000000..b2d8367
--- /dev/null
+++ b/wear/compose/compose-ui-tooling/src/commonMain/kotlin/androidx/wear/compose/ui/tooling/preview/WearPreviewLargeRound.kt
@@ -0,0 +1,36 @@
+/*
+ * 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.ui.tooling.preview
+
+import androidx.compose.ui.tooling.preview.Devices
+import androidx.compose.ui.tooling.preview.Preview
+
+/**
+ * [WearPreviewLargeRound] is a custom preview annotation for displaying Wear composables on large
+ * round Wear device ([Devices.WEAR_OS_LARGE_ROUND]).
+ *
+ * @sample androidx.wear.compose.material.samples.ButtonWithIconPreview
+ * @see [Devices.WEAR_OS_LARGE_ROUND]
+ */
+@Preview(
+    device = Devices.WEAR_OS_LARGE_ROUND,
+    backgroundColor = 0xff000000,
+    showBackground = true,
+    group = "Devices - Large Round",
+    showSystemUi = true
+)
+public annotation class WearPreviewLargeRound()
diff --git a/wear/compose/compose-ui-tooling/src/commonMain/kotlin/androidx/wear/compose/ui/tooling/preview/WearPreviewSmallRound.kt b/wear/compose/compose-ui-tooling/src/commonMain/kotlin/androidx/wear/compose/ui/tooling/preview/WearPreviewSmallRound.kt
new file mode 100644
index 0000000..70dcc79
--- /dev/null
+++ b/wear/compose/compose-ui-tooling/src/commonMain/kotlin/androidx/wear/compose/ui/tooling/preview/WearPreviewSmallRound.kt
@@ -0,0 +1,36 @@
+/*
+ * 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.ui.tooling.preview
+
+import androidx.compose.ui.tooling.preview.Devices
+import androidx.compose.ui.tooling.preview.Preview
+
+/**
+ * [WearPreviewSmallRound] is a custom preview annotation for displaying Wear composables on small
+ * round Wear device ([Devices.WEAR_OS_SMALL_ROUND]).
+ *
+ * @sample androidx.wear.compose.material.samples.ButtonWithIconPreview
+ * @see [Devices.WEAR_OS_SMALL_ROUND]
+ */
+@Preview(
+    device = Devices.WEAR_OS_SMALL_ROUND,
+    backgroundColor = 0xff000000,
+    showBackground = true,
+    group = "Devices - Small Round",
+    showSystemUi = true
+)
+public annotation class WearPreviewSmallRound
diff --git a/wear/compose/compose-ui-tooling/src/commonMain/kotlin/androidx/wear/compose/ui/tooling/preview/WearPreviewSquare.kt b/wear/compose/compose-ui-tooling/src/commonMain/kotlin/androidx/wear/compose/ui/tooling/preview/WearPreviewSquare.kt
new file mode 100644
index 0000000..4c41bd1
--- /dev/null
+++ b/wear/compose/compose-ui-tooling/src/commonMain/kotlin/androidx/wear/compose/ui/tooling/preview/WearPreviewSquare.kt
@@ -0,0 +1,36 @@
+/*
+ * 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.ui.tooling.preview
+
+import androidx.compose.ui.tooling.preview.Devices
+import androidx.compose.ui.tooling.preview.Preview
+
+/**
+ * [WearPreviewSquare] is a custom preview annotation for displaying Wear composables on a square
+ * Wear screen ([Devices.WEAR_OS_SQUARE]).
+ *
+ * @sample androidx.wear.compose.material.samples.ButtonWithIconPreview
+ * @see [Devices.WEAR_OS_SQUARE]
+ */
+@Preview(
+    device = Devices.WEAR_OS_SQUARE,
+    backgroundColor = 0xff000000,
+    showBackground = true,
+    group = "Devices - Small Square",
+    showSystemUi = true
+)
+public annotation class WearPreviewSquare()
diff --git a/wear/compose/integration-tests/demos/src/main/java/androidx/wear/compose/integration/demos/MaterialDemos.kt b/wear/compose/integration-tests/demos/src/main/java/androidx/wear/compose/integration/demos/MaterialDemos.kt
index 2c06dbd..45ecbf2 100644
--- a/wear/compose/integration-tests/demos/src/main/java/androidx/wear/compose/integration/demos/MaterialDemos.kt
+++ b/wear/compose/integration-tests/demos/src/main/java/androidx/wear/compose/integration/demos/MaterialDemos.kt
@@ -32,6 +32,7 @@
 import androidx.wear.compose.material.samples.AlertWithChips
 import androidx.wear.compose.material.samples.AnimateOptionChangePicker
 import androidx.wear.compose.material.samples.AppCardWithIcon
+import androidx.wear.compose.material.samples.AutoCenteringPickerGroup
 import androidx.wear.compose.material.samples.ButtonWithIcon
 import androidx.wear.compose.material.samples.ButtonWithText
 import androidx.wear.compose.material.samples.ChipWithIconAndLabel
@@ -60,6 +61,7 @@
 import androidx.wear.compose.material.samples.OutlinedChipWithIconAndLabel
 import androidx.wear.compose.material.samples.OutlinedCompactButtonWithIcon
 import androidx.wear.compose.material.samples.OutlinedCompactChipWithIconAndLabel
+import androidx.wear.compose.material.samples.PickerGroup24Hours
 import androidx.wear.compose.material.samples.ScalingLazyColumnEdgeAnchoredAndAnimatedScrollTo
 import androidx.wear.compose.material.samples.SimplePicker
 import androidx.wear.compose.material.samples.SimpleScaffoldWithScrollIndicator
@@ -212,6 +214,8 @@
                     ComposableDemo("Simple Picker") { SimplePicker() },
                     ComposableDemo("No gradient") { PickerWithoutGradient() },
                     ComposableDemo("Animate picker change") { AnimateOptionChangePicker() },
+                    ComposableDemo("Sample Picker Group") { PickerGroup24Hours() },
+                    ComposableDemo("Autocentering Picker Group") { AutoCenteringPickerGroup() }
                 )
             } else {
                 listOf(
diff --git a/wear/protolayout/protolayout-expression-pipeline/api/current.txt b/wear/protolayout/protolayout-expression-pipeline/api/current.txt
index 5cd07fa..e36b8b2 100644
--- a/wear/protolayout/protolayout-expression-pipeline/api/current.txt
+++ b/wear/protolayout/protolayout-expression-pipeline/api/current.txt
@@ -3,26 +3,27 @@
 
   public interface BoundDynamicType extends java.lang.AutoCloseable {
     method @UiThread public void close();
+    method @UiThread public void startEvaluation();
   }
 
   public class DynamicTypeEvaluator implements java.lang.AutoCloseable {
     ctor public DynamicTypeEvaluator(boolean, androidx.wear.protolayout.expression.pipeline.sensor.SensorGateway?, androidx.wear.protolayout.expression.pipeline.ObservableStateStore);
     ctor public DynamicTypeEvaluator(boolean, androidx.wear.protolayout.expression.pipeline.sensor.SensorGateway?, androidx.wear.protolayout.expression.pipeline.ObservableStateStore, androidx.wear.protolayout.expression.pipeline.QuotaManager);
-    method public androidx.wear.protolayout.expression.pipeline.BoundDynamicType bind(androidx.wear.protolayout.expression.DynamicBuilders.DynamicString, android.icu.util.ULocale, androidx.wear.protolayout.expression.pipeline.DynamicTypeValueReceiver<java.lang.String!>);
-    method public androidx.wear.protolayout.expression.pipeline.BoundDynamicType bind(androidx.wear.protolayout.expression.DynamicBuilders.DynamicInt32, androidx.wear.protolayout.expression.pipeline.DynamicTypeValueReceiver<java.lang.Integer!>);
-    method public androidx.wear.protolayout.expression.pipeline.BoundDynamicType bind(androidx.wear.protolayout.expression.DynamicBuilders.DynamicFloat, androidx.wear.protolayout.expression.pipeline.DynamicTypeValueReceiver<java.lang.Float!>);
-    method public androidx.wear.protolayout.expression.pipeline.BoundDynamicType bind(androidx.wear.protolayout.expression.DynamicBuilders.DynamicColor, androidx.wear.protolayout.expression.pipeline.DynamicTypeValueReceiver<java.lang.Integer!>);
-    method public androidx.wear.protolayout.expression.pipeline.BoundDynamicType bind(androidx.wear.protolayout.expression.DynamicBuilders.DynamicDuration, androidx.wear.protolayout.expression.pipeline.DynamicTypeValueReceiver<java.time.Duration!>);
-    method public androidx.wear.protolayout.expression.pipeline.BoundDynamicType bind(androidx.wear.protolayout.expression.DynamicBuilders.DynamicInstant, androidx.wear.protolayout.expression.pipeline.DynamicTypeValueReceiver<java.time.Instant!>);
-    method public androidx.wear.protolayout.expression.pipeline.BoundDynamicType bind(androidx.wear.protolayout.expression.DynamicBuilders.DynamicBool, androidx.wear.protolayout.expression.pipeline.DynamicTypeValueReceiver<java.lang.Boolean!>);
+    method public androidx.wear.protolayout.expression.pipeline.BoundDynamicType bind(androidx.wear.protolayout.expression.DynamicBuilders.DynamicString, android.icu.util.ULocale, java.util.concurrent.Executor, androidx.wear.protolayout.expression.pipeline.DynamicTypeValueReceiver<java.lang.String!>);
+    method public androidx.wear.protolayout.expression.pipeline.BoundDynamicType bind(androidx.wear.protolayout.expression.DynamicBuilders.DynamicInt32, java.util.concurrent.Executor, androidx.wear.protolayout.expression.pipeline.DynamicTypeValueReceiver<java.lang.Integer!>);
+    method public androidx.wear.protolayout.expression.pipeline.BoundDynamicType bind(androidx.wear.protolayout.expression.DynamicBuilders.DynamicFloat, java.util.concurrent.Executor, androidx.wear.protolayout.expression.pipeline.DynamicTypeValueReceiver<java.lang.Float!>);
+    method public androidx.wear.protolayout.expression.pipeline.BoundDynamicType bind(androidx.wear.protolayout.expression.DynamicBuilders.DynamicColor, java.util.concurrent.Executor, androidx.wear.protolayout.expression.pipeline.DynamicTypeValueReceiver<java.lang.Integer!>);
+    method public androidx.wear.protolayout.expression.pipeline.BoundDynamicType bind(androidx.wear.protolayout.expression.DynamicBuilders.DynamicDuration, java.util.concurrent.Executor, androidx.wear.protolayout.expression.pipeline.DynamicTypeValueReceiver<java.time.Duration!>);
+    method public androidx.wear.protolayout.expression.pipeline.BoundDynamicType bind(androidx.wear.protolayout.expression.DynamicBuilders.DynamicInstant, java.util.concurrent.Executor, androidx.wear.protolayout.expression.pipeline.DynamicTypeValueReceiver<java.time.Instant!>);
+    method public androidx.wear.protolayout.expression.pipeline.BoundDynamicType bind(androidx.wear.protolayout.expression.DynamicBuilders.DynamicBool, java.util.concurrent.Executor, androidx.wear.protolayout.expression.pipeline.DynamicTypeValueReceiver<java.lang.Boolean!>);
     method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP) public void close();
     method @UiThread public void disablePlatformDataSources();
     method @UiThread public void enablePlatformDataSources();
   }
 
   public interface DynamicTypeValueReceiver<T> {
-    method @UiThread public void onData(T);
-    method @UiThread public void onInvalidated();
+    method public void onData(T);
+    method public void onInvalidated();
   }
 
   public class ObservableStateStore {
diff --git a/wear/protolayout/protolayout-expression-pipeline/api/public_plus_experimental_current.txt b/wear/protolayout/protolayout-expression-pipeline/api/public_plus_experimental_current.txt
index 5cd07fa..e36b8b2 100644
--- a/wear/protolayout/protolayout-expression-pipeline/api/public_plus_experimental_current.txt
+++ b/wear/protolayout/protolayout-expression-pipeline/api/public_plus_experimental_current.txt
@@ -3,26 +3,27 @@
 
   public interface BoundDynamicType extends java.lang.AutoCloseable {
     method @UiThread public void close();
+    method @UiThread public void startEvaluation();
   }
 
   public class DynamicTypeEvaluator implements java.lang.AutoCloseable {
     ctor public DynamicTypeEvaluator(boolean, androidx.wear.protolayout.expression.pipeline.sensor.SensorGateway?, androidx.wear.protolayout.expression.pipeline.ObservableStateStore);
     ctor public DynamicTypeEvaluator(boolean, androidx.wear.protolayout.expression.pipeline.sensor.SensorGateway?, androidx.wear.protolayout.expression.pipeline.ObservableStateStore, androidx.wear.protolayout.expression.pipeline.QuotaManager);
-    method public androidx.wear.protolayout.expression.pipeline.BoundDynamicType bind(androidx.wear.protolayout.expression.DynamicBuilders.DynamicString, android.icu.util.ULocale, androidx.wear.protolayout.expression.pipeline.DynamicTypeValueReceiver<java.lang.String!>);
-    method public androidx.wear.protolayout.expression.pipeline.BoundDynamicType bind(androidx.wear.protolayout.expression.DynamicBuilders.DynamicInt32, androidx.wear.protolayout.expression.pipeline.DynamicTypeValueReceiver<java.lang.Integer!>);
-    method public androidx.wear.protolayout.expression.pipeline.BoundDynamicType bind(androidx.wear.protolayout.expression.DynamicBuilders.DynamicFloat, androidx.wear.protolayout.expression.pipeline.DynamicTypeValueReceiver<java.lang.Float!>);
-    method public androidx.wear.protolayout.expression.pipeline.BoundDynamicType bind(androidx.wear.protolayout.expression.DynamicBuilders.DynamicColor, androidx.wear.protolayout.expression.pipeline.DynamicTypeValueReceiver<java.lang.Integer!>);
-    method public androidx.wear.protolayout.expression.pipeline.BoundDynamicType bind(androidx.wear.protolayout.expression.DynamicBuilders.DynamicDuration, androidx.wear.protolayout.expression.pipeline.DynamicTypeValueReceiver<java.time.Duration!>);
-    method public androidx.wear.protolayout.expression.pipeline.BoundDynamicType bind(androidx.wear.protolayout.expression.DynamicBuilders.DynamicInstant, androidx.wear.protolayout.expression.pipeline.DynamicTypeValueReceiver<java.time.Instant!>);
-    method public androidx.wear.protolayout.expression.pipeline.BoundDynamicType bind(androidx.wear.protolayout.expression.DynamicBuilders.DynamicBool, androidx.wear.protolayout.expression.pipeline.DynamicTypeValueReceiver<java.lang.Boolean!>);
+    method public androidx.wear.protolayout.expression.pipeline.BoundDynamicType bind(androidx.wear.protolayout.expression.DynamicBuilders.DynamicString, android.icu.util.ULocale, java.util.concurrent.Executor, androidx.wear.protolayout.expression.pipeline.DynamicTypeValueReceiver<java.lang.String!>);
+    method public androidx.wear.protolayout.expression.pipeline.BoundDynamicType bind(androidx.wear.protolayout.expression.DynamicBuilders.DynamicInt32, java.util.concurrent.Executor, androidx.wear.protolayout.expression.pipeline.DynamicTypeValueReceiver<java.lang.Integer!>);
+    method public androidx.wear.protolayout.expression.pipeline.BoundDynamicType bind(androidx.wear.protolayout.expression.DynamicBuilders.DynamicFloat, java.util.concurrent.Executor, androidx.wear.protolayout.expression.pipeline.DynamicTypeValueReceiver<java.lang.Float!>);
+    method public androidx.wear.protolayout.expression.pipeline.BoundDynamicType bind(androidx.wear.protolayout.expression.DynamicBuilders.DynamicColor, java.util.concurrent.Executor, androidx.wear.protolayout.expression.pipeline.DynamicTypeValueReceiver<java.lang.Integer!>);
+    method public androidx.wear.protolayout.expression.pipeline.BoundDynamicType bind(androidx.wear.protolayout.expression.DynamicBuilders.DynamicDuration, java.util.concurrent.Executor, androidx.wear.protolayout.expression.pipeline.DynamicTypeValueReceiver<java.time.Duration!>);
+    method public androidx.wear.protolayout.expression.pipeline.BoundDynamicType bind(androidx.wear.protolayout.expression.DynamicBuilders.DynamicInstant, java.util.concurrent.Executor, androidx.wear.protolayout.expression.pipeline.DynamicTypeValueReceiver<java.time.Instant!>);
+    method public androidx.wear.protolayout.expression.pipeline.BoundDynamicType bind(androidx.wear.protolayout.expression.DynamicBuilders.DynamicBool, java.util.concurrent.Executor, androidx.wear.protolayout.expression.pipeline.DynamicTypeValueReceiver<java.lang.Boolean!>);
     method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP) public void close();
     method @UiThread public void disablePlatformDataSources();
     method @UiThread public void enablePlatformDataSources();
   }
 
   public interface DynamicTypeValueReceiver<T> {
-    method @UiThread public void onData(T);
-    method @UiThread public void onInvalidated();
+    method public void onData(T);
+    method public void onInvalidated();
   }
 
   public class ObservableStateStore {
diff --git a/wear/protolayout/protolayout-expression-pipeline/api/restricted_current.txt b/wear/protolayout/protolayout-expression-pipeline/api/restricted_current.txt
index 3a48704..e32ed48 100644
--- a/wear/protolayout/protolayout-expression-pipeline/api/restricted_current.txt
+++ b/wear/protolayout/protolayout-expression-pipeline/api/restricted_current.txt
@@ -3,26 +3,27 @@
 
   public interface BoundDynamicType extends java.lang.AutoCloseable {
     method @UiThread public void close();
+    method @UiThread public void startEvaluation();
   }
 
   public class DynamicTypeEvaluator implements java.lang.AutoCloseable {
     ctor public DynamicTypeEvaluator(boolean, androidx.wear.protolayout.expression.pipeline.sensor.SensorGateway?, androidx.wear.protolayout.expression.pipeline.ObservableStateStore);
     ctor public DynamicTypeEvaluator(boolean, androidx.wear.protolayout.expression.pipeline.sensor.SensorGateway?, androidx.wear.protolayout.expression.pipeline.ObservableStateStore, androidx.wear.protolayout.expression.pipeline.QuotaManager);
-    method public androidx.wear.protolayout.expression.pipeline.BoundDynamicType bind(androidx.wear.protolayout.expression.DynamicBuilders.DynamicString, android.icu.util.ULocale, androidx.wear.protolayout.expression.pipeline.DynamicTypeValueReceiver<java.lang.String!>);
-    method public androidx.wear.protolayout.expression.pipeline.BoundDynamicType bind(androidx.wear.protolayout.expression.DynamicBuilders.DynamicInt32, androidx.wear.protolayout.expression.pipeline.DynamicTypeValueReceiver<java.lang.Integer!>);
-    method public androidx.wear.protolayout.expression.pipeline.BoundDynamicType bind(androidx.wear.protolayout.expression.DynamicBuilders.DynamicFloat, androidx.wear.protolayout.expression.pipeline.DynamicTypeValueReceiver<java.lang.Float!>);
-    method public androidx.wear.protolayout.expression.pipeline.BoundDynamicType bind(androidx.wear.protolayout.expression.DynamicBuilders.DynamicColor, androidx.wear.protolayout.expression.pipeline.DynamicTypeValueReceiver<java.lang.Integer!>);
-    method public androidx.wear.protolayout.expression.pipeline.BoundDynamicType bind(androidx.wear.protolayout.expression.DynamicBuilders.DynamicDuration, androidx.wear.protolayout.expression.pipeline.DynamicTypeValueReceiver<java.time.Duration!>);
-    method public androidx.wear.protolayout.expression.pipeline.BoundDynamicType bind(androidx.wear.protolayout.expression.DynamicBuilders.DynamicInstant, androidx.wear.protolayout.expression.pipeline.DynamicTypeValueReceiver<java.time.Instant!>);
-    method public androidx.wear.protolayout.expression.pipeline.BoundDynamicType bind(androidx.wear.protolayout.expression.DynamicBuilders.DynamicBool, androidx.wear.protolayout.expression.pipeline.DynamicTypeValueReceiver<java.lang.Boolean!>);
+    method public androidx.wear.protolayout.expression.pipeline.BoundDynamicType bind(androidx.wear.protolayout.expression.DynamicBuilders.DynamicString, android.icu.util.ULocale, java.util.concurrent.Executor, androidx.wear.protolayout.expression.pipeline.DynamicTypeValueReceiver<java.lang.String!>);
+    method public androidx.wear.protolayout.expression.pipeline.BoundDynamicType bind(androidx.wear.protolayout.expression.DynamicBuilders.DynamicInt32, java.util.concurrent.Executor, androidx.wear.protolayout.expression.pipeline.DynamicTypeValueReceiver<java.lang.Integer!>);
+    method public androidx.wear.protolayout.expression.pipeline.BoundDynamicType bind(androidx.wear.protolayout.expression.DynamicBuilders.DynamicFloat, java.util.concurrent.Executor, androidx.wear.protolayout.expression.pipeline.DynamicTypeValueReceiver<java.lang.Float!>);
+    method public androidx.wear.protolayout.expression.pipeline.BoundDynamicType bind(androidx.wear.protolayout.expression.DynamicBuilders.DynamicColor, java.util.concurrent.Executor, androidx.wear.protolayout.expression.pipeline.DynamicTypeValueReceiver<java.lang.Integer!>);
+    method public androidx.wear.protolayout.expression.pipeline.BoundDynamicType bind(androidx.wear.protolayout.expression.DynamicBuilders.DynamicDuration, java.util.concurrent.Executor, androidx.wear.protolayout.expression.pipeline.DynamicTypeValueReceiver<java.time.Duration!>);
+    method public androidx.wear.protolayout.expression.pipeline.BoundDynamicType bind(androidx.wear.protolayout.expression.DynamicBuilders.DynamicInstant, java.util.concurrent.Executor, androidx.wear.protolayout.expression.pipeline.DynamicTypeValueReceiver<java.time.Instant!>);
+    method public androidx.wear.protolayout.expression.pipeline.BoundDynamicType bind(androidx.wear.protolayout.expression.DynamicBuilders.DynamicBool, java.util.concurrent.Executor, androidx.wear.protolayout.expression.pipeline.DynamicTypeValueReceiver<java.lang.Boolean!>);
     method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP) public void close();
     method @UiThread public void disablePlatformDataSources();
     method @UiThread public void enablePlatformDataSources();
   }
 
   public interface DynamicTypeValueReceiver<T> {
-    method @UiThread public void onData(T);
-    method @UiThread public void onInvalidated();
+    method public void onData(T);
+    method public void onInvalidated();
   }
 
   public class ObservableStateStore {
diff --git a/wear/protolayout/protolayout-expression-pipeline/build.gradle b/wear/protolayout/protolayout-expression-pipeline/build.gradle
index 8769b62..c07d335 100644
--- a/wear/protolayout/protolayout-expression-pipeline/build.gradle
+++ b/wear/protolayout/protolayout-expression-pipeline/build.gradle
@@ -25,6 +25,7 @@
     annotationProcessor(libs.nullaway)
     api("androidx.annotation:annotation:1.2.0")
     implementation("androidx.collection:collection:1.2.0")
+    implementation("androidx.core:core:1.7.0")
 
     implementation("androidx.annotation:annotation-experimental:1.2.0")
     implementation(project(path: ":wear:protolayout:protolayout-proto", configuration: "shadow"))
diff --git a/wear/protolayout/protolayout-expression-pipeline/src/main/java/androidx/wear/protolayout/expression/pipeline/AnimatableNode.java b/wear/protolayout/protolayout-expression-pipeline/src/main/java/androidx/wear/protolayout/expression/pipeline/AnimatableNode.java
index d4fd9ef..ed908a4 100644
--- a/wear/protolayout/protolayout-expression-pipeline/src/main/java/androidx/wear/protolayout/expression/pipeline/AnimatableNode.java
+++ b/wear/protolayout/protolayout-expression-pipeline/src/main/java/androidx/wear/protolayout/expression/pipeline/AnimatableNode.java
@@ -16,17 +16,30 @@
 
 package androidx.wear.protolayout.expression.pipeline;
 
+import android.animation.ArgbEvaluator;
+import android.animation.TypeEvaluator;
+
 import androidx.annotation.NonNull;
 import androidx.annotation.UiThread;
 import androidx.annotation.VisibleForTesting;
+import androidx.wear.protolayout.expression.proto.AnimationParameterProto.AnimationSpec;
 
 /** Data animatable source node within a dynamic data pipeline. */
 abstract class AnimatableNode {
+    static final ArgbEvaluator ARGB_EVALUATOR = new ArgbEvaluator();
+
     private boolean mIsVisible = false;
     @NonNull final QuotaAwareAnimator mQuotaAwareAnimator;
 
-    protected AnimatableNode(@NonNull QuotaManager quotaManager) {
-        mQuotaAwareAnimator = new QuotaAwareAnimator(null, quotaManager);
+    protected AnimatableNode(@NonNull QuotaManager quotaManager, @NonNull AnimationSpec spec) {
+        mQuotaAwareAnimator = new QuotaAwareAnimator(quotaManager, spec);
+    }
+
+    protected AnimatableNode(
+            @NonNull QuotaManager quotaManager,
+            @NonNull AnimationSpec spec,
+            @NonNull TypeEvaluator<?> evaluator) {
+        mQuotaAwareAnimator = new QuotaAwareAnimator(quotaManager, spec, evaluator);
     }
 
     @VisibleForTesting(otherwise = VisibleForTesting.NONE)
@@ -63,7 +76,7 @@
         mIsVisible = visible;
         if (mIsVisible) {
             startOrResumeAnimator();
-        } else if (mQuotaAwareAnimator.hasRunningOrStartedAnimation()) {
+        } else if (mQuotaAwareAnimator.isRunning()) {
             stopOrPauseAnimator();
         }
     }
@@ -77,8 +90,8 @@
     }
 
     /** Returns whether this node has a running animation. */
-    boolean hasRunningOrStartedAnimation() {
-        return mQuotaAwareAnimator.hasRunningOrStartedAnimation();
+    boolean hasRunningAnimation() {
+        return mQuotaAwareAnimator.isRunning();
     }
 
     /** Returns whether the animator in this node has an infinite duration. */
diff --git a/wear/protolayout/protolayout-expression-pipeline/src/main/java/androidx/wear/protolayout/expression/pipeline/AnimationsHelper.java b/wear/protolayout/protolayout-expression-pipeline/src/main/java/androidx/wear/protolayout/expression/pipeline/AnimationsHelper.java
index 57490a3..a6aa523 100644
--- a/wear/protolayout/protolayout-expression-pipeline/src/main/java/androidx/wear/protolayout/expression/pipeline/AnimationsHelper.java
+++ b/wear/protolayout/protolayout-expression-pipeline/src/main/java/androidx/wear/protolayout/expression/pipeline/AnimationsHelper.java
@@ -69,8 +69,8 @@
     /** Returns the delay from the given {@link AnimationSpec} or default value if not set. */
     @NonNull
     public static Duration getDelayOrDefault(@NonNull AnimationSpec spec) {
-        return spec.getDelayMillis() > 0
-                ? Duration.ofMillis(spec.getDelayMillis())
+        return spec.getStartDelayMillis() > 0
+                ? Duration.ofMillis(spec.getStartDelayMillis())
                 : DEFAULT_ANIM_DELAY;
     }
 
diff --git a/wear/protolayout/protolayout-expression-pipeline/src/main/java/androidx/wear/protolayout/expression/pipeline/BoundDynamicType.java b/wear/protolayout/protolayout-expression-pipeline/src/main/java/androidx/wear/protolayout/expression/pipeline/BoundDynamicType.java
index 8d51f4e..ad05f2a 100644
--- a/wear/protolayout/protolayout-expression-pipeline/src/main/java/androidx/wear/protolayout/expression/pipeline/BoundDynamicType.java
+++ b/wear/protolayout/protolayout-expression-pipeline/src/main/java/androidx/wear/protolayout/expression/pipeline/BoundDynamicType.java
@@ -21,11 +21,26 @@
 import androidx.annotation.VisibleForTesting;
 
 /**
- * An object representing a dynamic type that is being evaluated by {@link
+ * An object representing a dynamic type that is being prepared for evaluation by {@link
  * DynamicTypeEvaluator#bind}.
+ *
+ * <p>In order for evaluation and sending values to start, {@link #startEvaluation()} needs to be
+ * called.
+ *
+ * <p>To stop the evaluation, this object should be closed with {@link #close()}.
  */
 public interface BoundDynamicType extends AutoCloseable {
     /**
+     * Starts evaluating this dynamic type that was previously bound with any of the {@link
+     * DynamicTypeEvaluator#bind} methods.
+     *
+     * <p>It's the callers responsibility to destroy those dynamic types after use, with {@link
+     * BoundDynamicType#close()}.
+     */
+    @UiThread
+    void startEvaluation();
+
+    /**
      * Sets the visibility to all animations in this dynamic type. They can be triggered when
      * visible.
      *
@@ -43,7 +58,7 @@
     @UiThread
     @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
     @VisibleForTesting(otherwise = VisibleForTesting.NONE)
-    int getRunningOrStartedAnimationCount();
+    int getRunningAnimationCount();
 
     /** Destroys this dynamic type and it shouldn't be used after this. */
     @UiThread
diff --git a/wear/protolayout/protolayout-expression-pipeline/src/main/java/androidx/wear/protolayout/expression/pipeline/BoundDynamicTypeImpl.java b/wear/protolayout/protolayout-expression-pipeline/src/main/java/androidx/wear/protolayout/expression/pipeline/BoundDynamicTypeImpl.java
index 295a915..1ca496d 100644
--- a/wear/protolayout/protolayout-expression-pipeline/src/main/java/androidx/wear/protolayout/expression/pipeline/BoundDynamicTypeImpl.java
+++ b/wear/protolayout/protolayout-expression-pipeline/src/main/java/androidx/wear/protolayout/expression/pipeline/BoundDynamicTypeImpl.java
@@ -30,6 +30,22 @@
     }
 
     /**
+     * Initializes evaluation.
+     *
+     * <p>See {@link BoundDynamicType#startEvaluation()}.
+     */
+    @Override
+    public void startEvaluation() {
+        mNodes.stream()
+                .filter(n -> n instanceof DynamicDataSourceNode)
+                .forEach(n -> ((DynamicDataSourceNode<?>) n).preInit());
+
+        mNodes.stream()
+                .filter(n -> n instanceof DynamicDataSourceNode)
+                .forEach(n -> ((DynamicDataSourceNode<?>) n).init());
+    }
+
+    /**
      * Sets visibility for all {@link AnimatableNode} in this dynamic type. For others, this is
      * no-op.
      */
@@ -42,11 +58,11 @@
 
     /** Returns how many of {@link AnimatableNode} are running. */
     @Override
-    public int getRunningOrStartedAnimationCount() {
+    public int getRunningAnimationCount() {
         return (int)
                 mNodes.stream()
                         .filter(n -> n instanceof AnimatableNode)
-                        .filter(n -> ((AnimatableNode) n).hasRunningOrStartedAnimation())
+                        .filter(n -> ((AnimatableNode) n).hasRunningAnimation())
                         .count();
     }
 
diff --git a/wear/protolayout/protolayout-expression-pipeline/src/main/java/androidx/wear/protolayout/expression/pipeline/ColorNodes.java b/wear/protolayout/protolayout-expression-pipeline/src/main/java/androidx/wear/protolayout/expression/pipeline/ColorNodes.java
index 1ef366a..6e34b8c 100644
--- a/wear/protolayout/protolayout-expression-pipeline/src/main/java/androidx/wear/protolayout/expression/pipeline/ColorNodes.java
+++ b/wear/protolayout/protolayout-expression-pipeline/src/main/java/androidx/wear/protolayout/expression/pipeline/ColorNodes.java
@@ -16,10 +16,6 @@
 
 package androidx.wear.protolayout.expression.pipeline;
 
-import static androidx.wear.protolayout.expression.pipeline.AnimationsHelper.applyAnimationSpecToAnimator;
-
-import android.animation.ValueAnimator;
-
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import androidx.annotation.UiThread;
@@ -81,11 +77,14 @@
 
         AnimatableFixedColorNode(
                 AnimatableFixedColor protoNode,
-                DynamicTypeValueReceiver<Integer> mDownstream,
+                DynamicTypeValueReceiver<Integer> downstream,
                 QuotaManager quotaManager) {
-            super(quotaManager);
+
+            super(quotaManager, protoNode.getAnimationSpec(), ARGB_EVALUATOR);
             this.mProtoNode = protoNode;
-            this.mDownstream = mDownstream;
+            this.mDownstream = downstream;
+            mQuotaAwareAnimator.addUpdateCallback(
+                    animatedValue -> mDownstream.onData((Integer) animatedValue));
         }
 
         @Override
@@ -97,13 +96,7 @@
         @Override
         @UiThread
         public void init() {
-            ValueAnimator animator =
-                    ValueAnimator.ofArgb(mProtoNode.getFromArgb(), mProtoNode.getToArgb());
-            animator.addUpdateListener(a -> mDownstream.onData((Integer) a.getAnimatedValue()));
-
-            applyAnimationSpecToAnimator(animator, mProtoNode.getAnimationSpec());
-
-            mQuotaAwareAnimator.updateAnimator(animator);
+            mQuotaAwareAnimator.setIntValues(mProtoNode.getFromArgb(), mProtoNode.getToArgb());
             startOrSkipAnimator();
         }
 
@@ -131,8 +124,16 @@
                 DynamicTypeValueReceiver<Integer> downstream,
                 @NonNull AnimationSpec spec,
                 QuotaManager quotaManager) {
-            super(quotaManager);
+
+            super(quotaManager, spec, ARGB_EVALUATOR);
             this.mDownstream = downstream;
+            mQuotaAwareAnimator.addUpdateCallback(
+                    animatedValue -> {
+                        if (mPendingCalls == 0) {
+                            mCurrentValue = (Integer) animatedValue;
+                            mDownstream.onData(mCurrentValue);
+                        }
+                    });
             this.mInputCallback =
                     new DynamicTypeValueReceiver<Integer>() {
                         @Override
@@ -141,8 +142,6 @@
 
                             if (mPendingCalls == 1) {
                                 mDownstream.onPreUpdate();
-
-                                mQuotaAwareAnimator.resetAnimator();
                             }
                         }
 
@@ -157,19 +156,7 @@
                                     mCurrentValue = newData;
                                     mDownstream.onData(mCurrentValue);
                                 } else {
-                                    ValueAnimator animator =
-                                            ValueAnimator.ofArgb(mCurrentValue, newData);
-
-                                    applyAnimationSpecToAnimator(animator, spec);
-                                    animator.addUpdateListener(
-                                            a -> {
-                                                if (mPendingCalls == 0) {
-                                                    mCurrentValue = (Integer) a.getAnimatedValue();
-                                                    mDownstream.onData(mCurrentValue);
-                                                }
-                                            });
-
-                                    mQuotaAwareAnimator.updateAnimator(animator);
+                                    mQuotaAwareAnimator.setIntValues(mCurrentValue, newData);
                                     startOrSkipAnimator();
                                 }
                             }
diff --git a/wear/protolayout/protolayout-expression-pipeline/src/main/java/androidx/wear/protolayout/expression/pipeline/DurationNodes.java b/wear/protolayout/protolayout-expression-pipeline/src/main/java/androidx/wear/protolayout/expression/pipeline/DurationNodes.java
index 3813bd8..f8ebb47 100644
--- a/wear/protolayout/protolayout-expression-pipeline/src/main/java/androidx/wear/protolayout/expression/pipeline/DurationNodes.java
+++ b/wear/protolayout/protolayout-expression-pipeline/src/main/java/androidx/wear/protolayout/expression/pipeline/DurationNodes.java
@@ -13,6 +13,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+
 package androidx.wear.protolayout.expression.pipeline;
 
 import java.time.Duration;
diff --git a/wear/protolayout/protolayout-expression-pipeline/src/main/java/androidx/wear/protolayout/expression/pipeline/DynamicDataNode.java b/wear/protolayout/protolayout-expression-pipeline/src/main/java/androidx/wear/protolayout/expression/pipeline/DynamicDataNode.java
index a454577..d845acb 100644
--- a/wear/protolayout/protolayout-expression-pipeline/src/main/java/androidx/wear/protolayout/expression/pipeline/DynamicDataNode.java
+++ b/wear/protolayout/protolayout-expression-pipeline/src/main/java/androidx/wear/protolayout/expression/pipeline/DynamicDataNode.java
@@ -32,14 +32,14 @@
  *   private final DynamicTypeValueReceiver<Integer> myNode =
  *     new DynamicTypeValueReceiver<Integer>() {
  *       @Override
- *       public void onPreStateUpdate() {
+ *       public void onPreUpdate() {
  *         // Don't need to do anything here; just relay.
- *         downstreamNode.onPreStateUpdate();
+ *         downstreamNode.onPreUpdate();
  *       }
  *
  *       @Override
- *       public void onStateUpdate(Integer newData) {
- *         downstreamNode.onStateUpdate(newData.toString());
+ *       public void onData(Integer newData) {
+ *         downstreamNode.onData(newData.toString());
  *       }
  *     };
  *
diff --git a/wear/protolayout/protolayout-expression-pipeline/src/main/java/androidx/wear/protolayout/expression/pipeline/DynamicTypeEvaluator.java b/wear/protolayout/protolayout-expression-pipeline/src/main/java/androidx/wear/protolayout/expression/pipeline/DynamicTypeEvaluator.java
index 2080180..8e7aac7 100644
--- a/wear/protolayout/protolayout-expression-pipeline/src/main/java/androidx/wear/protolayout/expression/pipeline/DynamicTypeEvaluator.java
+++ b/wear/protolayout/protolayout-expression-pipeline/src/main/java/androidx/wear/protolayout/expression/pipeline/DynamicTypeEvaluator.java
@@ -44,9 +44,9 @@
 import androidx.wear.protolayout.expression.pipeline.FloatNodes.FixedFloatNode;
 import androidx.wear.protolayout.expression.pipeline.FloatNodes.Int32ToFloatNode;
 import androidx.wear.protolayout.expression.pipeline.FloatNodes.StateFloatNode;
-import androidx.wear.protolayout.expression.pipeline.Int32Nodes.AnimatableFixedInt32Node;
 import androidx.wear.protolayout.expression.pipeline.InstantNodes.FixedInstantNode;
 import androidx.wear.protolayout.expression.pipeline.InstantNodes.PlatformTimeSourceNode;
+import androidx.wear.protolayout.expression.pipeline.Int32Nodes.AnimatableFixedInt32Node;
 import androidx.wear.protolayout.expression.pipeline.Int32Nodes.ArithmeticInt32Node;
 import androidx.wear.protolayout.expression.pipeline.Int32Nodes.DynamicAnimatedInt32Node;
 import androidx.wear.protolayout.expression.pipeline.Int32Nodes.FixedInt32Node;
@@ -94,8 +94,8 @@
  * <p>Data source can include animations which will then emit value transitions.
  *
  * <p>In order to evaluate dynamic types, the caller needs to add any number of pending dynamic
- * types with {@link #bind} methods and then call {@link #processPendingBindings()} to start
- * evaluation on those dynamic types. Starting evaluation can be done for batches of dynamic types.
+ * types with {@link #bind} methods and then call {@link BoundDynamicType#startEvaluation()} on each
+ * of them to start evaluation. Starting evaluation can be done for batches of dynamic types.
  *
  * <p>It's the callers responsibility to destroy those dynamic types after use, with {@link
  * BoundDynamicType#close()}.
@@ -113,8 +113,6 @@
     @NonNull private final ObservableStateStore mStateStore;
     private final boolean mEnableAnimations;
     @NonNull private final QuotaManager mAnimationQuotaManager;
-    @NonNull private final List<DynamicDataNode<?>> mDynamicTypeNodes = new ArrayList<>();
-    private final Handler mUiHandler;
 
     @NonNull
     private static final QuotaManager DISABLED_ANIMATIONS_QUOTA_MANAGER =
@@ -204,8 +202,8 @@
             boolean enableAnimations,
             @NonNull QuotaManager animationQuotaManager) {
         this.mSensorGateway = sensorGateway;
-        mUiHandler = new Handler(Looper.getMainLooper());
-        Executor uiExecutor = new MainThreadExecutor(mUiHandler);
+        Handler uiHandler = new Handler(Looper.getMainLooper());
+        MainThreadExecutor uiExecutor = new MainThreadExecutor(uiHandler);
         if (this.mSensorGateway != null) {
             if (platformDataSourcesInitiallyEnabled) {
                 this.mSensorGateway.enableUpdates();
@@ -218,7 +216,7 @@
             this.mSensorGatewayDataSource = null;
         }
 
-        this.mTimeGateway = new TimeGatewayImpl(mUiHandler, platformDataSourcesInitiallyEnabled);
+        this.mTimeGateway = new TimeGatewayImpl(uiHandler, platformDataSourcesInitiallyEnabled);
         this.mTimeDataSource = new EpochTimePlatformDataSource(uiExecutor, mTimeGateway);
 
         this.mEnableAnimations = enableAnimations;
@@ -227,82 +225,37 @@
     }
 
     /**
-     * Starts evaluating all stored pending dynamic types.
-     *
-     * <p>This needs to be called when new pending dynamic types are added via any {@code bind}
-     * method, either when one or a batch is added.
-     *
-     * <p>Any pending dynamic type will be initialized for evaluation. All other already initialized
-     * dynamic types will remain unaffected.
-     *
-     * <p>It's the callers responsibility to destroy those dynamic types after use, with {@link
-     * BoundDynamicType#close()}.
-     *
-     * @hide
-     */
-    @UiThread
-    @RestrictTo(Scope.LIBRARY_GROUP)
-    public void processPendingBindings() {
-        processBindings(mDynamicTypeNodes);
-
-        // This method empties the array with dynamic type nodes.
-        clearDynamicTypesArray();
-    }
-
-    @UiThread
-    private static void processBindings(List<DynamicDataNode<?>> bindings) {
-        preInitNodes(bindings);
-        initNodes(bindings);
-    }
-
-    /**
-     * Removes any stored pending bindings by clearing the list that stores them. Note that this
-     * doesn't destroy them.
-     */
-    @UiThread
-    private void clearDynamicTypesArray() {
-        mDynamicTypeNodes.clear();
-    }
-
-    /** This should be called before initNodes() */
-    @UiThread
-    private static void preInitNodes(List<DynamicDataNode<?>> bindings) {
-        bindings.stream()
-                .filter(n -> n instanceof DynamicDataSourceNode)
-                .forEach(n -> ((DynamicDataSourceNode<?>) n).preInit());
-    }
-
-    @UiThread
-    private static void initNodes(List<DynamicDataNode<?>> bindings) {
-        bindings.stream()
-                .filter(n -> n instanceof DynamicDataSourceNode)
-                .forEach(n -> ((DynamicDataSourceNode<?>) n).init());
-    }
-
-    /**
      * Adds dynamic type from the given {@link DynamicBuilders.DynamicString} for evaluation.
-     * Evaluation will start immediately.
+     *
+     * <p>Evaluation of this dynamic type will start when {@link BoundDynamicType#startEvaluation()}
+     * is called on the returned object.
+     *
+     * <p>Results of evaluation will be sent through the given {@link DynamicTypeValueReceiver}
+     * on the given {@link Executor}.
      *
      * @param stringSource The given String dynamic type that should be evaluated.
+     * @param locale The locale used for the given String source.
+     * @param executor The Executor to run the consumer on.
      * @param consumer The registered consumer for results of the evaluation. It will be called from
      *     UI thread.
-     * @param locale The locale used for the given String source.
      */
     @NonNull
     public BoundDynamicType bind(
             @NonNull DynamicBuilders.DynamicString stringSource,
             @NonNull ULocale locale,
+            @NonNull Executor executor,
             @NonNull DynamicTypeValueReceiver<String> consumer) {
-        List<DynamicDataNode<?>> resultBuilder = new ArrayList<>();
-        bindRecursively(stringSource.toDynamicStringProto(), consumer, locale, resultBuilder);
-        mUiHandler.post(() -> processBindings(resultBuilder));
-        return new BoundDynamicTypeImpl(resultBuilder);
+        return bind(
+                stringSource.toDynamicStringProto(),
+                locale,
+                new DynamicTypeValueReceiverOnExecutor<>(executor, consumer));
     }
 
     /**
      * Adds pending dynamic type from the given {@link DynamicString} for future evaluation.
      *
-     * <p>Evaluation of this dynamic type will start when {@link #processPendingBindings} is called.
+     * <p>Evaluation of this dynamic type will start when {@link BoundDynamicType#startEvaluation()}
+     * is called on the returned object.
      *
      * @param stringSource The given String dynamic type that should be evaluated.
      * @param consumer The registered consumer for results of the evaluation. It will be called from
@@ -318,33 +271,38 @@
             @NonNull DynamicTypeValueReceiver<String> consumer) {
         List<DynamicDataNode<?>> resultBuilder = new ArrayList<>();
         bindRecursively(stringSource, consumer, locale, resultBuilder);
-        mDynamicTypeNodes.addAll(resultBuilder);
         return new BoundDynamicTypeImpl(resultBuilder);
     }
 
     /**
      * Adds dynamic type from the given {@link DynamicBuilders.DynamicInt32} for evaluation.
-     * Evaluation will start immediately.
+     *
+     * <p>Evaluation of this dynamic type will start when {@link BoundDynamicType#startEvaluation()}
+     * is called on the returned object.
+     *
+     * <p>Results of evaluation will be sent through the given {@link DynamicTypeValueReceiver}
+     * on the given {@link Executor}.
      *
      * @param int32Source The given integer dynamic type that should be evaluated.
+     * @param executor The Executor to run the consumer on.
      * @param consumer The registered consumer for results of the evaluation. It will be called from
      *     UI thread.
      */
     @NonNull
     public BoundDynamicType bind(
             @NonNull DynamicBuilders.DynamicInt32 int32Source,
+            @NonNull Executor executor,
             @NonNull DynamicTypeValueReceiver<Integer> consumer) {
-        List<DynamicDataNode<?>> resultBuilder = new ArrayList<>();
-        bindRecursively(
-                int32Source.toDynamicInt32Proto(), consumer, resultBuilder, Optional.empty());
-        mUiHandler.post(() -> processBindings(resultBuilder));
-        return new BoundDynamicTypeImpl(resultBuilder);
+        return bind(
+                int32Source.toDynamicInt32Proto(),
+                new DynamicTypeValueReceiverOnExecutor<>(executor, consumer));
     }
 
     /**
      * Adds pending dynamic type from the given {@link DynamicInt32} for future evaluation.
      *
-     * <p>Evaluation of this dynamic type will start when {@link #processPendingBindings} is called.
+     * <p>Evaluation of this dynamic type will start when {@link BoundDynamicType#startEvaluation()}
+     * is called on the returned object.
      *
      * @param int32Source The given integer dynamic type that should be evaluated.
      * @param consumer The registered consumer for results of the evaluation. It will be called from
@@ -358,13 +316,19 @@
             @NonNull DynamicTypeValueReceiver<Integer> consumer) {
         List<DynamicDataNode<?>> resultBuilder = new ArrayList<>();
         bindRecursively(int32Source, consumer, resultBuilder, Optional.empty());
-        mDynamicTypeNodes.addAll(resultBuilder);
         return new BoundDynamicTypeImpl(resultBuilder);
     }
 
     /**
      * Adds pending expression from the given {@link DynamicInt32} for future evaluation.
      *
+     * <p>Evaluation of this dynamic type will start when {@link BoundDynamicType#startEvaluation()}
+     * is called on the returned object.
+     *
+     * <p>While the {@link BoundDynamicType} is not destroyed with {@link BoundDynamicType#close()}
+     * by caller, results of evaluation will be sent through the given {@link
+     * DynamicTypeValueReceiver}.
+     *
      * @param int32Source The given integer dynamic type that should be evaluated.
      * @param consumer The registered consumer for results of the evaluation. It will be called from
      *     UI thread.
@@ -380,33 +344,38 @@
             int animationFallbackValue) {
         List<DynamicDataNode<?>> resultBuilder = new ArrayList<>();
         bindRecursively(int32Source, consumer, resultBuilder, Optional.of(animationFallbackValue));
-        mDynamicTypeNodes.addAll(resultBuilder);
         return new BoundDynamicTypeImpl(resultBuilder);
     }
 
     /**
      * Adds dynamic type from the given {@link DynamicBuilders.DynamicFloat} for evaluation.
-     * Evaluation will start immediately.
+     *
+     * <p>Evaluation of this dynamic type will start when {@link BoundDynamicType#startEvaluation()}
+     * is called on the returned object.
+     *
+     * <p>Results of evaluation will be sent through the given {@link DynamicTypeValueReceiver}
+     * on the given {@link Executor}.
      *
      * @param floatSource The given float dynamic type that should be evaluated.
+     * @param executor The Executor to run the consumer on.
      * @param consumer The registered consumer for results of the evaluation. It will be called from
      *     UI thread.
      */
     @NonNull
     public BoundDynamicType bind(
             @NonNull DynamicBuilders.DynamicFloat floatSource,
+            @NonNull Executor executor,
             @NonNull DynamicTypeValueReceiver<Float> consumer) {
-        List<DynamicDataNode<?>> resultBuilder = new ArrayList<>();
-        bindRecursively(
-                floatSource.toDynamicFloatProto(), consumer, resultBuilder, Optional.empty());
-        mUiHandler.post(() -> processBindings(resultBuilder));
-        return new BoundDynamicTypeImpl(resultBuilder);
+        return bind(
+                floatSource.toDynamicFloatProto(),
+                new DynamicTypeValueReceiverOnExecutor<>(executor, consumer));
     }
 
     /**
      * Adds pending dynamic type from the given {@link DynamicFloat} for future evaluation.
      *
-     * <p>Evaluation of this dynamic type will start when {@link #processPendingBindings} is called.
+     * <p>Evaluation of this dynamic type will start when {@link BoundDynamicType#startEvaluation()}
+     * is called on the returned object.
      *
      * @param floatSource The given float dynamic type that should be evaluated.
      * @param consumer The registered consumer for results of the evaluation. It will be called from
@@ -423,14 +392,14 @@
             float animationFallbackValue) {
         List<DynamicDataNode<?>> resultBuilder = new ArrayList<>();
         bindRecursively(floatSource, consumer, resultBuilder, Optional.of(animationFallbackValue));
-        mDynamicTypeNodes.addAll(resultBuilder);
         return new BoundDynamicTypeImpl(resultBuilder);
     }
 
     /**
      * Adds pending dynamic type from the given {@link DynamicFloat} for future evaluation.
      *
-     * <p>Evaluation of this dynamic type will start when {@link #processPendingBindings} is called.
+     * <p>Evaluation of this dynamic type will start when {@link BoundDynamicType#startEvaluation()}
+     * is called on the returned object.
      *
      * @param floatSource The given float dynamic type that should be evaluated.
      * @param consumer The registered consumer for results of the evaluation. It will be called from
@@ -443,33 +412,38 @@
             @NonNull DynamicFloat floatSource, @NonNull DynamicTypeValueReceiver<Float> consumer) {
         List<DynamicDataNode<?>> resultBuilder = new ArrayList<>();
         bindRecursively(floatSource, consumer, resultBuilder, Optional.empty());
-        mDynamicTypeNodes.addAll(resultBuilder);
         return new BoundDynamicTypeImpl(resultBuilder);
     }
 
     /**
      * Adds dynamic type from the given {@link DynamicBuilders.DynamicColor} for evaluation.
-     * Evaluation will start immediately.
+     *
+     * <p>Evaluation of this dynamic type will start when {@link BoundDynamicType#startEvaluation()}
+     * is called on the returned object.
+     *
+     * <p>Results of evaluation will be sent through the given {@link DynamicTypeValueReceiver}
+     * on the given {@link Executor}.
      *
      * @param colorSource The given color dynamic type that should be evaluated.
+     * @param executor The Executor to run the consumer on.
      * @param consumer The registered consumer for results of the evaluation. It will be called from
      *     UI thread.
      */
     @NonNull
     public BoundDynamicType bind(
             @NonNull DynamicBuilders.DynamicColor colorSource,
+            @NonNull Executor executor,
             @NonNull DynamicTypeValueReceiver<Integer> consumer) {
-        List<DynamicDataNode<?>> resultBuilder = new ArrayList<>();
-        bindRecursively(
-                colorSource.toDynamicColorProto(), consumer, resultBuilder, Optional.empty());
-        mUiHandler.post(() -> processBindings(resultBuilder));
-        return new BoundDynamicTypeImpl(resultBuilder);
+        return bind(
+                colorSource.toDynamicColorProto(),
+                new DynamicTypeValueReceiverOnExecutor<>(executor, consumer));
     }
 
     /**
      * Adds pending dynamic type from the given {@link DynamicColor} for future evaluation.
      *
-     * <p>Evaluation of this dynamic type will start when {@link #processPendingBindings} is called.
+     * <p>Evaluation of this dynamic type will start when {@link BoundDynamicType#startEvaluation()}
+     * is called on the returned object.
      *
      * @param colorSource The given color dynamic type that should be evaluated.
      * @param consumer The registered consumer for results of the evaluation. It will be called from
@@ -483,14 +457,14 @@
             @NonNull DynamicTypeValueReceiver<Integer> consumer) {
         List<DynamicDataNode<?>> resultBuilder = new ArrayList<>();
         bindRecursively(colorSource, consumer, resultBuilder, Optional.empty());
-        mDynamicTypeNodes.addAll(resultBuilder);
         return new BoundDynamicTypeImpl(resultBuilder);
     }
 
     /**
      * Adds pending dynamic type from the given {@link DynamicColor} for future evaluation.
      *
-     * <p>Evaluation of this dynamic type will start when {@link #processPendingBindings} is called.
+     * <p>Evaluation of this dynamic type will start when {@link BoundDynamicType#startEvaluation()}
+     * is called on the returned object.
      *
      * @param colorSource The given color dynamic type that should be evaluated.
      * @param consumer The registered consumer for results of the evaluation. It will be called from
@@ -507,85 +481,96 @@
             int animationFallbackValue) {
         List<DynamicDataNode<?>> resultBuilder = new ArrayList<>();
         bindRecursively(colorSource, consumer, resultBuilder, Optional.of(animationFallbackValue));
-        mDynamicTypeNodes.addAll(resultBuilder);
         return new BoundDynamicTypeImpl(resultBuilder);
     }
 
     /**
      * Adds dynamic type from the given {@link DynamicBuilders.DynamicDuration} for evaluation.
-     * Evaluation will start immediately.
+     *
+     * <p>Evaluation of this dynamic type will start when {@link BoundDynamicType#startEvaluation()}
+     * is called on the returned object.
+     *
+     * <p>Results of evaluation will be sent through the given {@link DynamicTypeValueReceiver}
+     * on the given {@link Executor}.
      *
      * @param durationSource The given duration dynamic type that should be evaluated.
+     * @param executor The Executor to run the consumer on.
      * @param consumer The registered consumer for results of the evaluation. It will be called from
-     * UI thread.
+     *     UI thread.
      */
     @NonNull
     public BoundDynamicType bind(
-        @NonNull DynamicBuilders.DynamicDuration durationSource,
-        @NonNull DynamicTypeValueReceiver<Duration> consumer) {
-        List<DynamicDataNode<?>> resultBuilder = new ArrayList<>();
-        bindRecursively(durationSource.toDynamicDurationProto(), consumer, resultBuilder);
-        processBindings(resultBuilder);
-        return new BoundDynamicTypeImpl(resultBuilder);
+            @NonNull DynamicBuilders.DynamicDuration durationSource,
+            @NonNull Executor executor,
+            @NonNull DynamicTypeValueReceiver<Duration> consumer) {
+        return bind(
+                durationSource.toDynamicDurationProto(),
+                new DynamicTypeValueReceiverOnExecutor<>(executor, consumer));
     }
 
     /**
      * Adds pending dynamic type from the given {@link DynamicDuration} for future evaluation.
      *
-     * <p>Evaluation of this dynamic type will start when {@link #processPendingBindings} is called.
+     * <p>Evaluation of this dynamic type will start when {@link BoundDynamicType#startEvaluation()}
+     * is called on the returned object.
      *
      * @param durationSource The given durations dynamic type that should be evaluated.
      * @param consumer The registered consumer for results of the evaluation. It will be called from
-     * UI thread.
+     *     UI thread.
      * @hide
      */
     @NonNull
     @RestrictTo(Scope.LIBRARY_GROUP)
     public BoundDynamicType bind(
-        @NonNull DynamicDuration durationSource,
-        @NonNull DynamicTypeValueReceiver<Duration> consumer) {
+            @NonNull DynamicDuration durationSource,
+            @NonNull DynamicTypeValueReceiver<Duration> consumer) {
         List<DynamicDataNode<?>> resultBuilder = new ArrayList<>();
         bindRecursively(durationSource, consumer, resultBuilder);
-        mDynamicTypeNodes.addAll(resultBuilder);
         return new BoundDynamicTypeImpl(resultBuilder);
     }
 
     /**
      * Adds dynamic type from the given {@link DynamicBuilders.DynamicInstant} for evaluation.
-     * Evaluation will start immediately.
+     *
+     * <p>Evaluation of this dynamic type will start when {@link BoundDynamicType#startEvaluation()}
+     * is called on the returned object.
+     *
+     * <p>Results of evaluation will be sent through the given {@link DynamicTypeValueReceiver}
+     * on the given {@link Executor}.
      *
      * @param instantSource The given instant dynamic type that should be evaluated.
+     * @param executor The Executor to run the consumer on.
      * @param consumer The registered consumer for results of the evaluation. It will be called from
-     * UI thread.
+     *     UI thread.
      */
     @NonNull
     public BoundDynamicType bind(
-        @NonNull DynamicBuilders.DynamicInstant instantSource,
-        @NonNull DynamicTypeValueReceiver<Instant> consumer) {
-        List<DynamicDataNode<?>> resultBuilder = new ArrayList<>();
-        bindRecursively(instantSource.toDynamicInstantProto(), consumer, resultBuilder);
-        processBindings(resultBuilder);
-        return new BoundDynamicTypeImpl(resultBuilder);
+            @NonNull DynamicBuilders.DynamicInstant instantSource,
+            @NonNull Executor executor,
+            @NonNull DynamicTypeValueReceiver<Instant> consumer) {
+        return bind(
+                instantSource.toDynamicInstantProto(),
+                new DynamicTypeValueReceiverOnExecutor<>(executor, consumer));
     }
 
     /**
      * Adds pending dynamic type from the given {@link DynamicInstant} for future evaluation.
      *
-     * <p>Evaluation of this dynamic type will start when {@link #processPendingBindings} is called.
+     * <p>Evaluation of this dynamic type will start when {@link BoundDynamicType#startEvaluation()}
+     * is called on the returned object.
      *
      * @param instantSource The given instant dynamic type that should be evaluated.
      * @param consumer The registered consumer for results of the evaluation. It will be called from
-     * UI thread.
+     *     UI thread.
      * @hide
      */
     @NonNull
     @RestrictTo(Scope.LIBRARY_GROUP)
     public BoundDynamicType bind(
-        @NonNull DynamicInstant instantSource,
-        @NonNull DynamicTypeValueReceiver<Instant> consumer) {
+            @NonNull DynamicInstant instantSource,
+            @NonNull DynamicTypeValueReceiver<Instant> consumer) {
         List<DynamicDataNode<?>> resultBuilder = new ArrayList<>();
         bindRecursively(instantSource, consumer, resultBuilder);
-        mDynamicTypeNodes.addAll(resultBuilder);
         return new BoundDynamicTypeImpl(resultBuilder);
     }
 
@@ -593,24 +578,29 @@
      * Adds dynamic type from the given {@link DynamicBuilders.DynamicBool} for evaluation.
      * Evaluation will start immediately.
      *
+     * <p>Results of evaluation will be sent through the given {@link DynamicTypeValueReceiver}
+     * on the given {@link Executor}.
+     *
      * @param boolSource The given boolean dynamic type that should be evaluated.
+     * @param executor The Executor to run the consumer on.
      * @param consumer The registered consumer for results of the evaluation. It will be called from
      *     UI thread.
      */
     @NonNull
     public BoundDynamicType bind(
             @NonNull DynamicBuilders.DynamicBool boolSource,
+            @NonNull Executor executor,
             @NonNull DynamicTypeValueReceiver<Boolean> consumer) {
-        List<DynamicDataNode<?>> resultBuilder = new ArrayList<>();
-        bindRecursively(boolSource.toDynamicBoolProto(), consumer, resultBuilder);
-        mUiHandler.post(() -> processBindings(resultBuilder));
-        return new BoundDynamicTypeImpl(resultBuilder);
+        return bind(
+                boolSource.toDynamicBoolProto(),
+                new DynamicTypeValueReceiverOnExecutor<>(executor, consumer));
     }
 
     /**
      * Adds pending dynamic type from the given {@link DynamicBool} for future evaluation.
      *
-     * <p>Evaluation of this dynamic type will start when {@link #processPendingBindings} is called.
+     * <p>Evaluation of this dynamic type will start when {@link BoundDynamicType#startEvaluation()}
+     * is called on the returned object.
      *
      * @param boolSource The given boolean dynamic type that should be evaluated.
      * @param consumer The registered consumer for results of the evaluation. It will be called from
@@ -623,14 +613,13 @@
             @NonNull DynamicBool boolSource, @NonNull DynamicTypeValueReceiver<Boolean> consumer) {
         List<DynamicDataNode<?>> resultBuilder = new ArrayList<>();
         bindRecursively(boolSource, consumer, resultBuilder);
-        mDynamicTypeNodes.addAll(resultBuilder);
         return new BoundDynamicTypeImpl(resultBuilder);
     }
 
     /**
-     * Same as {@link #bind(DynamicBuilders.DynamicString, ULocale, DynamicTypeValueReceiver)}, but
-     * instead of returning one {@link BoundDynamicType}, all {@link DynamicDataNode} produced by
-     * evaluating given dynamic type are added to the given list.
+     * Same as {@link #bind}, but instead of returning one {@link BoundDynamicType}, all
+     * {@link DynamicDataNode} produced by evaluating given dynamic type are added to the given
+     * list.
      */
     private void bindRecursively(
             @NonNull DynamicString stringSource,
@@ -725,9 +714,9 @@
     }
 
     /**
-     * Same as {@link #bind(DynamicBuilders.DynamicInt32, DynamicTypeValueReceiver)}, but instead of
-     * returning one {@link BoundDynamicType}, all {@link DynamicDataNode} produced by evaluating
-     * given dynamic type are added to the given list.
+     * Same as {@link #bind}, but instead of returning one {@link BoundDynamicType}, all
+     * {@link DynamicDataNode} produced by evaluating given dynamic type are added to the given
+     * list.
      */
     private void bindRecursively(
             @NonNull DynamicInt32 int32Source,
@@ -741,7 +730,8 @@
                 node = new FixedInt32Node(int32Source.getFixed(), consumer);
                 break;
             case PLATFORM_SOURCE:
-                node = new PlatformInt32SourceNode(
+                node =
+                        new PlatformInt32SourceNode(
                                 int32Source.getPlatformSource(),
                                 mSensorGatewayDataSource,
                                 consumer);
@@ -809,34 +799,36 @@
                     break;
                 }
             case DURATION_PART:
-            {
-                GetDurationPartOpNode durationPartOpNode =
-                    new GetDurationPartOpNode(int32Source.getDurationPart(), consumer);
-                node = durationPartOpNode;
+                {
+                    GetDurationPartOpNode durationPartOpNode =
+                            new GetDurationPartOpNode(int32Source.getDurationPart(), consumer);
+                    node = durationPartOpNode;
 
-                bindRecursively(
-                    int32Source.getDurationPart().getInput(),
-                    durationPartOpNode.getIncomingCallback(),
-                    resultBuilder);
-                break;
-            }
+                    bindRecursively(
+                            int32Source.getDurationPart().getInput(),
+                            durationPartOpNode.getIncomingCallback(),
+                            resultBuilder);
+                    break;
+                }
             case ANIMATABLE_FIXED:
                 if (!mEnableAnimations && animationFallbackValue.isPresent()) {
                     // Just assign static value if animations are disabled.
                     node =
                             new FixedInt32Node(
-                                    FixedInt32.newBuilder().setValue(
-                                            animationFallbackValue.get()).build(), consumer);
+                                    FixedInt32.newBuilder()
+                                            .setValue(animationFallbackValue.get())
+                                            .build(),
+                                    consumer);
 
                 } else {
-                    // We don't have to check if enableAnimations is true, because if it's false
-                    // and we didn't
-                    // have static value set, constructor has put QuotaManager that don't have
-                    // any quota, so
-                    // animations won't be played and they would jump to the end value.
+                    // We don't have to check if enableAnimations is true, because if it's false and
+                    // we didn't have static value set, constructor has put QuotaManager that don't
+                    // have any quota, so animations won't be played and they would jump to the end
+                    // value.
                     node =
                             new AnimatableFixedInt32Node(
-                                    int32Source.getAnimatableFixed(), consumer,
+                                    int32Source.getAnimatableFixed(),
+                                    consumer,
                                     mAnimationQuotaManager);
                 }
                 break;
@@ -845,18 +837,21 @@
                     // Just assign static value if animations are disabled.
                     node =
                             new FixedInt32Node(
-                                    FixedInt32.newBuilder().setValue(
-                                            animationFallbackValue.get()).build(), consumer);
+                                    FixedInt32.newBuilder()
+                                            .setValue(animationFallbackValue.get())
+                                            .build(),
+                                    consumer);
 
                 } else {
-                    // We don't have to check if enableAnimations is true, because if it's false
-                    // and we didn't
-                    // have static value set, constructor has put QuotaManager that don't have
-                    // any quota, so
-                    // animations won't be played and they would jump to the end value.
+                    // We don't have to check if enableAnimations is true, because if it's false and
+                    // we didn't have static value set, constructor has put QuotaManager that don't
+                    // have any quota, so animations won't be played and they would jump to the end
+                    // value.
                     AnimatableDynamicInt32 dynamicNode = int32Source.getAnimatableDynamic();
                     DynamicAnimatedInt32Node animationNode =
-                            new DynamicAnimatedInt32Node(consumer, dynamicNode.getAnimationSpec(),
+                            new DynamicAnimatedInt32Node(
+                                    consumer,
+                                    dynamicNode.getAnimationSpec(),
                                     mAnimationQuotaManager);
                     node = animationNode;
 
@@ -877,14 +872,14 @@
     }
 
     /**
-     * Same as {@link #bind(DynamicBuilders.DynamicDuration, DynamicTypeValueReceiver)}, but instead
-     * of returning one {@link BoundDynamicType}, all {@link DynamicDataNode} produced by evaluating
-     * given dynamic type are added to the given list.
+     * Same as {@link #bind}, but instead of returning one {@link BoundDynamicType}, all
+     * {@link DynamicDataNode} produced by evaluating given dynamic type are added to the given
+     * list.
      */
     private void bindRecursively(
-        @NonNull DynamicDuration durationSource,
-        @NonNull DynamicTypeValueReceiver<Duration> consumer,
-        @NonNull List<DynamicDataNode<?>> resultBuilder) {
+            @NonNull DynamicDuration durationSource,
+            @NonNull DynamicTypeValueReceiver<Duration> consumer,
+            @NonNull List<DynamicDataNode<?>> resultBuilder) {
         DynamicDataNode<?> node;
 
         switch (durationSource.getInnerCase()) {
@@ -910,14 +905,14 @@
     }
 
     /**
-     * Same as {@link #bind(DynamicBuilders.DynamicInstant, DynamicTypeValueReceiver)}, but instead
-     * of returning one {@link BoundDynamicType}, all {@link DynamicDataNode} produced by evaluating
-     * given dynamic type are added to the given list.
+     * Same as {@link #bind}, but instead of returning one {@link BoundDynamicType}, all
+     * {@link DynamicDataNode} produced by evaluating given dynamic type are added to the given
+     * list.
      */
     private void bindRecursively(
-        @NonNull DynamicInstant instantSource,
-        @NonNull DynamicTypeValueReceiver<Instant> consumer,
-        @NonNull List<DynamicDataNode<?>> resultBuilder) {
+            @NonNull DynamicInstant instantSource,
+            @NonNull DynamicTypeValueReceiver<Instant> consumer,
+            @NonNull List<DynamicDataNode<?>> resultBuilder) {
         DynamicDataNode<?> node;
 
         switch (instantSource.getInnerCase()) {
@@ -938,9 +933,9 @@
     }
 
     /**
-     * Same as {@link #bind(DynamicBuilders.DynamicFloat, DynamicTypeValueReceiver)}, but instead of
-     * returning one {@link BoundDynamicType}, all {@link DynamicDataNode} produced by evaluating
-     * given dynamic type are added to the given list.
+     * Same as {@link #bind}, but instead of returning one {@link BoundDynamicType}, all
+     * {@link DynamicDataNode} produced by evaluating given dynamic type are added to the given
+     * list.
      */
     private void bindRecursively(
             @NonNull DynamicFloat floatSource,
@@ -1075,9 +1070,9 @@
     }
 
     /**
-     * Same as {@link #bind(DynamicBuilders.DynamicColor, DynamicTypeValueReceiver)}, but instead of
-     * returning one {@link BoundDynamicType}, all {@link DynamicDataNode} produced by evaluating
-     * given dynamic type are added to the given list.
+     * Same as {@link #bind}, but instead of returning one {@link BoundDynamicType}, all
+     * {@link DynamicDataNode} produced by evaluating given dynamic type are added to the given
+     * list.
      */
     private void bindRecursively(
             @NonNull DynamicColor colorSource,
@@ -1158,9 +1153,9 @@
     }
 
     /**
-     * Same as {@link #bind(DynamicBuilders.DynamicBool, DynamicTypeValueReceiver)}, but instead of
-     * returning one {@link BoundDynamicType}, all {@link DynamicDataNode} produced by evaluating
-     * given dynamic type are added to the given list.
+     * Same as {@link #bind}, but instead of returning one {@link BoundDynamicType}, all
+     * {@link DynamicDataNode} produced by evaluating given dynamic type are added to the given
+     * list.
      */
     private void bindRecursively(
             @NonNull DynamicBool boolSource,
@@ -1283,4 +1278,39 @@
             Log.e(TAG, "Error while cleaning up time gateway", ex);
         }
     }
+
+    /**
+     * Wraps {@link DynamicTypeValueReceiver} and executes its methods on the given
+     * {@link Executor}.
+     */
+    private static class DynamicTypeValueReceiverOnExecutor<T>
+            implements DynamicTypeValueReceiver<T> {
+
+        @NonNull private final Executor mExecutor;
+        @NonNull private final DynamicTypeValueReceiver<T> mConsumer;
+
+        DynamicTypeValueReceiverOnExecutor(
+                @NonNull Executor executor, @NonNull DynamicTypeValueReceiver<T> consumer) {
+            this.mConsumer = consumer;
+            this.mExecutor = executor;
+        }
+
+        @Override
+        @SuppressWarnings("ExecutorTaskName")
+        public void onPreUpdate() {
+            mExecutor.execute(mConsumer::onPreUpdate);
+        }
+
+        @Override
+        @SuppressWarnings("ExecutorTaskName")
+        public void onData(@NonNull T newData) {
+            mExecutor.execute(() -> mConsumer.onData(newData));
+        }
+
+        @Override
+        @SuppressWarnings("ExecutorTaskName")
+        public void onInvalidated() {
+            mExecutor.execute(mConsumer::onInvalidated);
+        }
+    }
 }
diff --git a/wear/protolayout/protolayout-expression-pipeline/src/main/java/androidx/wear/protolayout/expression/pipeline/DynamicTypeValueReceiver.java b/wear/protolayout/protolayout-expression-pipeline/src/main/java/androidx/wear/protolayout/expression/pipeline/DynamicTypeValueReceiver.java
index f421d27..d38d4a3 100644
--- a/wear/protolayout/protolayout-expression-pipeline/src/main/java/androidx/wear/protolayout/expression/pipeline/DynamicTypeValueReceiver.java
+++ b/wear/protolayout/protolayout-expression-pipeline/src/main/java/androidx/wear/protolayout/expression/pipeline/DynamicTypeValueReceiver.java
@@ -18,7 +18,6 @@
 
 import androidx.annotation.NonNull;
 import androidx.annotation.RestrictTo;
-import androidx.annotation.UiThread;
 
 /**
  * Callback for an evaluation result. This is intended to support two-step updates; first a
@@ -34,7 +33,7 @@
  */
 public interface DynamicTypeValueReceiver<T> {
     /**
-     * Called when evaluation result for the expression that this callback was registered for is
+     * Called when evaluation result for the dynamic type that this callback was registered for is
      * about to be updated. This allows a downstream consumer to properly synchronize updates if it
      * depends on two or more evaluation result items. In that case, it should use this call to
      * figure out how many of its dependencies are going to be updated, and wait for all of them to
@@ -42,19 +41,17 @@
      *
      * @hide
      */
-    @UiThread
     @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
     void onPreUpdate();
 
     /**
-     * Called when the expression that this callback was registered for has a new evaluation result.
+     * Called when the dynamic type that this callback was registered for has a new evaluation
+     * result.
      *
      * @see DynamicTypeValueReceiver#onPreUpdate()
      */
-    @UiThread
     void onData(@NonNull T newData);
 
-    /** Called when the expression that this callback was registered for has an invalid result. */
-    @UiThread
+    /** Called when the dynamic type that this callback was registered for has an invalid result. */
     void onInvalidated();
 }
diff --git a/wear/protolayout/protolayout-expression-pipeline/src/main/java/androidx/wear/protolayout/expression/pipeline/FixedQuotaManagerImpl.java b/wear/protolayout/protolayout-expression-pipeline/src/main/java/androidx/wear/protolayout/expression/pipeline/FixedQuotaManagerImpl.java
new file mode 100644
index 0000000..731c8c7
--- /dev/null
+++ b/wear/protolayout/protolayout-expression-pipeline/src/main/java/androidx/wear/protolayout/expression/pipeline/FixedQuotaManagerImpl.java
@@ -0,0 +1,76 @@
+/*
+ * 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.protolayout.expression.pipeline;
+
+import androidx.annotation.RestrictTo;
+import androidx.annotation.RestrictTo.Scope;
+import androidx.annotation.VisibleForTesting;
+
+/**
+ * Quota manager with fixed quota cap. This class is not thread safe.
+ *
+ * @hide
+ */
+@RestrictTo(Scope.LIBRARY_GROUP)
+public class FixedQuotaManagerImpl implements QuotaManager {
+    private final int mQuotaCap;
+
+    private int mQuotaCounter = 0;
+
+    /** Creates a {@link FixedQuotaManagerImpl} with the given quota cap. */
+    public FixedQuotaManagerImpl(int quotaCap) {
+        this.mQuotaCap = quotaCap;
+    }
+
+    /**
+     * @see QuotaManager#tryAcquireQuota
+     *     <p>Note that this method is not thread safe.
+     */
+    @Override
+    public boolean tryAcquireQuota(int quota) {
+        if (mQuotaCounter + quota <= mQuotaCap) {
+            mQuotaCounter += quota;
+            return true;
+        } else {
+            return false;
+        }
+    }
+
+    /**
+     * @see QuotaManager#releaseQuota
+     *     <p>Note that this method is not thread safe.
+     */
+    @Override
+    public void releaseQuota(int quota) {
+        if (mQuotaCounter - quota < 0) {
+            throw new IllegalArgumentException(
+                    "Trying to release more quota than it was acquired!");
+        }
+        mQuotaCounter -= quota;
+    }
+
+    /**
+     * Returns true if all quota has been released.
+     *
+     * @hide
+     */
+    @VisibleForTesting(otherwise = VisibleForTesting.NONE)
+    @RestrictTo(Scope.TESTS)
+    public boolean isAllQuotaReleased() {
+        return mQuotaCounter == 0;
+    }
+}
diff --git a/wear/protolayout/protolayout-expression-pipeline/src/main/java/androidx/wear/protolayout/expression/pipeline/FloatNodes.java b/wear/protolayout/protolayout-expression-pipeline/src/main/java/androidx/wear/protolayout/expression/pipeline/FloatNodes.java
index 4c1027b..ebfe128 100644
--- a/wear/protolayout/protolayout-expression-pipeline/src/main/java/androidx/wear/protolayout/expression/pipeline/FloatNodes.java
+++ b/wear/protolayout/protolayout-expression-pipeline/src/main/java/androidx/wear/protolayout/expression/pipeline/FloatNodes.java
@@ -16,9 +16,6 @@
 
 package androidx.wear.protolayout.expression.pipeline;
 
-import static androidx.wear.protolayout.expression.pipeline.AnimationsHelper.applyAnimationSpecToAnimator;
-
-import android.animation.ValueAnimator;
 import android.util.Log;
 
 import androidx.annotation.NonNull;
@@ -127,9 +124,12 @@
                 AnimatableFixedFloat protoNode,
                 DynamicTypeValueReceiver<Float> downstream,
                 QuotaManager quotaManager) {
-            super(quotaManager);
+
+            super(quotaManager, protoNode.getAnimationSpec());
             this.mProtoNode = protoNode;
             this.mDownstream = downstream;
+            mQuotaAwareAnimator.addUpdateCallback(
+                    animatedValue -> mDownstream.onData((Float) animatedValue));
         }
 
         @Override
@@ -141,13 +141,7 @@
         @Override
         @UiThread
         public void init() {
-            ValueAnimator animator =
-                    ValueAnimator.ofFloat(mProtoNode.getFromValue(), mProtoNode.getToValue());
-            animator.addUpdateListener(a -> mDownstream.onData((float) a.getAnimatedValue()));
-
-            applyAnimationSpecToAnimator(animator, mProtoNode.getAnimationSpec());
-
-            mQuotaAwareAnimator.updateAnimator(animator);
+            mQuotaAwareAnimator.setFloatValues(mProtoNode.getFromValue(), mProtoNode.getToValue());
             startOrSkipAnimator();
         }
 
@@ -174,8 +168,16 @@
                 DynamicTypeValueReceiver<Float> downstream,
                 @NonNull AnimationSpec spec,
                 QuotaManager quotaManager) {
-            super(quotaManager);
+
+            super(quotaManager, spec);
             this.mDownstream = downstream;
+            mQuotaAwareAnimator.addUpdateCallback(
+                    animatedValue -> {
+                        if (mPendingCalls == 0) {
+                            mCurrentValue = (Float) animatedValue;
+                            mDownstream.onData(mCurrentValue);
+                        }
+                    });
             this.mInputCallback =
                     new DynamicTypeValueReceiver<Float>() {
                         @Override
@@ -184,8 +186,6 @@
 
                             if (mPendingCalls == 1) {
                                 mDownstream.onPreUpdate();
-
-                                mQuotaAwareAnimator.resetAnimator();
                             }
                         }
 
@@ -200,20 +200,7 @@
                                     mCurrentValue = newData;
                                     mDownstream.onData(mCurrentValue);
                                 } else {
-                                    ValueAnimator animator =
-                                            ValueAnimator.ofFloat(mCurrentValue, newData);
-
-                                    applyAnimationSpecToAnimator(animator, spec);
-
-                                    animator.addUpdateListener(
-                                            a -> {
-                                                if (mPendingCalls == 0) {
-                                                    mCurrentValue = (float) a.getAnimatedValue();
-                                                    mDownstream.onData(mCurrentValue);
-                                                }
-                                            });
-
-                                    mQuotaAwareAnimator.updateAnimator(animator);
+                                    mQuotaAwareAnimator.setFloatValues(mCurrentValue, newData);
                                     startOrSkipAnimator();
                                 }
                             }
diff --git a/wear/protolayout/protolayout-expression-pipeline/src/main/java/androidx/wear/protolayout/expression/pipeline/InstantNodes.java b/wear/protolayout/protolayout-expression-pipeline/src/main/java/androidx/wear/protolayout/expression/pipeline/InstantNodes.java
index 012469c..f6dcf61 100644
--- a/wear/protolayout/protolayout-expression-pipeline/src/main/java/androidx/wear/protolayout/expression/pipeline/InstantNodes.java
+++ b/wear/protolayout/protolayout-expression-pipeline/src/main/java/androidx/wear/protolayout/expression/pipeline/InstantNodes.java
@@ -13,6 +13,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+
 package androidx.wear.protolayout.expression.pipeline;
 
 import androidx.annotation.Nullable;
@@ -24,6 +25,7 @@
 /** Dynamic data nodes which yield instants. */
 class InstantNodes {
     private InstantNodes() {}
+
     /** Dynamic instant node that has a fixed value. */
     static class FixedInstantNode implements DynamicDataSourceNode<Integer> {
         private final Instant mValue;
@@ -49,6 +51,7 @@
         @Override
         public void destroy() {}
     }
+
     /** Dynamic Instant node that gets value from the platform source. */
     static class PlatformTimeSourceNode implements DynamicDataSourceNode<Integer> {
         @Nullable private final EpochTimePlatformDataSource mEpochTimePlatformDataSource;
diff --git a/wear/protolayout/protolayout-expression-pipeline/src/main/java/androidx/wear/protolayout/expression/pipeline/Int32Nodes.java b/wear/protolayout/protolayout-expression-pipeline/src/main/java/androidx/wear/protolayout/expression/pipeline/Int32Nodes.java
index a2c9a94..d70b11e 100644
--- a/wear/protolayout/protolayout-expression-pipeline/src/main/java/androidx/wear/protolayout/expression/pipeline/Int32Nodes.java
+++ b/wear/protolayout/protolayout-expression-pipeline/src/main/java/androidx/wear/protolayout/expression/pipeline/Int32Nodes.java
@@ -16,11 +16,11 @@
 
 package androidx.wear.protolayout.expression.pipeline;
 
-import static androidx.wear.protolayout.expression.pipeline.AnimationsHelper.applyAnimationSpecToAnimator;
+
 import static java.lang.Math.abs;
 
-import android.animation.ValueAnimator;
 import android.util.Log;
+
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import androidx.annotation.UiThread;
@@ -31,11 +31,12 @@
 import androidx.wear.protolayout.expression.proto.DynamicProto.ArithmeticInt32Op;
 import androidx.wear.protolayout.expression.proto.DynamicProto.DurationPartType;
 import androidx.wear.protolayout.expression.proto.DynamicProto.FloatToInt32Op;
+import androidx.wear.protolayout.expression.proto.DynamicProto.GetDurationPartOp;
 import androidx.wear.protolayout.expression.proto.DynamicProto.PlatformInt32Source;
 import androidx.wear.protolayout.expression.proto.DynamicProto.PlatformInt32SourceType;
 import androidx.wear.protolayout.expression.proto.DynamicProto.StateInt32Source;
-import androidx.wear.protolayout.expression.proto.DynamicProto.GetDurationPartOp;
 import androidx.wear.protolayout.expression.proto.FixedProto.FixedInt32;
+
 import java.time.Duration;
 
 /** Dynamic data nodes which yield integers. */
@@ -213,9 +214,10 @@
         private static final String TAG = "GetDurationPartOpNode";
 
         GetDurationPartOpNode(
-            GetDurationPartOp protoNode, DynamicTypeValueReceiver<Integer> downstream) {
-            super(downstream,
-                duration -> (int) getDurationPart(duration, protoNode.getDurationPart()));
+                GetDurationPartOp protoNode, DynamicTypeValueReceiver<Integer> downstream) {
+            super(
+                    downstream,
+                    duration -> (int) getDurationPart(duration, protoNode.getDurationPart()));
         }
 
         private static long getDurationPart(Duration duration, DurationPartType durationPartType) {
@@ -256,9 +258,11 @@
                 AnimatableFixedInt32 protoNode,
                 DynamicTypeValueReceiver<Integer> downstream,
                 QuotaManager quotaManager) {
-            super(quotaManager);
+            super(quotaManager, protoNode.getAnimationSpec());
             this.mProtoNode = protoNode;
             this.mDownstream = downstream;
+            mQuotaAwareAnimator.addUpdateCallback(
+                    animatedValue -> mDownstream.onData((Integer) animatedValue));
         }
 
         @Override
@@ -270,11 +274,7 @@
         @Override
         @UiThread
         public void init() {
-            ValueAnimator animator =
-                    ValueAnimator.ofInt(mProtoNode.getFromValue(), mProtoNode.getToValue());
-            applyAnimationSpecToAnimator(animator, mProtoNode.getAnimationSpec());
-            animator.addUpdateListener(a -> mDownstream.onData((Integer) a.getAnimatedValue()));
-            mQuotaAwareAnimator.updateAnimator(animator);
+            mQuotaAwareAnimator.setIntValues(mProtoNode.getFromValue(), mProtoNode.getToValue());
             startOrSkipAnimator();
         }
 
@@ -286,14 +286,13 @@
     }
 
     /** Dynamic int32 node that gets animatable value from dynamic source. */
-    static class DynamicAnimatedInt32Node extends AnimatableNode implements
-            DynamicDataNode<Integer> {
+    static class DynamicAnimatedInt32Node extends AnimatableNode
+            implements DynamicDataNode<Integer> {
 
         final DynamicTypeValueReceiver<Integer> mDownstream;
         private final DynamicTypeValueReceiver<Integer> mInputCallback;
 
-        @Nullable
-        Integer mCurrentValue = null;
+        @Nullable Integer mCurrentValue = null;
         int mPendingCalls = 0;
 
         // Static analysis complains about calling methods of parent class AnimatableNode under
@@ -303,8 +302,15 @@
                 DynamicTypeValueReceiver<Integer> downstream,
                 @NonNull AnimationSpec spec,
                 QuotaManager quotaManager) {
-            super(quotaManager);
+            super(quotaManager, spec);
             this.mDownstream = downstream;
+            mQuotaAwareAnimator.addUpdateCallback(
+                    animatedValue -> {
+                        if (mPendingCalls == 0) {
+                            mCurrentValue = (Integer) animatedValue;
+                            mDownstream.onData(mCurrentValue);
+                        }
+                    });
             this.mInputCallback =
                     new DynamicTypeValueReceiver<Integer>() {
                         @Override
@@ -313,8 +319,6 @@
 
                             if (mPendingCalls == 1) {
                                 mDownstream.onPreUpdate();
-
-                                mQuotaAwareAnimator.resetAnimator();
                             }
                         }
 
@@ -329,19 +333,7 @@
                                     mCurrentValue = newData;
                                     mDownstream.onData(mCurrentValue);
                                 } else {
-                                    ValueAnimator animator = ValueAnimator.ofInt(mCurrentValue,
-                                            newData);
-
-                                    applyAnimationSpecToAnimator(animator, spec);
-                                    animator.addUpdateListener(
-                                            a -> {
-                                                if (mPendingCalls == 0) {
-                                                    mCurrentValue = (Integer) a.getAnimatedValue();
-                                                    mDownstream.onData(mCurrentValue);
-                                                }
-                                            });
-
-                                    mQuotaAwareAnimator.updateAnimator(animator);
+                                    mQuotaAwareAnimator.setIntValues(mCurrentValue, newData);
                                     startOrSkipAnimator();
                                 }
                             }
diff --git a/wear/protolayout/protolayout-expression-pipeline/src/main/java/androidx/wear/protolayout/expression/pipeline/ObservableStateStore.java b/wear/protolayout/protolayout-expression-pipeline/src/main/java/androidx/wear/protolayout/expression/pipeline/ObservableStateStore.java
index 5eefdd1..9573dac 100644
--- a/wear/protolayout/protolayout-expression-pipeline/src/main/java/androidx/wear/protolayout/expression/pipeline/ObservableStateStore.java
+++ b/wear/protolayout/protolayout-expression-pipeline/src/main/java/androidx/wear/protolayout/expression/pipeline/ObservableStateStore.java
@@ -41,8 +41,7 @@
  * must only be used from the UI thread.
  */
 public class ObservableStateStore {
-    @NonNull
-    private final Map<String, StateEntryValue> mCurrentState = new ArrayMap<>();
+    @NonNull private final Map<String, StateEntryValue> mCurrentState = new ArrayMap<>();
 
     @NonNull
     private final Map<String, Set<DynamicTypeValueReceiver<StateEntryValue>>> mRegisteredCallbacks =
@@ -84,12 +83,14 @@
         Map<String, StateEntryValue> changedEntries = getChangedEntries(newState);
 
         Stream.concat(removedKeys.stream(), changedEntries.keySet().stream())
-                .forEach(key -> {
-                    for (DynamicTypeValueReceiver<StateEntryValue> callback :
-                            mRegisteredCallbacks.getOrDefault(key, Collections.emptySet())) {
-                        callback.onPreUpdate();
-                    }
-                });
+                .forEach(
+                        key -> {
+                            for (DynamicTypeValueReceiver<StateEntryValue> callback :
+                                    mRegisteredCallbacks.getOrDefault(
+                                            key, Collections.emptySet())) {
+                                callback.onPreUpdate();
+                            }
+                        });
 
         mCurrentState.clear();
         mCurrentState.putAll(newState);
diff --git a/wear/protolayout/protolayout-expression-pipeline/src/main/java/androidx/wear/protolayout/expression/pipeline/PlatformDataSources.java b/wear/protolayout/protolayout-expression-pipeline/src/main/java/androidx/wear/protolayout/expression/pipeline/PlatformDataSources.java
index b3a2ee2..f070941 100644
--- a/wear/protolayout/protolayout-expression-pipeline/src/main/java/androidx/wear/protolayout/expression/pipeline/PlatformDataSources.java
+++ b/wear/protolayout/protolayout-expression-pipeline/src/main/java/androidx/wear/protolayout/expression/pipeline/PlatformDataSources.java
@@ -31,6 +31,7 @@
 
 /** Utility for various platform data sources. */
 class PlatformDataSources {
+
     private PlatformDataSources() {}
 
     interface PlatformDataSource {
diff --git a/wear/protolayout/protolayout-expression-pipeline/src/main/java/androidx/wear/protolayout/expression/pipeline/QuotaAwareAnimator.java b/wear/protolayout/protolayout-expression-pipeline/src/main/java/androidx/wear/protolayout/expression/pipeline/QuotaAwareAnimator.java
index db26283..796ec7b 100644
--- a/wear/protolayout/protolayout-expression-pipeline/src/main/java/androidx/wear/protolayout/expression/pipeline/QuotaAwareAnimator.java
+++ b/wear/protolayout/protolayout-expression-pipeline/src/main/java/androidx/wear/protolayout/expression/pipeline/QuotaAwareAnimator.java
@@ -16,13 +16,21 @@
 
 package androidx.wear.protolayout.expression.pipeline;
 
+import static androidx.wear.protolayout.expression.pipeline.AnimationsHelper.applyAnimationSpecToAnimator;
+
 import android.animation.Animator;
 import android.animation.AnimatorListenerAdapter;
+import android.animation.TypeEvaluator;
 import android.animation.ValueAnimator;
+import android.os.Handler;
+import android.os.Looper;
 
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import androidx.annotation.UiThread;
+import androidx.annotation.VisibleForTesting;
+import androidx.core.os.HandlerCompat;
+import androidx.wear.protolayout.expression.proto.AnimationParameterProto.AnimationSpec;
 
 import java.util.concurrent.atomic.AtomicBoolean;
 
@@ -32,50 +40,121 @@
  * on wrapped {@link Animator} will be replaced.
  */
 class QuotaAwareAnimator {
-    @Nullable private ValueAnimator mAnimator;
-
+    @NonNull private final ValueAnimator mAnimator;
+    @NonNull private final QuotaManager mQuotaManager;
     @NonNull private final QuotaReleasingAnimatorListener mListener;
+    @NonNull private final Handler mUiHandler;
+    private long mStartDelay = 0;
+    private final Runnable mAcquireQuotaAndAnimateRunnable = this::acquireQuotaAndAnimate;
+    @Nullable private final TypeEvaluator<?> mEvaluator;
 
-    QuotaAwareAnimator(@Nullable ValueAnimator animator, @NonNull QuotaManager quotaManager) {
-        this.mAnimator = animator;
-        this.mListener = new QuotaReleasingAnimatorListener(quotaManager);
+    interface UpdateCallback {
+        abstract void onUpdate(@NonNull Object animatedValue);
+    }
 
-        if (this.mAnimator != null) {
-            this.mAnimator.addListener(mListener);
+    QuotaAwareAnimator(@NonNull QuotaManager quotaManager, @NonNull AnimationSpec spec) {
+        this(quotaManager, spec, null);
+    }
+
+    /**
+     * If an evaluator other than a float or int type shall be used when calculating the animated
+     * values of this animation, use this constructor to set the preferred type evaluator.
+     */
+    QuotaAwareAnimator(
+            @NonNull QuotaManager quotaManager,
+            @NonNull AnimationSpec spec,
+            @Nullable TypeEvaluator<?> evaluator) {
+        mQuotaManager = quotaManager;
+        mAnimator = new ValueAnimator();
+        mUiHandler = new Handler(Looper.getMainLooper());
+        mListener = new QuotaReleasingAnimatorListener(quotaManager);
+        mAnimator.addListener(mListener);
+        mAnimator.addPauseListener(mListener);
+        applyAnimationSpecToAnimator(mAnimator, spec);
+
+        // The start delay would be handled outside ValueAnimator, to make sure that the quota was
+        // not consumed during the delay.
+        mStartDelay = mAnimator.getStartDelay();
+        mAnimator.setStartDelay(0);
+        mEvaluator = evaluator;
+    }
+    /**
+     * Adds a listener that is sent update events through the life of the animation. This method is
+     * called on every frame of the animation after the values of the animation have been
+     * calculated.
+     */
+    void addUpdateCallback(@NonNull UpdateCallback updateCallback) {
+        mAnimator.addUpdateListener(
+                animation -> updateCallback.onUpdate(animation.getAnimatedValue()));
+    }
+
+    /**
+     * Sets float values that will be animated between.
+     *
+     * @param values A set of values that the animation will animate between over time.
+     */
+    void setFloatValues(float... values) {
+        mAnimator.cancel();
+        // ValueAnimator#setEvaluator only valid after values are set, and only need to set once.
+        boolean needToSetEvaluator = mAnimator.getValues() == null && mEvaluator != null;
+        mAnimator.setFloatValues(values);
+        if (needToSetEvaluator) {
+            mAnimator.setEvaluator(mEvaluator);
         }
     }
 
     /**
-     * Sets the new animator with {link @QuotaReleasingListener} added. Previous animator will be
-     * canceled.
+     * Sets integer values that will be animated between.
+     *
+     * @param values A set of values that the animation will animate between over time.
      */
-    void updateAnimator(@NonNull ValueAnimator animator) {
-        cancelAnimator();
+    void setIntValues(int... values) {
+        mAnimator.cancel();
 
-        this.mAnimator = animator;
-        this.mAnimator.addListener(mListener);
-        this.mAnimator.addPauseListener(mListener);
-    }
-
-    /** Resets the animator to null. Previous animator will be canceled. */
-    void resetAnimator() {
-        cancelAnimator();
-
-        mAnimator = null;
+        // ValueAnimator#setEvaluator only valid after values are set, and only need to set once.
+        boolean needToSetEvaluator = mAnimator.getValues() == null && mEvaluator != null;
+        mAnimator.setIntValues(values);
+        if (needToSetEvaluator) {
+            mAnimator.setEvaluator(mEvaluator);
+        }
     }
 
     /**
-     * Tries to start animation. This method will call start on animation, but when animation is due
-     * to start (i.e. after the given delay), listener will check the quota and allow/disallow
-     * animation to be played.
+     * Tries to start animation. This method first handles the start delay if any, then checks the
+     * quota to start tha animation or skip and jump to the end directly.
      */
     @UiThread
     void tryStartAnimation() {
-        if (mAnimator == null) {
+        if (isRunning()) {
             return;
         }
 
-        mAnimator.start();
+        if (mStartDelay > 0) {
+            // Do nothing if we already has pending call to acquireQuotaAndAnimate
+            if (!HandlerCompat.hasCallbacks(mUiHandler, mAcquireQuotaAndAnimateRunnable)) {
+                mUiHandler.postDelayed(mAcquireQuotaAndAnimateRunnable, mStartDelay);
+            }
+        } else {
+            acquireQuotaAndAnimate();
+        }
+    }
+
+    private void acquireQuotaAndAnimate() {
+        // Only valid after setFloatValues/setIntValues has been called
+        if (mAnimator.getValues() == null) {
+            return;
+        }
+
+        if (mQuotaManager.tryAcquireQuota(1)) {
+            mListener.mIsUsingQuota.set(true);
+            mAnimator.start();
+        } else {
+            mListener.mIsUsingQuota.set(false);
+            // No need to jump to an end of animation if it can't be played when they are infinite.
+            if (!isInfiniteAnimator()) {
+                mAnimator.end();
+            }
+        }
     }
 
     /**
@@ -85,17 +164,21 @@
      */
     @UiThread
     void tryStartOrResumeInfiniteAnimation() {
-        if (mAnimator == null) {
+        // Early out for finite animation, already running animation or no valid values before any
+        // setFloatValues or setIntValues call
+        if (!isInfiniteAnimator() || isRunning() || mAnimator.getValues() == null) {
             return;
         }
-        ValueAnimator localAnimator = mAnimator;
-        if (localAnimator.isPaused()) {
-            localAnimator.resume();
-        } else if (isInfiniteAnimator()) {
+
+        if (mAnimator.isPaused()) {
+            if (mQuotaManager.tryAcquireQuota(1)) {
+                mListener.mIsUsingQuota.set(true);
+                mAnimator.resume();
+            }
+        } else {
             // Infinite animators created when this node was invisible have not started yet.
-            localAnimator.start();
+            tryStartAnimation();
         }
-        // No need to jump to an end of animation if it can't be played as they are infinite.
     }
 
     /**
@@ -104,14 +187,16 @@
      */
     @UiThread
     void stopOrPauseAnimator() {
-        if (mAnimator == null) {
-            return;
-        }
-        ValueAnimator localAnimator = mAnimator;
         if (isInfiniteAnimator()) {
-            localAnimator.pause();
+            // remove pending call to start the animation if any
+            mUiHandler.removeCallbacks(mAcquireQuotaAndAnimateRunnable);
+            mAnimator.pause();
+            if (mListener.mIsUsingQuota.compareAndSet(true, false)) {
+                mQuotaManager.releaseQuota(1);
+            }
         } else {
             // This causes the animation to assign the end value of the property being animated.
+            // Quota will be released at onAnimationEnd()
             stopAnimator();
         }
     }
@@ -119,36 +204,26 @@
     /** Stops the animator, which will cause it to assign the end value. */
     @UiThread
     void stopAnimator() {
-        if (mAnimator == null) {
-            return;
+        // remove pending call to start the animation if any
+        mUiHandler.removeCallbacks(mAcquireQuotaAndAnimateRunnable);
+        if (mAnimator.getValues() != null) {
+            mAnimator.end();
         }
-        mAnimator.end();
-    }
-
-    /** Cancels the animator, which will stop in its tracks. */
-    @UiThread
-    void cancelAnimator() {
-        if (mAnimator == null) {
-            return;
-        }
-        // This calls both onCancel and onEnd methods from listener.
-        mAnimator.cancel();
-        mAnimator.removeListener(mListener);
-        mAnimator.removePauseListener(mListener);
     }
 
     /** Returns whether the animator in this class has an infinite duration. */
     protected boolean isInfiniteAnimator() {
-        return mAnimator != null && mAnimator.getTotalDuration() == Animator.DURATION_INFINITE;
+        return mAnimator.getTotalDuration() == Animator.DURATION_INFINITE;
     }
 
-    /**
-     * Returns whether this node has a running or started animation. Started means that animation is
-     * scheduled to run, but it has set time delay.
-     */
-    boolean hasRunningOrStartedAnimation() {
-        return mAnimator != null
-                && (mAnimator.isRunning() || /* delayed animation */ mAnimator.isStarted());
+    /** Returns whether this node has a running animation. */
+    boolean isRunning() {
+        return mAnimator.isRunning();
+    }
+
+    @VisibleForTesting(otherwise = VisibleForTesting.NONE)
+    boolean isPaused() {
+        return mAnimator.isPaused();
     }
 
     /**
@@ -162,10 +237,8 @@
         @NonNull private final QuotaManager mQuotaManager;
 
         // We need to keep track of whether the animation has started because pipeline has initiated
-        // and it has received quota, or onAnimationStart listener has been called because of the
-        // inner ValueAnimator implementation (i.e., when calling end() on animator to assign it end
-        // value, ValueAnimator will call start first if animation is not running to get it to the
-        // end state.
+        // and it has received quota, or it is skipped by calling {@link
+        // android.animation.Animator#end()} because no quota is available.
         @NonNull final AtomicBoolean mIsUsingQuota = new AtomicBoolean(false);
 
         QuotaReleasingAnimatorListener(@NonNull QuotaManager quotaManager) {
@@ -173,46 +246,8 @@
         }
 
         @Override
-        public void onAnimationStart(Animator animation) {
-            acquireQuota(animation);
-        }
-
-        @Override
-        public void onAnimationResume(Animator animation) {
-            acquireQuota(animation);
-        }
-
-        @Override
         @UiThread
         public void onAnimationEnd(Animator animation) {
-            releaseQuota();
-        }
-
-        @Override
-        @UiThread
-        public void onAnimationPause(Animator animation) {
-            releaseQuota();
-        }
-
-        /**
-         * This method will block the given Animator from running animation if there is no enough
-         * quota. In that case, animation will jump to an end.
-         */
-        private void acquireQuota(Animator animation) {
-            if (!mQuotaManager.tryAcquireQuota(1)) {
-                mIsUsingQuota.set(false);
-                animation.end();
-                // End will fire end value via UpdateListener. We don't want any new updates to be
-                // pushed to the callback.
-                if (animation instanceof ValueAnimator) {
-                    ((ValueAnimator) animation).removeAllUpdateListeners();
-                }
-            } else {
-                mIsUsingQuota.set(true);
-            }
-        }
-
-        private void releaseQuota() {
             if (mIsUsingQuota.compareAndSet(true, false)) {
                 mQuotaManager.releaseQuota(1);
             }
diff --git a/wear/protolayout/protolayout-expression-pipeline/src/test/java/androidx/wear/protolayout/expression/pipeline/AddToListCallback.java b/wear/protolayout/protolayout-expression-pipeline/src/test/java/androidx/wear/protolayout/expression/pipeline/AddToListCallback.java
index 80093d3..abfcd56 100644
--- a/wear/protolayout/protolayout-expression-pipeline/src/test/java/androidx/wear/protolayout/expression/pipeline/AddToListCallback.java
+++ b/wear/protolayout/protolayout-expression-pipeline/src/test/java/androidx/wear/protolayout/expression/pipeline/AddToListCallback.java
@@ -16,6 +16,7 @@
 
 package androidx.wear.protolayout.expression.pipeline;
 
+import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 
 import java.util.List;
@@ -38,7 +39,7 @@
     public void onPreUpdate() {}
 
     @Override
-    public void onData(T newData) {
+    public void onData(@NonNull T newData) {
         mListToUpdate.add(newData);
     }
 
diff --git a/wear/protolayout/protolayout-expression-pipeline/src/test/java/androidx/wear/protolayout/expression/pipeline/AnimatableNodeTest.java b/wear/protolayout/protolayout-expression-pipeline/src/test/java/androidx/wear/protolayout/expression/pipeline/AnimatableNodeTest.java
index e0f76fd..6905235 100644
--- a/wear/protolayout/protolayout-expression-pipeline/src/test/java/androidx/wear/protolayout/expression/pipeline/AnimatableNodeTest.java
+++ b/wear/protolayout/protolayout-expression-pipeline/src/test/java/androidx/wear/protolayout/expression/pipeline/AnimatableNodeTest.java
@@ -18,69 +18,241 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import android.animation.ValueAnimator;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.animation.TypeEvaluator;
+import android.graphics.Color;
+import android.os.Looper;
 
 import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.wear.protolayout.expression.proto.AnimationParameterProto.AnimationSpec;
+
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Range;
 
 import org.junit.Test;
 import org.junit.runner.RunWith;
 
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+
 @RunWith(AndroidJUnit4.class)
 public class AnimatableNodeTest {
 
+    private final List<Float> mUpdateValues = new ArrayList<>();
+    private final List<Integer> mUpdateColors = new ArrayList<>();
+
+    private void assertAnimation(List<Float> values, float start, float end) {
+        for (Float value : values) {
+            assertThat(value).isIn(Range.closed(start, end));
+        }
+        assertThat(values.size()).isGreaterThan(2);
+        if (start < end) {
+            assertThat(values).isInOrder();
+        } else {
+            assertThat(values).isInOrder(Comparator.reverseOrder());
+        }
+        assertThat(values.get(0)).isEqualTo(start);
+        assertThat(Iterables.getLast(values)).isEqualTo(end);
+    }
+
     @Test
     public void infiniteAnimator_onlyStartsWhenNodeIsVisible() {
-        ValueAnimator animator = ValueAnimator.ofFloat(0.0f, 10.0f);
-        QuotaManager quotaManager = new UnlimitedQuotaManager();
-        TestQuotaAwareAnimator quotaAwareAnimator =
-                new TestQuotaAwareAnimator(animator, quotaManager);
+        QuotaManager quotaManager = new FixedQuotaManagerImpl(Integer.MAX_VALUE);
+        TestQuotaAwareAnimator quotaAwareAnimator = new TestQuotaAwareAnimator(quotaManager);
+        quotaAwareAnimator.setFloatValues(0.0f, 10.0f);
         TestAnimatableNode animNode = new TestAnimatableNode(quotaAwareAnimator);
 
         quotaAwareAnimator.isInfiniteAnimator = true;
 
-        assertThat(animator.isRunning()).isFalse();
+        assertThat(quotaAwareAnimator.isRunning()).isFalse();
 
         animNode.startOrSkipAnimator();
-        assertThat(animator.isRunning()).isFalse();
+        assertThat(quotaAwareAnimator.isRunning()).isFalse();
 
         animNode.setVisibility(true);
-        assertThat(animator.isRunning()).isTrue();
+        assertThat(quotaAwareAnimator.isRunning()).isTrue();
     }
 
     @Test
     public void infiniteAnimator_pausesWhenNodeIsInvisible() {
-        ValueAnimator animator = ValueAnimator.ofFloat(0.0f, 10.0f);
-        QuotaManager quotaManager = new UnlimitedQuotaManager();
-        TestQuotaAwareAnimator quotaAwareAnimator =
-                new TestQuotaAwareAnimator(animator, quotaManager);
+        QuotaManager quotaManager = new FixedQuotaManagerImpl(Integer.MAX_VALUE);
+        TestQuotaAwareAnimator quotaAwareAnimator = new TestQuotaAwareAnimator(quotaManager);
         TestAnimatableNode animNode = new TestAnimatableNode(quotaAwareAnimator);
+        quotaAwareAnimator.setFloatValues(0.0f, 10.0f);
 
         quotaAwareAnimator.isInfiniteAnimator = true;
 
         animNode.setVisibility(true);
-        assertThat(animator.isRunning()).isTrue();
+        assertThat(quotaAwareAnimator.isRunning()).isTrue();
 
         animNode.setVisibility(false);
-        assertThat(animator.isPaused()).isTrue();
+        assertThat(quotaAwareAnimator.isPaused()).isTrue();
 
         animNode.setVisibility(true);
-        assertThat(animator.isRunning()).isTrue();
+        assertThat(quotaAwareAnimator.isRunning()).isTrue();
     }
 
     @Test
     public void animator_noQuota_notPlayed() {
-        ValueAnimator animator = ValueAnimator.ofFloat(0.0f, 10.0f);
-        QuotaManager quotaManager = new TestNoQuotaManagerImpl();
-        TestQuotaAwareAnimator quotaAwareAnimator =
-                new TestQuotaAwareAnimator(animator, quotaManager);
+        QuotaManager quotaManager = new FixedQuotaManagerImpl(0);
+        TestQuotaAwareAnimator quotaAwareAnimator = new TestQuotaAwareAnimator(quotaManager);
+        quotaAwareAnimator.setFloatValues(0.0f, 10.0f);
         TestAnimatableNode animNode = new TestAnimatableNode(quotaAwareAnimator);
 
         // Check that animator hasn't started because there is no quota.
         animNode.setVisibility(true);
-        assertThat(animator.isStarted()).isFalse();
-        assertThat(animator.isRunning()).isFalse();
+        assertThat(quotaAwareAnimator.isRunning()).isFalse();
+    }
+
+    @Test
+    public void animator_reused_withFloatValues() {
+        mUpdateValues.clear();
+        QuotaManager quotaManager = new FixedQuotaManagerImpl(Integer.MAX_VALUE);
+        TestQuotaAwareAnimator quotaAwareAnimator = new TestQuotaAwareAnimator(quotaManager);
+        quotaAwareAnimator.setFloatValues(0.0f, 10.0f);
+        quotaAwareAnimator.addUpdateCallback(a -> mUpdateValues.add((float) a));
+        TestAnimatableNode animNode = new TestAnimatableNode(quotaAwareAnimator);
+
+        animNode.setVisibility(true);
+        animNode.startOrSkipAnimator();
+        assertThat(quotaAwareAnimator.isRunning()).isTrue();
+
+        shadowOf(Looper.getMainLooper()).idle();
+        assertThat(Iterables.getLast(mUpdateValues)).isEqualTo(10.0f);
+        assertThat(quotaAwareAnimator.isRunning()).isFalse();
+
+        quotaAwareAnimator.setFloatValues(10.0f, 15.0f);
+        animNode.startOrSkipAnimator();
+        assertThat(quotaAwareAnimator.isRunning()).isTrue();
+        shadowOf(Looper.getMainLooper()).idle();
+        assertThat(Iterables.getLast(mUpdateValues)).isEqualTo(15.0f);
+        assertThat(quotaAwareAnimator.isRunning()).isFalse();
+    }
+
+    @Test
+    public void animator_reused_withColorValues() {
+        mUpdateColors.clear();
+        QuotaManager quotaManager = new FixedQuotaManagerImpl(Integer.MAX_VALUE);
+        TestQuotaAwareAnimator quotaAwareAnimator =
+                new TestQuotaAwareAnimator(quotaManager, AnimatableNode.ARGB_EVALUATOR);
+        quotaAwareAnimator.setIntValues(Color.BLUE, Color.MAGENTA);
+        quotaAwareAnimator.addUpdateCallback(a -> mUpdateColors.add((int) a));
+        TestAnimatableNode animNode = new TestAnimatableNode(quotaAwareAnimator);
+
+        animNode.setVisibility(true);
+        animNode.startOrSkipAnimator();
+        assertThat(quotaAwareAnimator.isRunning()).isTrue();
+        shadowOf(Looper.getMainLooper()).idle();
+        assertThat((int) Iterables.getLast(mUpdateColors)).isEqualTo(Color.MAGENTA);
+        assertThat(quotaAwareAnimator.isRunning()).isFalse();
+
+        quotaAwareAnimator.setIntValues(Color.MAGENTA, Color.DKGRAY);
+        animNode.startOrSkipAnimator();
+        assertThat(quotaAwareAnimator.isRunning()).isTrue();
+        shadowOf(Looper.getMainLooper()).idle();
+        assertThat((int) Iterables.getLast(mUpdateColors)).isEqualTo(Color.DKGRAY);
+        assertThat(quotaAwareAnimator.isRunning()).isFalse();
+    }
+
+    @Test
+    public void animator_reuse_withFloatValues() {
+        mUpdateValues.clear();
+        QuotaManager quotaManager = new FixedQuotaManagerImpl(Integer.MAX_VALUE);
+        TestQuotaAwareAnimator quotaAwareAnimator = new TestQuotaAwareAnimator(quotaManager);
+        quotaAwareAnimator.setFloatValues(0.0f, 10.0f);
+        quotaAwareAnimator.addUpdateCallback(a -> mUpdateValues.add((float) a));
+        TestAnimatableNode animNode = new TestAnimatableNode(quotaAwareAnimator);
+
+        animNode.setVisibility(true);
+        animNode.startOrSkipAnimator();
+        shadowOf(Looper.getMainLooper()).idle();
+        // Quota available, update listener was called with interpolated values.
+        assertAnimation(mUpdateValues, 0.0f, 10.0f);
+
+        mUpdateValues.clear();
+        quotaAwareAnimator.setFloatValues(10.0f, 15.0f);
+        animNode.startOrSkipAnimator();
+        shadowOf(Looper.getMainLooper()).idle();
+        // Quota available, update listener was called with interpolated values.
+        assertAnimation(mUpdateValues, 10.0f, 15.0f);
+    }
+
+    @Test
+    public void animator_reuse_noQuota() {
+        mUpdateValues.clear();
+        QuotaManager quotaManager = new FixedQuotaManagerImpl(0);
+        TestQuotaAwareAnimator quotaAwareAnimator = new TestQuotaAwareAnimator(quotaManager);
+        quotaAwareAnimator.setFloatValues(0.0f, 10.0f);
+        quotaAwareAnimator.addUpdateCallback(a -> mUpdateValues.add((float) a));
+        TestAnimatableNode animNode = new TestAnimatableNode(quotaAwareAnimator);
+
+        animNode.setVisibility(true);
+        animNode.startOrSkipAnimator();
+        shadowOf(Looper.getMainLooper()).idle();
+        // No quota available, update listener was called only once end values.
+        assertThat(mUpdateValues).containsExactly(10.f);
+
+        mUpdateValues.clear();
+        quotaAwareAnimator.setFloatValues(10.0f, 15.0f);
+        animNode.startOrSkipAnimator();
+        shadowOf(Looper.getMainLooper()).idle();
+        // No quota available, update listender was called only once with end values.
+        assertThat(mUpdateValues).containsExactly(15.0f);
+    }
+
+    @Test
+    public void animator_reuse_noQuota_then_withQuota() {
+        mUpdateValues.clear();
+        QuotaManager quotaManager = new FixedQuotaManagerImpl(1);
+        TestQuotaAwareAnimator quotaAwareAnimator = new TestQuotaAwareAnimator(quotaManager);
+        quotaAwareAnimator.setFloatValues(0.0f, 10.0f);
+        quotaAwareAnimator.addUpdateCallback(a -> mUpdateValues.add((float) a));
+        TestAnimatableNode animNode = new TestAnimatableNode(quotaAwareAnimator);
+        animNode.setVisibility(true);
+
+        // Occupy the single quota
+        quotaManager.tryAcquireQuota(1);
+        animNode.startOrSkipAnimator();
+        shadowOf(Looper.getMainLooper()).idle();
+        // No quota available, update listender was called only once with end values.
+        assertThat(mUpdateValues).containsExactly(10.0f);
+
+        mUpdateValues.clear();
+        // Release the single quota
+        quotaManager.releaseQuota(1);
+        quotaAwareAnimator.setFloatValues(10.0f, 15.0f);
+        animNode.startOrSkipAnimator();
+        shadowOf(Looper.getMainLooper()).idle();
+        // Quota available, update listener was called with interpolated values.
+        assertAnimation(mUpdateValues, 10.0f, 15.0f);
+    }
+
+    @Test
+    public void animator_reuse_withQuota_then_noQuota() {
+        mUpdateValues.clear();
+        QuotaManager quotaManager = new FixedQuotaManagerImpl(1);
+        TestQuotaAwareAnimator quotaAwareAnimator = new TestQuotaAwareAnimator(quotaManager);
+        quotaAwareAnimator.setFloatValues(0.0f, 10.0f);
+        quotaAwareAnimator.addUpdateCallback(a -> mUpdateValues.add((float) a));
+        TestAnimatableNode animNode = new TestAnimatableNode(quotaAwareAnimator);
+        animNode.setVisibility(true);
+
+        animNode.startOrSkipAnimator();
+        shadowOf(Looper.getMainLooper()).idle();
+        // Quota available, update listener was called with interpolated values.
+        assertAnimation(mUpdateValues, 0.0f, 10.0f);
+
+        mUpdateValues.clear();
+        // Release the single quota
+        quotaManager.tryAcquireQuota(1);
+        quotaAwareAnimator.setFloatValues(13.0f, 15.0f);
+        animNode.startOrSkipAnimator();
+        shadowOf(Looper.getMainLooper()).idle();
+        // No quota available, update listener was called only once with end values.
+        assertThat(mUpdateValues).containsExactly(15.0f);
     }
 
     static class TestAnimatableNode extends AnimatableNode {
@@ -93,9 +265,14 @@
     static class TestQuotaAwareAnimator extends QuotaAwareAnimator {
         public boolean isInfiniteAnimator = false;
 
+        TestQuotaAwareAnimator(@NonNull QuotaManager mQuotaManager) {
+            super(mQuotaManager, AnimationSpec.getDefaultInstance());
+        }
+
+        @SuppressWarnings("rawtypes")
         TestQuotaAwareAnimator(
-                @Nullable ValueAnimator animator, @NonNull QuotaManager mQuotaManager) {
-            super(animator, mQuotaManager);
+                @NonNull QuotaManager mQuotaManager, @NonNull TypeEvaluator evaluator) {
+            super(mQuotaManager, AnimationSpec.getDefaultInstance(), evaluator);
         }
 
         @Override
diff --git a/wear/protolayout/protolayout-expression-pipeline/src/test/java/androidx/wear/protolayout/expression/pipeline/ColorNodesTest.java b/wear/protolayout/protolayout-expression-pipeline/src/test/java/androidx/wear/protolayout/expression/pipeline/ColorNodesTest.java
index ffdfcca..007ea68 100644
--- a/wear/protolayout/protolayout-expression-pipeline/src/test/java/androidx/wear/protolayout/expression/pipeline/ColorNodesTest.java
+++ b/wear/protolayout/protolayout-expression-pipeline/src/test/java/androidx/wear/protolayout/expression/pipeline/ColorNodesTest.java
@@ -20,6 +20,8 @@
 
 import static org.robolectric.Shadows.shadowOf;
 
+import static java.lang.Integer.MAX_VALUE;
+
 import android.os.Looper;
 
 import androidx.test.ext.junit.runners.AndroidJUnit4;
@@ -139,7 +141,7 @@
     @Test
     public void animatableFixedColor_animates() {
         List<Integer> results = new ArrayList<>();
-        QuotaManager quotaManager = new UnlimitedQuotaManager();
+        QuotaManager quotaManager = new FixedQuotaManagerImpl(MAX_VALUE);
         AnimatableFixedColor protoNode =
                 AnimatableFixedColor.newBuilder()
                         .setFromArgb(FROM_COLOR)
@@ -147,8 +149,7 @@
                         .build();
         AnimatableFixedColorNode node =
                 new AnimatableFixedColorNode(
-                        protoNode, new AddToListCallback<>(results), quotaManager
-                );
+                        protoNode, new AddToListCallback<>(results), quotaManager);
         node.setVisibility(true);
 
         node.init();
@@ -162,17 +163,15 @@
     @Test
     public void animatableFixedColor_whenInvisible_skipsToEnd() {
         List<Integer> results = new ArrayList<>();
-        QuotaManager quotaManager = new UnlimitedQuotaManager();
+        QuotaManager quotaManager = new FixedQuotaManagerImpl(MAX_VALUE);
         AnimatableFixedColor protoNode =
-                AnimatableFixedColor
-                        .newBuilder()
+                AnimatableFixedColor.newBuilder()
                         .setFromArgb(FROM_COLOR)
                         .setToArgb(TO_COLOR)
                         .build();
         AnimatableFixedColorNode node =
                 new AnimatableFixedColorNode(
-                        protoNode, new AddToListCallback<>(results), quotaManager
-                );
+                        protoNode, new AddToListCallback<>(results), quotaManager);
         node.setVisibility(false);
 
         node.init();
@@ -183,24 +182,43 @@
     }
 
     @Test
+    public void animatableFixedColor_whenNoQuota_skipToEnd() {
+        List<Integer> results = new ArrayList<>();
+        QuotaManager quotaManager = new FixedQuotaManagerImpl(0);
+        AnimatableFixedColor protoNode =
+                AnimatableFixedColor.newBuilder()
+                        .setFromArgb(FROM_COLOR)
+                        .setToArgb(TO_COLOR)
+                        .build();
+        AnimatableFixedColorNode node =
+                new AnimatableFixedColorNode(
+                        protoNode, new AddToListCallback<>(results), quotaManager);
+        node.setVisibility(true);
+
+        node.init();
+        shadowOf(Looper.getMainLooper()).idle();
+
+        assertThat(results).hasSize(1);
+        assertThat(results).containsExactly(TO_COLOR);
+    }
+
+    @Test
     public void dynamicAnimatedColor_animatesWithStateChange() {
         List<Integer> results = new ArrayList<>();
-        QuotaManager quotaManager = new UnlimitedQuotaManager();
+        QuotaManager quotaManager = new FixedQuotaManagerImpl(MAX_VALUE);
         ObservableStateStore oss =
                 new ObservableStateStore(
                         ImmutableMap.of(
                                 "foo",
                                 StateEntryValue.newBuilder()
                                         .setColorVal(
-                                                FixedColor.newBuilder().setArgb(FROM_COLOR).build()
-                                        )
+                                                FixedColor.newBuilder().setArgb(FROM_COLOR).build())
                                         .build()));
         DynamicAnimatedColorNode colorNode =
                 new DynamicAnimatedColorNode(
                         new AddToListCallback<>(results),
                         AnimationSpec.getDefaultInstance(),
-                        quotaManager
-                );
+                        quotaManager);
         colorNode.setVisibility(true);
         StateColorSourceNode stateNode =
                 new StateColorSourceNode(
@@ -219,9 +237,9 @@
                                 .build()));
         shadowOf(Looper.getMainLooper()).idle();
 
-        assertThat(results.size()).isGreaterThan(2);
         assertThat(results.get(0)).isEqualTo(FROM_COLOR);
         assertThat(Iterables.getLast(results)).isEqualTo(TO_COLOR);
+        assertThat(results.size()).isGreaterThan(2);
     }
 
     @Test
@@ -230,22 +248,20 @@
         int color2 = TO_COLOR;
         int color3 = 0xFFFFFFFF;
         List<Integer> results = new ArrayList<>();
-        QuotaManager quotaManager = new UnlimitedQuotaManager();
+        QuotaManager quotaManager = new FixedQuotaManagerImpl(MAX_VALUE);
         ObservableStateStore oss =
                 new ObservableStateStore(
                         ImmutableMap.of(
                                 "foo",
                                 StateEntryValue.newBuilder()
                                         .setColorVal(
-                                                FixedColor.newBuilder().setArgb(color1).build()
-                                        )
+                                                FixedColor.newBuilder().setArgb(color1).build())
                                         .build()));
         DynamicAnimatedColorNode colorNode =
                 new DynamicAnimatedColorNode(
                         new AddToListCallback<>(results),
                         AnimationSpec.getDefaultInstance(),
-                        quotaManager
-                );
+                        quotaManager);
         colorNode.setVisibility(false);
         StateColorSourceNode stateNode =
                 new StateColorSourceNode(
@@ -280,9 +296,70 @@
         shadowOf(Looper.getMainLooper()).idle();
 
         // Contains intermediate values besides the initial and last.
-        assertThat(results.size()).isGreaterThan(2);
         assertThat(results.get(0)).isEqualTo(color2);
         assertThat(Iterables.getLast(results)).isEqualTo(color3);
+        assertThat(results.size()).isGreaterThan(2);
+        assertThat(results).isInOrder();
+    }
+
+    @Test
+    public void dynamicAnimatedColor_animate_noQuota_then_withQuota() {
+        int color1 = FROM_COLOR;
+        int color2 = TO_COLOR;
+        int color3 = 0xFFFFFFFF;
+        List<Integer> results = new ArrayList<>();
+        QuotaManager quotaManager = new FixedQuotaManagerImpl(1);
+        ObservableStateStore oss =
+                new ObservableStateStore(
+                        ImmutableMap.of(
+                                "foo",
+                                StateEntryValue.newBuilder()
+                                        .setColorVal(
+                                                FixedColor.newBuilder().setArgb(color1).build())
+                                        .build()));
+        DynamicAnimatedColorNode colorNode =
+                new DynamicAnimatedColorNode(
+                        new AddToListCallback<>(results),
+                        AnimationSpec.getDefaultInstance(),
+                        quotaManager);
+        colorNode.setVisibility(true);
+        // Occupy the only quota
+        quotaManager.tryAcquireQuota(1);
+        StateColorSourceNode stateNode =
+                new StateColorSourceNode(
+                        oss,
+                        StateColorSource.newBuilder().setSourceKey("foo").build(),
+                        colorNode.getInputCallback());
+
+        stateNode.preInit();
+        stateNode.init();
+
+        results.clear();
+        oss.setStateEntryValuesProto(
+                ImmutableMap.of(
+                        "foo",
+                        StateEntryValue.newBuilder()
+                                .setColorVal(FixedColor.newBuilder().setArgb(color2))
+                                .build()));
+        shadowOf(Looper.getMainLooper()).idle();
+
+        assertThat(results).containsExactly(TO_COLOR);
+
+        // Release the only quota
+        quotaManager.releaseQuota(1);
+
+        oss.setStateEntryValuesProto(
+                ImmutableMap.of(
+                        "foo",
+                        StateEntryValue.newBuilder()
+                                .setColorVal(FixedColor.newBuilder().setArgb(color3))
+                                .build()));
+        shadowOf(Looper.getMainLooper()).idle();
+
+        // Contains intermediate values besides the initial and last.
+        assertThat(results.get(0)).isEqualTo(color2);
+        assertThat(Iterables.getLast(results)).isEqualTo(color3);
+        assertThat(results.size()).isGreaterThan(2);
         assertThat(results).isInOrder();
     }
 }
diff --git a/wear/protolayout/protolayout-expression-pipeline/src/test/java/androidx/wear/protolayout/expression/pipeline/DurationNodesTest.java b/wear/protolayout/protolayout-expression-pipeline/src/test/java/androidx/wear/protolayout/expression/pipeline/DurationNodesTest.java
new file mode 100644
index 0000000..de9f979
--- /dev/null
+++ b/wear/protolayout/protolayout-expression-pipeline/src/test/java/androidx/wear/protolayout/expression/pipeline/DurationNodesTest.java
@@ -0,0 +1,48 @@
+/*
+ * 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.protolayout.expression.pipeline;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.wear.protolayout.expression.pipeline.DurationNodes.BetweenInstancesNode;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+
+@RunWith(AndroidJUnit4.class)
+public class DurationNodesTest {
+
+    @Test
+    public void testBetweenDuration() {
+        List<Duration> results = new ArrayList<>();
+
+        Instant firstInstant = Instant.ofEpochSecond(10000L);
+        Instant secondInstant = Instant.ofEpochSecond(12345L);
+
+        BetweenInstancesNode node = new BetweenInstancesNode(new AddToListCallback<>(results));
+        node.getLhsIncomingCallback().onData(firstInstant);
+        node.getRhsIncomingCallback().onData(secondInstant);
+
+        assertThat(results).containsExactly(Duration.between(firstInstant, secondInstant));
+    }
+}
diff --git a/wear/protolayout/protolayout-expression-pipeline/src/test/java/androidx/wear/protolayout/expression/pipeline/DynamicTypeEvaluatorTest.java b/wear/protolayout/protolayout-expression-pipeline/src/test/java/androidx/wear/protolayout/expression/pipeline/DynamicTypeEvaluatorTest.java
index d2ab0198..0bcba4e 100644
--- a/wear/protolayout/protolayout-expression-pipeline/src/test/java/androidx/wear/protolayout/expression/pipeline/DynamicTypeEvaluatorTest.java
+++ b/wear/protolayout/protolayout-expression-pipeline/src/test/java/androidx/wear/protolayout/expression/pipeline/DynamicTypeEvaluatorTest.java
@@ -22,6 +22,8 @@
 
 import static org.robolectric.Shadows.shadowOf;
 
+import static java.lang.Integer.MAX_VALUE;
+
 import android.icu.util.ULocale;
 import android.os.Looper;
 
@@ -58,204 +60,202 @@
     @ParameterizedRobolectricTestRunner.Parameters(name = "{0}")
     public static ImmutableList<Object[]> params() {
         DynamicTypeEvaluatorTest.TestCase<?>[] testCases = {
-                test(constant("hello"), "hello"),
-                test(DynamicString.fromState("state_hello_world"), "hello_world"),
-                test(DynamicInt32.constant(5).format(), "5"),
-                test(DynamicInt32.constant(10), 10),
-                test(DynamicInt32.fromState("state_int_15"), 15),
-                test(DynamicInt32.fromState("state_int_15").plus(DynamicInt32.constant(2)), 17),
-                test(DynamicInt32.fromState("state_int_15").minus(DynamicInt32.constant(5)), 10),
-                test(DynamicInt32.fromState("state_int_15").times(DynamicInt32.constant(2)), 30),
-                test(DynamicInt32.fromState("state_int_15").div(DynamicInt32.constant(3)), 5),
-                test(DynamicInt32.fromState("state_int_15").rem(DynamicInt32.constant(2)), 1),
-                test(DynamicInt32.fromState("state_int_15").plus(2), 17),
-                test(DynamicInt32.fromState("state_int_15").minus(5), 10),
-                test(DynamicInt32.fromState("state_int_15").times(2), 30),
-                test(DynamicInt32.fromState("state_int_15").div(3), 5),
-                test(DynamicInt32.fromState("state_int_15").rem(2), 1),
-                test(DynamicInt32.fromState("state_int_15").plus(2.5f), 17.5f),
-                test(DynamicInt32.fromState("state_int_15").minus(5.5f), 9.5f),
-                test(DynamicInt32.fromState("state_int_15").times(2.5f), 37.5f),
-                test(DynamicInt32.fromState("state_int_15").div(2.0f), 7.5f),
-                test(DynamicInt32.fromState("state_int_15").rem(4.5f), 1.5f),
-                test(DynamicInt32.fromState("state_int_15").plus(DynamicFloat.constant(2.5f)),
-                        17.5f),
-                test(DynamicInt32.fromState("state_int_15").minus(DynamicFloat.constant(5.5f)),
-                        9.5f),
-                test(DynamicInt32.fromState("state_int_15").times(DynamicFloat.constant(2.5f)),
-                        37.5f),
-                test(DynamicInt32.fromState("state_int_15").div(DynamicFloat.constant(2.0f)), 7.5f),
-                test(DynamicInt32.fromState("state_int_15").rem(DynamicFloat.constant(4.5f)), 1.5f),
-                test(DynamicFloat.constant(5.0f), 5.0f),
-                test(DynamicFloat.fromState("state_float_1.5"), 1.5f),
-                test(DynamicFloat.constant(1234.567f).asInt(), 1234),
-                test(DynamicFloat.constant(0.967f).asInt(), 0),
-                test(DynamicFloat.constant(-1234.967f).asInt(), -1235),
-                test(DynamicFloat.constant(-0.967f).asInt(), -1),
-                test(DynamicFloat.constant(Float.MIN_VALUE).asInt(), 0),
-                test(DynamicFloat.constant(Float.MAX_VALUE).asInt(), (int) Float.MAX_VALUE),
-                test(DynamicInt32.constant(100).asFloat(), 100.0f),
-                test(DynamicInt32.constant(Integer.MIN_VALUE).asFloat(),
-                        Float.valueOf(Integer.MIN_VALUE)),
-                test(DynamicInt32.constant(Integer.MAX_VALUE).asFloat(),
-                        Float.valueOf(Integer.MAX_VALUE)),
-                test(DynamicFloat.constant(100f).plus(DynamicFloat.constant(2f)), 102f),
-                test(DynamicFloat.constant(100f).minus(DynamicFloat.constant(5.5f)), 94.5f),
-                test(DynamicFloat.constant(5.5f).times(DynamicFloat.constant(2f)), 11f),
-                test(DynamicFloat.constant(5f).div(DynamicFloat.constant(2f)), 2.5f),
-                test(DynamicFloat.constant(5f).rem(DynamicFloat.constant(2f)), 1f),
-                test(DynamicFloat.constant(100f).plus(2f), 102f),
-                test(DynamicFloat.constant(100f).minus(5.5f), 94.5f),
-                test(DynamicFloat.constant(5.5f).times(2f), 11f),
-                test(DynamicFloat.constant(5f).div(2f), 2.5f),
-                test(DynamicFloat.constant(5f).rem(2f), 1f),
-                test(DynamicFloat.constant(0.12345622f).eq(0.12345688f), true),
-                test(DynamicFloat.constant(0.123455f).ne(0.123457f), true),
-                test(DynamicFloat.constant(0.12345622f).ne(0.12345688f), false),
-                test(DynamicFloat.constant(0.123455f).eq(0.123457f), false),
-                test(DynamicFloat.constant(0.4f).lt(0.6f), true),
-                test(DynamicFloat.constant(0.4f).lt(0.2f), false),
-                test(DynamicFloat.constant(0.1234568f).lt(0.1234562f), false),
-                test(DynamicFloat.constant(0.4f).lte(0.6f), true),
-                test(DynamicFloat.constant(0.1234568f).lte(0.1234562f), true),
-                test(DynamicFloat.constant(0.6f).gt(0.4f), true),
-                test(DynamicFloat.constant(0.4f).gt(0.6f), false),
-                test(DynamicFloat.constant(0.1234568f).gt(0.1234562f), false),
-                test(DynamicFloat.constant(0.6f).gte(0.4f), true),
-                test(DynamicFloat.constant(0.1234568f).gte(0.1234562f), true),
-                test(DynamicBool.constant(true), true),
-                test(DynamicBool.constant(true).isTrue(), true),
-                test(DynamicBool.constant(false).isTrue(), false),
-                test(DynamicBool.constant(true).isFalse(), false),
-                test(DynamicBool.constant(false).isFalse(), true),
-                test(DynamicBool.constant(true).and(DynamicBool.constant(true)), true),
-                test(DynamicBool.constant(true).and(DynamicBool.constant(false)), false),
-                test(DynamicBool.constant(false).and(DynamicBool.constant(true)), false),
-                test(DynamicBool.constant(false).and(DynamicBool.constant(false)), false),
-                test(DynamicBool.constant(true).or(DynamicBool.constant(true)), true),
-                test(DynamicBool.constant(true).or(DynamicBool.constant(false)), true),
-                test(DynamicBool.constant(false).or(DynamicBool.constant(true)), true),
-                test(DynamicBool.constant(false).or(DynamicBool.constant(false)), false),
-                test(DynamicBool.fromState("state_bool_true"), true),
-                test(DynamicBool.constant(false), false),
-                test(DynamicBool.fromState("state_bool_false"), false),
-                test(DynamicInt32.constant(5).eq(DynamicInt32.constant(5)), true),
-                test(DynamicInt32.constant(5).eq(DynamicInt32.constant(6)), false),
-                test(DynamicInt32.constant(5).ne(DynamicInt32.constant(5)), false),
-                test(DynamicInt32.constant(5).ne(DynamicInt32.constant(6)), true),
-                test(DynamicInt32.constant(10).lt(11), true),
-                test(DynamicInt32.constant(10).lt(10), false),
-                test(DynamicInt32.constant(10).lt(5), false),
-                test(DynamicInt32.constant(10).lte(11), true),
-                test(DynamicInt32.constant(10).lte(10), true),
-                test(DynamicInt32.constant(10).lte(5), false),
-                test(DynamicInt32.constant(10).gt(11), false),
-                test(DynamicInt32.constant(10).gt(10), false),
-                test(DynamicInt32.constant(10).gt(5), true),
-                test(DynamicInt32.constant(10).gte(11), false),
-                test(DynamicInt32.constant(10).gte(10), true),
-                test(DynamicInt32.constant(10).gte(5), true),
-                // Instant maximum value
-                test(DynamicInstant.withSecondsPrecision(Instant.MAX),
+            test(constant("hello"), "hello"),
+            test(DynamicString.fromState("state_hello_world"), "hello_world"),
+            test(DynamicInt32.constant(5).format(), "5"),
+            test(DynamicInt32.constant(10), 10),
+            test(DynamicInt32.fromState("state_int_15"), 15),
+            test(DynamicInt32.fromState("state_int_15").plus(DynamicInt32.constant(2)), 17),
+            test(DynamicInt32.fromState("state_int_15").minus(DynamicInt32.constant(5)), 10),
+            test(DynamicInt32.fromState("state_int_15").times(DynamicInt32.constant(2)), 30),
+            test(DynamicInt32.fromState("state_int_15").div(DynamicInt32.constant(3)), 5),
+            test(DynamicInt32.fromState("state_int_15").rem(DynamicInt32.constant(2)), 1),
+            test(DynamicInt32.fromState("state_int_15").plus(2), 17),
+            test(DynamicInt32.fromState("state_int_15").minus(5), 10),
+            test(DynamicInt32.fromState("state_int_15").times(2), 30),
+            test(DynamicInt32.fromState("state_int_15").div(3), 5),
+            test(DynamicInt32.fromState("state_int_15").rem(2), 1),
+            test(DynamicInt32.fromState("state_int_15").plus(2.5f), 17.5f),
+            test(DynamicInt32.fromState("state_int_15").minus(5.5f), 9.5f),
+            test(DynamicInt32.fromState("state_int_15").times(2.5f), 37.5f),
+            test(DynamicInt32.fromState("state_int_15").div(2.0f), 7.5f),
+            test(DynamicInt32.fromState("state_int_15").rem(4.5f), 1.5f),
+            test(DynamicInt32.fromState("state_int_15").plus(DynamicFloat.constant(2.5f)), 17.5f),
+            test(DynamicInt32.fromState("state_int_15").minus(DynamicFloat.constant(5.5f)), 9.5f),
+            test(DynamicInt32.fromState("state_int_15").times(DynamicFloat.constant(2.5f)), 37.5f),
+            test(DynamicInt32.fromState("state_int_15").div(DynamicFloat.constant(2.0f)), 7.5f),
+            test(DynamicInt32.fromState("state_int_15").rem(DynamicFloat.constant(4.5f)), 1.5f),
+            test(DynamicFloat.constant(5.0f), 5.0f),
+            test(DynamicFloat.fromState("state_float_1.5"), 1.5f),
+            test(DynamicFloat.constant(1234.567f).asInt(), 1234),
+            test(DynamicFloat.constant(0.967f).asInt(), 0),
+            test(DynamicFloat.constant(-1234.967f).asInt(), -1235),
+            test(DynamicFloat.constant(-0.967f).asInt(), -1),
+            test(DynamicFloat.constant(Float.MIN_VALUE).asInt(), 0),
+            test(DynamicFloat.constant(Float.MAX_VALUE).asInt(), (int) Float.MAX_VALUE),
+            test(DynamicInt32.constant(100).asFloat(), 100.0f),
+            test(
+                    DynamicInt32.constant(Integer.MIN_VALUE).asFloat(),
+                    Float.valueOf(Integer.MIN_VALUE)),
+            test(
+                    DynamicInt32.constant(Integer.MAX_VALUE).asFloat(),
+                    Float.valueOf(Integer.MAX_VALUE)),
+            test(DynamicFloat.constant(100f).plus(DynamicFloat.constant(2f)), 102f),
+            test(DynamicFloat.constant(100f).minus(DynamicFloat.constant(5.5f)), 94.5f),
+            test(DynamicFloat.constant(5.5f).times(DynamicFloat.constant(2f)), 11f),
+            test(DynamicFloat.constant(5f).div(DynamicFloat.constant(2f)), 2.5f),
+            test(DynamicFloat.constant(5f).rem(DynamicFloat.constant(2f)), 1f),
+            test(DynamicFloat.constant(100f).plus(2f), 102f),
+            test(DynamicFloat.constant(100f).minus(5.5f), 94.5f),
+            test(DynamicFloat.constant(5.5f).times(2f), 11f),
+            test(DynamicFloat.constant(5f).div(2f), 2.5f),
+            test(DynamicFloat.constant(5f).rem(2f), 1f),
+            test(DynamicFloat.constant(0.12345622f).eq(0.12345688f), true),
+            test(DynamicFloat.constant(0.123455f).ne(0.123457f), true),
+            test(DynamicFloat.constant(0.12345622f).ne(0.12345688f), false),
+            test(DynamicFloat.constant(0.123455f).eq(0.123457f), false),
+            test(DynamicFloat.constant(0.4f).lt(0.6f), true),
+            test(DynamicFloat.constant(0.4f).lt(0.2f), false),
+            test(DynamicFloat.constant(0.1234568f).lt(0.1234562f), false),
+            test(DynamicFloat.constant(0.4f).lte(0.6f), true),
+            test(DynamicFloat.constant(0.1234568f).lte(0.1234562f), true),
+            test(DynamicFloat.constant(0.6f).gt(0.4f), true),
+            test(DynamicFloat.constant(0.4f).gt(0.6f), false),
+            test(DynamicFloat.constant(0.1234568f).gt(0.1234562f), false),
+            test(DynamicFloat.constant(0.6f).gte(0.4f), true),
+            test(DynamicFloat.constant(0.1234568f).gte(0.1234562f), true),
+            test(DynamicBool.constant(true), true),
+            test(DynamicBool.constant(true).isTrue(), true),
+            test(DynamicBool.constant(false).isTrue(), false),
+            test(DynamicBool.constant(true).isFalse(), false),
+            test(DynamicBool.constant(false).isFalse(), true),
+            test(DynamicBool.constant(true).and(DynamicBool.constant(true)), true),
+            test(DynamicBool.constant(true).and(DynamicBool.constant(false)), false),
+            test(DynamicBool.constant(false).and(DynamicBool.constant(true)), false),
+            test(DynamicBool.constant(false).and(DynamicBool.constant(false)), false),
+            test(DynamicBool.constant(true).or(DynamicBool.constant(true)), true),
+            test(DynamicBool.constant(true).or(DynamicBool.constant(false)), true),
+            test(DynamicBool.constant(false).or(DynamicBool.constant(true)), true),
+            test(DynamicBool.constant(false).or(DynamicBool.constant(false)), false),
+            test(DynamicBool.fromState("state_bool_true"), true),
+            test(DynamicBool.constant(false), false),
+            test(DynamicBool.fromState("state_bool_false"), false),
+            test(DynamicInt32.constant(5).eq(DynamicInt32.constant(5)), true),
+            test(DynamicInt32.constant(5).eq(DynamicInt32.constant(6)), false),
+            test(DynamicInt32.constant(5).ne(DynamicInt32.constant(5)), false),
+            test(DynamicInt32.constant(5).ne(DynamicInt32.constant(6)), true),
+            test(DynamicInt32.constant(10).lt(11), true),
+            test(DynamicInt32.constant(10).lt(10), false),
+            test(DynamicInt32.constant(10).lt(5), false),
+            test(DynamicInt32.constant(10).lte(11), true),
+            test(DynamicInt32.constant(10).lte(10), true),
+            test(DynamicInt32.constant(10).lte(5), false),
+            test(DynamicInt32.constant(10).gt(11), false),
+            test(DynamicInt32.constant(10).gt(10), false),
+            test(DynamicInt32.constant(10).gt(5), true),
+            test(DynamicInt32.constant(10).gte(11), false),
+            test(DynamicInt32.constant(10).gte(10), true),
+            test(DynamicInt32.constant(10).gte(5), true),
+            // Instant maximum value
+            test(
+                    DynamicInstant.withSecondsPrecision(Instant.MAX),
                     Instant.MAX.truncatedTo(ChronoUnit.SECONDS)),
-                // Duration Int overflow
-                test(
+            // Duration Int overflow
+            test(
                     DynamicInstant.withSecondsPrecision(Instant.EPOCH)
-                        .durationUntil(DynamicInstant.withSecondsPrecision(Instant.MAX))
-                        .toIntSeconds(),
+                            .durationUntil(DynamicInstant.withSecondsPrecision(Instant.MAX))
+                            .toIntSeconds(),
                     (int) Instant.MAX.getEpochSecond()),
-                // Positive duration
-                test(durationOfSeconds(123456L).toIntDays(), 1),
-                test(durationOfSeconds(123456L).toIntHours(), 34),
-                test(durationOfSeconds(123456L).toIntMinutes(), 2057),
-                test(durationOfSeconds(123456L).toIntSeconds(), 123456),
-                test(durationOfSeconds(123456L).getIntDaysPart(), 1),
-                test(durationOfSeconds(123456L).getHoursPart(), 10),
-                test(durationOfSeconds(123456L).getMinutesPart(), 17),
-                test(durationOfSeconds(123456L).getSecondsPart(), 36),
-                // Negative duration
-                test(durationOfSeconds(-123456L).toIntDays(), -1),
-                test(durationOfSeconds(-123456L).toIntHours(), -34),
-                test(durationOfSeconds(-123456L).toIntMinutes(), -2057),
-                test(durationOfSeconds(-123456L).toIntSeconds(), -123456),
-                test(durationOfSeconds(-123456L).getIntDaysPart(), 1),
-                test(durationOfSeconds(-123456L).getHoursPart(), 10),
-                test(durationOfSeconds(-123456L).getMinutesPart(), 17),
-                test(durationOfSeconds(-123456L).getSecondsPart(), 36),
-                test(
-                        DynamicString.onCondition(DynamicBool.constant(true))
-                                .use(constant("Hello"))
-                                .elseUse(constant("World")),
-                        "Hello"),
-                test(
-                        DynamicString.onCondition(DynamicBool.constant(false))
-                                .use(constant("Hello"))
-                                .elseUse(constant("World")),
-                        "World"),
-                test(
-                        DynamicString.fromState("state_hello_world").concat(
-                                DynamicString.constant("_test")),
-                        "hello_world_test"),
-                test(
-                        DynamicString.constant("this ")
-                                .concat(DynamicString.constant("is "))
-                                .concat(DynamicString.constant("a test")),
-                        "this is a test"),
-                test(
-                        DynamicInt32.onCondition(DynamicBool.constant(true))
-                                .use(DynamicInt32.constant(1))
-                                .elseUse(DynamicInt32.constant(10)),
-                        1),
-                test(
-                        DynamicInt32.onCondition(DynamicBool.constant(false))
-                                .use(DynamicInt32.constant(1))
-                                .elseUse(DynamicInt32.constant(10)),
-                        10),
-                test(
-                        DynamicFloat.constant(12.345f)
-                                .format(
-                                        FloatFormatter.with()
-                                                .maxFractionDigits(2)
-                                                .minIntegerDigits(4)
-                                                .groupingUsed(true)),
-                        "0,012.35"),
-                test(
-                        DynamicFloat.constant(12.345f)
-                                .format(
-                                        FloatFormatter.with()
-                                                .minFractionDigits(4)
-                                                .minIntegerDigits(4)
-                                                .groupingUsed(false)),
-                        "0012.3450"),
-                test(
-                        DynamicFloat.constant(12.345f)
-                                .format(FloatFormatter.with().maxFractionDigits(1).groupingUsed(
-                                        true))
-                                .concat(DynamicString.constant("°")),
-                        "12.3°"),
-                test(
-                        DynamicFloat.constant(12.345678f)
-                                .format(
-                                        FloatFormatter.with()
-                                                .minFractionDigits(4)
-                                                .maxFractionDigits(2)
-                                                .groupingUsed(true)),
-                        "12.3457"),
-                test(
-                        DynamicFloat.constant(12.345678f)
-                                .format(FloatFormatter.with().minFractionDigits(2).groupingUsed(
-                                        true)),
-                        "12.346"),
-                test(DynamicFloat.constant(12.3456f).format(FloatFormatter.with()), "12.346"),
-                test(
-                        DynamicInt32.constant(12)
-                                .format(IntFormatter.with().minIntegerDigits(4).groupingUsed(true)),
-                        "0,012"),
-                test(DynamicInt32.constant(12).format(IntFormatter.with()), "12")
+            // Positive duration
+            test(durationOfSeconds(123456L).toIntDays(), 1),
+            test(durationOfSeconds(123456L).toIntHours(), 34),
+            test(durationOfSeconds(123456L).toIntMinutes(), 2057),
+            test(durationOfSeconds(123456L).toIntSeconds(), 123456),
+            test(durationOfSeconds(123456L).getIntDaysPart(), 1),
+            test(durationOfSeconds(123456L).getHoursPart(), 10),
+            test(durationOfSeconds(123456L).getMinutesPart(), 17),
+            test(durationOfSeconds(123456L).getSecondsPart(), 36),
+            // Negative duration
+            test(durationOfSeconds(-123456L).toIntDays(), -1),
+            test(durationOfSeconds(-123456L).toIntHours(), -34),
+            test(durationOfSeconds(-123456L).toIntMinutes(), -2057),
+            test(durationOfSeconds(-123456L).toIntSeconds(), -123456),
+            test(durationOfSeconds(-123456L).getIntDaysPart(), 1),
+            test(durationOfSeconds(-123456L).getHoursPart(), 10),
+            test(durationOfSeconds(-123456L).getMinutesPart(), 17),
+            test(durationOfSeconds(-123456L).getSecondsPart(), 36),
+            test(
+                    DynamicString.onCondition(DynamicBool.constant(true))
+                            .use(constant("Hello"))
+                            .elseUse(constant("World")),
+                    "Hello"),
+            test(
+                    DynamicString.onCondition(DynamicBool.constant(false))
+                            .use(constant("Hello"))
+                            .elseUse(constant("World")),
+                    "World"),
+            test(
+                    DynamicString.fromState("state_hello_world")
+                            .concat(DynamicString.constant("_test")),
+                    "hello_world_test"),
+            test(
+                    DynamicString.constant("this ")
+                            .concat(DynamicString.constant("is "))
+                            .concat(DynamicString.constant("a test")),
+                    "this is a test"),
+            test(
+                    DynamicInt32.onCondition(DynamicBool.constant(true))
+                            .use(DynamicInt32.constant(1))
+                            .elseUse(DynamicInt32.constant(10)),
+                    1),
+            test(
+                    DynamicInt32.onCondition(DynamicBool.constant(false))
+                            .use(DynamicInt32.constant(1))
+                            .elseUse(DynamicInt32.constant(10)),
+                    10),
+            test(
+                    DynamicFloat.constant(12.345f)
+                            .format(
+                                    FloatFormatter.with()
+                                            .maxFractionDigits(2)
+                                            .minIntegerDigits(4)
+                                            .groupingUsed(true)),
+                    "0,012.35"),
+            test(
+                    DynamicFloat.constant(12.345f)
+                            .format(
+                                    FloatFormatter.with()
+                                            .minFractionDigits(4)
+                                            .minIntegerDigits(4)
+                                            .groupingUsed(false)),
+                    "0012.3450"),
+            test(
+                    DynamicFloat.constant(12.345f)
+                            .format(FloatFormatter.with().maxFractionDigits(1).groupingUsed(true))
+                            .concat(DynamicString.constant("°")),
+                    "12.3°"),
+            test(
+                    DynamicFloat.constant(12.345678f)
+                            .format(
+                                    FloatFormatter.with()
+                                            .minFractionDigits(4)
+                                            .maxFractionDigits(2)
+                                            .groupingUsed(true)),
+                    "12.3457"),
+            test(
+                    DynamicFloat.constant(12.345678f)
+                            .format(FloatFormatter.with().minFractionDigits(2).groupingUsed(true)),
+                    "12.346"),
+            test(DynamicFloat.constant(12.3456f).format(FloatFormatter.with()), "12.346"),
+            test(
+                    DynamicInt32.constant(12)
+                            .format(IntFormatter.with().minIntegerDigits(4).groupingUsed(true)),
+                    "0,012"),
+            test(DynamicInt32.constant(12).format(IntFormatter.with()), "12")
         };
         ImmutableList.Builder<Object[]> immutableListBuilder = new ImmutableList.Builder<>();
         for (DynamicTypeEvaluatorTest.TestCase<?> testCase : testCases) {
-            immutableListBuilder.add(new Object[]{testCase});
+            immutableListBuilder.add(new Object[] {testCase});
         }
         return immutableListBuilder.build();
     }
@@ -275,7 +275,7 @@
                         /* platformDataSourcesInitiallyEnabled= */ true,
                         /* sensorGateway= */ null,
                         stateStore,
-                        new UnlimitedQuotaManager());
+                        new FixedQuotaManagerImpl(MAX_VALUE));
 
         mTestCase.runTest(evaluator);
     }
@@ -284,10 +284,10 @@
             DynamicString bindUnderTest, String expectedValue) {
         return new DynamicTypeEvaluatorTest.TestCase<>(
                 bindUnderTest.toDynamicStringProto().toString(),
-                (evaluator, cb) -> {
-                    evaluator.bind(bindUnderTest, ULocale.getDefault(), cb);
-                    evaluator.processPendingBindings();
-                },
+                (evaluator, cb) ->
+                        evaluator.bind(
+                                bindUnderTest, ULocale.getDefault(), new MainThreadExecutor(), cb)
+                                .startEvaluation(),
                 expectedValue);
     }
 
@@ -295,32 +295,28 @@
             DynamicInt32 bindUnderTest, Integer expectedValue) {
         return new DynamicTypeEvaluatorTest.TestCase<>(
                 bindUnderTest.toDynamicInt32Proto().toString(),
-                (evaluator, cb) -> {
-                    evaluator.bind(bindUnderTest, cb);
-                    evaluator.processPendingBindings();
-                },
+                (evaluator, cb) -> evaluator.bind(bindUnderTest, new MainThreadExecutor(), cb)
+                        .startEvaluation(),
                 expectedValue);
     }
 
     private static DynamicTypeEvaluatorTest.TestCase<Instant> test(
-        DynamicInstant bindUnderTest, Instant instant) {
-      return new DynamicTypeEvaluatorTest.TestCase<>(
-          bindUnderTest.toDynamicInstantProto().toString(),
-          (evaluator, cb) -> {
-            evaluator.bind(bindUnderTest, cb);
-            evaluator.processPendingBindings();
-          },
-          instant);
+            DynamicInstant bindUnderTest, Instant instant) {
+        return new DynamicTypeEvaluatorTest.TestCase<>(
+                bindUnderTest.toDynamicInstantProto().toString(),
+                (evaluator, cb) -> {
+                    evaluator.bind(bindUnderTest, new MainThreadExecutor(), cb)
+                            .startEvaluation();
+                },
+                instant);
     }
 
     private static DynamicTypeEvaluatorTest.TestCase<Float> test(
             DynamicFloat bindUnderTest, Float expectedValue) {
         return new DynamicTypeEvaluatorTest.TestCase<>(
                 bindUnderTest.toDynamicFloatProto().toString(),
-                (evaluator, cb) -> {
-                    evaluator.bind(bindUnderTest, cb);
-                    evaluator.processPendingBindings();
-                },
+                (evaluator, cb) -> evaluator.bind(bindUnderTest, new MainThreadExecutor(), cb)
+                        .startEvaluation(),
                 expectedValue);
     }
 
@@ -328,10 +324,8 @@
             DynamicBool bindUnderTest, Boolean expectedValue) {
         return new DynamicTypeEvaluatorTest.TestCase<>(
                 bindUnderTest.toDynamicBoolProto().toString(),
-                (evaluator, cb) -> {
-                    evaluator.bind(bindUnderTest, cb);
-                    evaluator.processPendingBindings();
-                },
+                (evaluator, cb) -> evaluator.bind(bindUnderTest, new MainThreadExecutor(), cb)
+                        .startEvaluation(),
                 expectedValue);
     }
 
@@ -356,8 +350,7 @@
             DynamicTypeValueReceiver<T> callback =
                     new DynamicTypeValueReceiver<T>() {
                         @Override
-                        public void onPreUpdate() {
-                        }
+                        public void onPreUpdate() {}
 
                         @Override
                         public void onData(@NonNull T newData) {
@@ -365,8 +358,7 @@
                         }
 
                         @Override
-                        public void onInvalidated() {
-                        }
+                        public void onInvalidated() {}
                     };
 
             this.mExpressionEvaluator.accept(evaluator, callback);
@@ -376,17 +368,17 @@
             assertThat(results).containsExactly(mExpectedValue);
         }
 
-        @Override
         @NonNull
+        @Override
         public String toString() {
             return mName + " = " + mExpectedValue;
         }
     }
 
     private static DynamicDuration durationOfSeconds(long seconds) {
-      Instant now = Instant.now();
-      return DynamicInstant.withSecondsPrecision(now)
-          .durationUntil(DynamicInstant.withSecondsPrecision(now.plusSeconds(seconds)));
+        Instant now = Instant.now();
+        return DynamicInstant.withSecondsPrecision(now)
+                .durationUntil(DynamicInstant.withSecondsPrecision(now.plusSeconds(seconds)));
     }
 
     private static ImmutableMap<String, StateEntryValue> generateExampleState() {
@@ -396,16 +388,20 @@
                         .setStringVal(FixedString.newBuilder().setValue("hello_world"))
                         .build(),
                 "state_int_15",
-                StateEntryValue.newBuilder().setInt32Val(
-                        FixedInt32.newBuilder().setValue(15)).build(),
+                StateEntryValue.newBuilder()
+                        .setInt32Val(FixedInt32.newBuilder().setValue(15))
+                        .build(),
                 "state_float_1.5",
-                StateEntryValue.newBuilder().setFloatVal(
-                        FixedFloat.newBuilder().setValue(1.5f)).build(),
+                StateEntryValue.newBuilder()
+                        .setFloatVal(FixedFloat.newBuilder().setValue(1.5f))
+                        .build(),
                 "state_bool_true",
-                StateEntryValue.newBuilder().setBoolVal(
-                        FixedBool.newBuilder().setValue(true)).build(),
+                StateEntryValue.newBuilder()
+                        .setBoolVal(FixedBool.newBuilder().setValue(true))
+                        .build(),
                 "state_bool_false",
-                StateEntryValue.newBuilder().setBoolVal(
-                        FixedBool.newBuilder().setValue(false)).build());
+                StateEntryValue.newBuilder()
+                        .setBoolVal(FixedBool.newBuilder().setValue(false))
+                        .build());
     }
 }
diff --git a/wear/protolayout/protolayout-expression-pipeline/src/test/java/androidx/wear/protolayout/expression/pipeline/InstantNodesTest.java b/wear/protolayout/protolayout-expression-pipeline/src/test/java/androidx/wear/protolayout/expression/pipeline/InstantNodesTest.java
new file mode 100644
index 0000000..14da1b7
--- /dev/null
+++ b/wear/protolayout/protolayout-expression-pipeline/src/test/java/androidx/wear/protolayout/expression/pipeline/InstantNodesTest.java
@@ -0,0 +1,91 @@
+/*
+ * 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.protolayout.expression.pipeline;
+
+import static androidx.test.core.app.ApplicationProvider.getApplicationContext;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import androidx.annotation.NonNull;
+import androidx.core.content.ContextCompat;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.wear.protolayout.expression.pipeline.InstantNodes.FixedInstantNode;
+import androidx.wear.protolayout.expression.pipeline.InstantNodes.PlatformTimeSourceNode;
+import androidx.wear.protolayout.expression.proto.FixedProto.FixedInstant;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.Executor;
+
+@RunWith(AndroidJUnit4.class)
+public class InstantNodesTest {
+
+    @Test
+    public void testFixedInstant() {
+        List<Instant> results = new ArrayList<>();
+
+        FixedInstant protoNode = FixedInstant.newBuilder().setEpochSeconds(1234567L).build();
+        FixedInstantNode node = new FixedInstantNode(protoNode, new AddToListCallback<>(results));
+
+        node.init();
+
+        assertThat(results).containsExactly(Instant.ofEpochSecond(1234567L));
+    }
+
+    @Test
+    public void testPlatformTimeSourceNodeDestroy() {
+        FakeTimeGateway fakeTimeGateway = new FakeTimeGateway();
+        EpochTimePlatformDataSource timeSource =
+                new EpochTimePlatformDataSource(
+                        ContextCompat.getMainExecutor(getApplicationContext()), fakeTimeGateway);
+        List<Instant> results = new ArrayList<>();
+
+        PlatformTimeSourceNode node =
+                new PlatformTimeSourceNode(timeSource, new AddToListCallback<>(results));
+        node.preInit();
+        node.init();
+        assertThat(fakeTimeGateway.getNumRegisteredCallbacks()).isEqualTo(1);
+
+        node.destroy();
+        assertThat(fakeTimeGateway.getNumRegisteredCallbacks()).isEqualTo(0);
+    }
+
+    private static class FakeTimeGateway implements TimeGateway {
+        private final Set<TimeCallback> mRegisteredCallbacks = new HashSet<>();
+
+        @Override
+        public void registerForUpdates(
+                @NonNull Executor executor, @NonNull TimeGateway.TimeCallback callback) {
+            mRegisteredCallbacks.add(callback);
+        }
+
+        @Override
+        public void unregisterForUpdates(@NonNull TimeGateway.TimeCallback callback) {
+            mRegisteredCallbacks.remove(callback);
+        }
+
+        public int getNumRegisteredCallbacks() {
+            return mRegisteredCallbacks.size();
+        }
+    }
+}
diff --git a/wear/protolayout/protolayout-expression-pipeline/src/test/java/androidx/wear/protolayout/expression/pipeline/Int32NodesTest.java b/wear/protolayout/protolayout-expression-pipeline/src/test/java/androidx/wear/protolayout/expression/pipeline/Int32NodesTest.java
index ddb9ec7..4f610b2 100644
--- a/wear/protolayout/protolayout-expression-pipeline/src/test/java/androidx/wear/protolayout/expression/pipeline/Int32NodesTest.java
+++ b/wear/protolayout/protolayout-expression-pipeline/src/test/java/androidx/wear/protolayout/expression/pipeline/Int32NodesTest.java
@@ -20,15 +20,20 @@
 
 import static org.robolectric.Shadows.shadowOf;
 
+import static java.lang.Integer.MAX_VALUE;
+
 import android.os.Looper;
 
 import androidx.test.ext.junit.runners.AndroidJUnit4;
 import androidx.wear.protolayout.expression.pipeline.Int32Nodes.AnimatableFixedInt32Node;
 import androidx.wear.protolayout.expression.pipeline.Int32Nodes.DynamicAnimatedInt32Node;
 import androidx.wear.protolayout.expression.pipeline.Int32Nodes.FixedInt32Node;
+import androidx.wear.protolayout.expression.pipeline.Int32Nodes.GetDurationPartOpNode;
 import androidx.wear.protolayout.expression.pipeline.Int32Nodes.StateInt32SourceNode;
 import androidx.wear.protolayout.expression.proto.AnimationParameterProto.AnimationSpec;
 import androidx.wear.protolayout.expression.proto.DynamicProto.AnimatableFixedInt32;
+import androidx.wear.protolayout.expression.proto.DynamicProto.DurationPartType;
+import androidx.wear.protolayout.expression.proto.DynamicProto.GetDurationPartOp;
 import androidx.wear.protolayout.expression.proto.DynamicProto.StateInt32Source;
 import androidx.wear.protolayout.expression.proto.FixedProto.FixedInt32;
 import androidx.wear.protolayout.expression.proto.StateEntryProto.StateEntryValue;
@@ -39,6 +44,7 @@
 import org.junit.Test;
 import org.junit.runner.RunWith;
 
+import java.time.Duration;
 import java.util.ArrayList;
 import java.util.List;
 
@@ -57,6 +63,96 @@
     }
 
     @Test
+    public void testGetDurationPartOpNode_positiveDuration() {
+
+        // Equivalent to 1day and 10h:17m:36s
+        Duration duration = Duration.ofSeconds(123456);
+
+        assertThat(
+                        createGetDurationPartOpNodeAndGetPart(
+                                duration, DurationPartType.DURATION_PART_TYPE_DAYS))
+                .isEqualTo(1);
+        assertThat(
+                        createGetDurationPartOpNodeAndGetPart(
+                                duration, DurationPartType.DURATION_PART_TYPE_HOURS))
+                .isEqualTo(10);
+        assertThat(
+                        createGetDurationPartOpNodeAndGetPart(
+                                duration, DurationPartType.DURATION_PART_TYPE_MINUTES))
+                .isEqualTo(17);
+        assertThat(
+                        createGetDurationPartOpNodeAndGetPart(
+                                duration, DurationPartType.DURATION_PART_TYPE_SECONDS))
+                .isEqualTo(36);
+        assertThat(
+                        createGetDurationPartOpNodeAndGetPart(
+                                duration, DurationPartType.DURATION_PART_TYPE_TOTAL_DAYS))
+                .isEqualTo(1);
+        assertThat(
+                        createGetDurationPartOpNodeAndGetPart(
+                                duration, DurationPartType.DURATION_PART_TYPE_TOTAL_HOURS))
+                .isEqualTo(34);
+        assertThat(
+                        createGetDurationPartOpNodeAndGetPart(
+                                duration, DurationPartType.DURATION_PART_TYPE_TOTAL_MINUTES))
+                .isEqualTo(2057);
+        assertThat(
+                        createGetDurationPartOpNodeAndGetPart(
+                                duration, DurationPartType.DURATION_PART_TYPE_TOTAL_SECONDS))
+                .isEqualTo(123456);
+    }
+
+    @Test
+    public void testGetDurationPartOpNode_negativeDuration() {
+
+        // Equivalent to negative 1day and 10h:17m:36s
+        Duration duration = Duration.ofSeconds(-123456);
+
+        assertThat(
+                        createGetDurationPartOpNodeAndGetPart(
+                                duration, DurationPartType.DURATION_PART_TYPE_DAYS))
+                .isEqualTo(1);
+        assertThat(
+                        createGetDurationPartOpNodeAndGetPart(
+                                duration, DurationPartType.DURATION_PART_TYPE_HOURS))
+                .isEqualTo(10);
+        assertThat(
+                        createGetDurationPartOpNodeAndGetPart(
+                                duration, DurationPartType.DURATION_PART_TYPE_MINUTES))
+                .isEqualTo(17);
+        assertThat(
+                        createGetDurationPartOpNodeAndGetPart(
+                                duration, DurationPartType.DURATION_PART_TYPE_SECONDS))
+                .isEqualTo(36);
+        assertThat(
+                        createGetDurationPartOpNodeAndGetPart(
+                                duration, DurationPartType.DURATION_PART_TYPE_TOTAL_DAYS))
+                .isEqualTo(-1);
+        assertThat(
+                        createGetDurationPartOpNodeAndGetPart(
+                                duration, DurationPartType.DURATION_PART_TYPE_TOTAL_HOURS))
+                .isEqualTo(-34);
+        assertThat(
+                        createGetDurationPartOpNodeAndGetPart(
+                                duration, DurationPartType.DURATION_PART_TYPE_TOTAL_MINUTES))
+                .isEqualTo(-2057);
+        assertThat(
+                        createGetDurationPartOpNodeAndGetPart(
+                                duration, DurationPartType.DURATION_PART_TYPE_TOTAL_SECONDS))
+                .isEqualTo(-123456);
+    }
+
+    private int createGetDurationPartOpNodeAndGetPart(Duration duration, DurationPartType part) {
+        List<Integer> results = new ArrayList<>();
+        GetDurationPartOpNode node =
+                new GetDurationPartOpNode(
+                        GetDurationPartOp.newBuilder().setDurationPart(part).build(),
+                        new AddToListCallback<>(results));
+        node.getIncomingCallback().onData(duration);
+        return results.get(0);
+    }
+
+    @Test
     public void stateInt32NodeTest() {
         List<Integer> results = new ArrayList<>();
         ObservableStateStore oss =
@@ -112,13 +208,15 @@
         int startValue = 3;
         int endValue = 33;
         List<Integer> results = new ArrayList<>();
-        QuotaManager quotaManager = new UnlimitedQuotaManager();
+        QuotaManager quotaManager = new FixedQuotaManagerImpl(MAX_VALUE);
         AnimatableFixedInt32 protoNode =
-                AnimatableFixedInt32.newBuilder().setFromValue(startValue).setToValue(
-                        endValue).build();
+                AnimatableFixedInt32.newBuilder()
+                        .setFromValue(startValue)
+                        .setToValue(endValue)
+                        .build();
         AnimatableFixedInt32Node node =
-                new AnimatableFixedInt32Node(protoNode, new AddToListCallback<>(results),
-                        quotaManager);
+                new AnimatableFixedInt32Node(
+                        protoNode, new AddToListCallback<>(results), quotaManager);
         node.setVisibility(true);
 
         node.init();
@@ -134,13 +232,15 @@
         int startValue = 3;
         int endValue = 33;
         List<Integer> results = new ArrayList<>();
-        QuotaManager quotaManager = new UnlimitedQuotaManager();
+        QuotaManager quotaManager = new FixedQuotaManagerImpl(MAX_VALUE);
         AnimatableFixedInt32 protoNode =
-                AnimatableFixedInt32.newBuilder().setFromValue(startValue).setToValue(
-                        endValue).build();
+                AnimatableFixedInt32.newBuilder()
+                        .setFromValue(startValue)
+                        .setToValue(endValue)
+                        .build();
         AnimatableFixedInt32Node node =
-                new AnimatableFixedInt32Node(protoNode, new AddToListCallback<>(results),
-                        quotaManager);
+                new AnimatableFixedInt32Node(
+                        protoNode, new AddToListCallback<>(results), quotaManager);
         node.setVisibility(false);
 
         node.init();
@@ -155,13 +255,15 @@
         int startValue = 3;
         int endValue = 33;
         List<Integer> results = new ArrayList<>();
-        QuotaManager quotaManager = new TestNoQuotaManagerImpl();
+        QuotaManager quotaManager = new FixedQuotaManagerImpl(0);
         AnimatableFixedInt32 protoNode =
-                AnimatableFixedInt32.newBuilder().setFromValue(startValue).setToValue(
-                        endValue).build();
+                AnimatableFixedInt32.newBuilder()
+                        .setFromValue(startValue)
+                        .setToValue(endValue)
+                        .build();
         AnimatableFixedInt32Node node =
-                new AnimatableFixedInt32Node(protoNode, new AddToListCallback<>(results),
-                        quotaManager);
+                new AnimatableFixedInt32Node(
+                        protoNode, new AddToListCallback<>(results), quotaManager);
         node.setVisibility(true);
 
         node.init();
@@ -177,7 +279,7 @@
         int value2 = 11;
         int value3 = 17;
         List<Integer> results = new ArrayList<>();
-        QuotaManager quotaManager = new UnlimitedQuotaManager();
+        QuotaManager quotaManager = new FixedQuotaManagerImpl(MAX_VALUE);
         ObservableStateStore oss =
                 new ObservableStateStore(
                         ImmutableMap.of(
@@ -188,7 +290,8 @@
                                         .build()));
         DynamicAnimatedInt32Node int32Node =
                 new DynamicAnimatedInt32Node(
-                        new AddToListCallback<>(results), AnimationSpec.getDefaultInstance(),
+                        new AddToListCallback<>(results),
+                        AnimationSpec.getDefaultInstance(),
                         quotaManager);
         int32Node.setVisibility(false);
         StateInt32SourceNode stateNode =
diff --git a/wear/protolayout/protolayout-expression-pipeline/src/test/java/androidx/wear/protolayout/expression/pipeline/ObservableStateStoreTest.java b/wear/protolayout/protolayout-expression-pipeline/src/test/java/androidx/wear/protolayout/expression/pipeline/ObservableStateStoreTest.java
index e6612d6..defecdd 100644
--- a/wear/protolayout/protolayout-expression-pipeline/src/test/java/androidx/wear/protolayout/expression/pipeline/ObservableStateStoreTest.java
+++ b/wear/protolayout/protolayout-expression-pipeline/src/test/java/androidx/wear/protolayout/expression/pipeline/ObservableStateStoreTest.java
@@ -53,8 +53,7 @@
         mStateStoreUnderTest.setStateEntryValues(
                 ImmutableMap.of("foo", StateEntryBuilders.StateEntryValue.fromString("baz")));
 
-        mExpect
-                .that(mStateStoreUnderTest.getStateEntryValuesProto("foo"))
+        mExpect.that(mStateStoreUnderTest.getStateEntryValuesProto("foo"))
                 .isEqualTo(buildStateEntry("baz"));
     }
 
@@ -62,8 +61,7 @@
     public void canReadInitialState() {
         mExpect.that(mStateStoreUnderTest.getStateEntryValuesProto("foo"))
                 .isEqualTo(buildStateEntry("bar"));
-        mExpect
-                .that(mStateStoreUnderTest.getStateEntryValuesProto("baz"))
+        mExpect.that(mStateStoreUnderTest.getStateEntryValuesProto("baz"))
                 .isEqualTo(buildStateEntry("foobar"));
     }
 
@@ -81,8 +79,7 @@
 
         mExpect.that(mStateStoreUnderTest.getStateEntryValuesProto("foo"))
                 .isEqualTo(buildStateEntry("test"));
-        mExpect
-                .that(mStateStoreUnderTest.getStateEntryValuesProto("newKey"))
+        mExpect.that(mStateStoreUnderTest.getStateEntryValuesProto("newKey"))
                 .isEqualTo(buildStateEntry("testNewKey"));
 
         // This should have been cleared...
@@ -142,8 +139,10 @@
     public void removeStateFiresInvalidated() {
         mStateStoreUnderTest.setStateEntryValuesProto(
                 ImmutableMap.of(
-                        "invalidated", buildStateEntry("value"),
-                        "notInvalidated", buildStateEntry("value")));
+                        "invalidated",
+                        buildStateEntry("value"),
+                        "notInvalidated",
+                        buildStateEntry("value")));
         DynamicTypeValueReceiver<StateEntryValue> invalidated = buildStateUpdateCallbackMock();
         DynamicTypeValueReceiver<StateEntryValue> notInvalidated = buildStateUpdateCallbackMock();
         mStateStoreUnderTest.registerCallback("invalidated", invalidated);
@@ -171,8 +170,7 @@
         mStateStoreUnderTest.unregisterCallback("foo", cb);
 
         mStateStoreUnderTest.setStateEntryValuesProto(
-                ImmutableMap.of("foo", buildStateEntry("testAgain"))
-        );
+                ImmutableMap.of("foo", buildStateEntry("testAgain")));
 
         verifyNoInteractions(cb);
     }
diff --git a/wear/protolayout/protolayout-expression-pipeline/src/test/java/androidx/wear/protolayout/expression/pipeline/StringNodesTest.java b/wear/protolayout/protolayout-expression-pipeline/src/test/java/androidx/wear/protolayout/expression/pipeline/StringNodesTest.java
index 1883c8e..7166fdd 100644
--- a/wear/protolayout/protolayout-expression-pipeline/src/test/java/androidx/wear/protolayout/expression/pipeline/StringNodesTest.java
+++ b/wear/protolayout/protolayout-expression-pipeline/src/test/java/androidx/wear/protolayout/expression/pipeline/StringNodesTest.java
@@ -61,8 +61,7 @@
                                 .setGroupingUsed(true)
                                 .setMinIntegerDigits(4)
                                 .build(),
-                        ULocale.UK
-                );
+                        ULocale.UK);
         Int32FormatNode node = new Int32FormatNode(formatter, new AddToListCallback<>(results));
         node.getIncomingCallback().onPreUpdate();
         node.getIncomingCallback().onData(32);
diff --git a/wear/protolayout/protolayout-expression-pipeline/src/test/java/androidx/wear/protolayout/expression/pipeline/TestNoQuotaManagerImpl.java b/wear/protolayout/protolayout-expression-pipeline/src/test/java/androidx/wear/protolayout/expression/pipeline/TestNoQuotaManagerImpl.java
deleted file mode 100644
index 520e2cdc..0000000
--- a/wear/protolayout/protolayout-expression-pipeline/src/test/java/androidx/wear/protolayout/expression/pipeline/TestNoQuotaManagerImpl.java
+++ /dev/null
@@ -1,29 +0,0 @@
-/*
- * Copyright 2023 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package androidx.wear.protolayout.expression.pipeline;
-
-/** QuotaManager that doesn't allow any quota. */
-public class TestNoQuotaManagerImpl implements QuotaManager {
-
-    @Override
-    public boolean tryAcquireQuota(int quota) {
-        return false;
-    }
-
-    @Override
-    public void releaseQuota(int quota) {}
-}
diff --git a/wear/protolayout/protolayout-expression-pipeline/src/test/java/androidx/wear/protolayout/expression/pipeline/TimeGatewayImplTest.java b/wear/protolayout/protolayout-expression-pipeline/src/test/java/androidx/wear/protolayout/expression/pipeline/TimeGatewayImplTest.java
index c1384ae..943f1a3 100644
--- a/wear/protolayout/protolayout-expression-pipeline/src/test/java/androidx/wear/protolayout/expression/pipeline/TimeGatewayImplTest.java
+++ b/wear/protolayout/protolayout-expression-pipeline/src/test/java/androidx/wear/protolayout/expression/pipeline/TimeGatewayImplTest.java
@@ -143,15 +143,14 @@
 
     @Test
     public void missedUpdate_schedulesAgainInFuture() throws Exception {
-        // This test is a tad fragile, and needs to know about the implementation details of
-        // Looper and ShadowLooper. Looper will call SystemClock.uptimeMillis internally to see what
-        // is schedulable. ShadowLooper also uses this in idleFor; it will do something similar
-        // to runFor; find the next time, advance SystemClock.setCurrentTimeMillis by that
-        // amount, call idle(), then keep going. Note though that it pulls the current time via
-        // SystemClock.uptimeMillis, but sets the current time via SystemClock
-        // .setCurrentTimeMillis. It appears that to Robolectric, the two are aliased (and
-        // getting the current time using System.getCurrentTimeMillis() gets the **actual**
-        // current time).
+        // This test is a tad fragile, and needs to know about the implementation details of Looper
+        // and ShadowLooper. Looper will call SystemClock.uptimeMillis internally to see what is
+        // schedulable. ShadowLooper also uses this in idleFor; it will do something similar to
+        // runFor; find the next time, advance SystemClock.setCurrentTimeMillis by that amount, call
+        // idle(), then keep going. Note though that it pulls the current time via
+        // SystemClock.uptimeMillis, but sets the current time via SystemClock.setCurrentTimeMillis.
+        // It appears that to Robolectric, the two are aliased (and getting the current time using
+        // System.getCurrentTimeMillis() gets the **actual** current time).
         //
         // This means that we can fake this behaviour to simulate a "missed" call; just advance the
         // system clock, then call ShadowLooper#idle() to trigger any tasks that should have been
diff --git a/wear/protolayout/protolayout-expression-pipeline/src/test/java/androidx/wear/protolayout/expression/pipeline/UnlimitedQuotaManager.java b/wear/protolayout/protolayout-expression-pipeline/src/test/java/androidx/wear/protolayout/expression/pipeline/UnlimitedQuotaManager.java
deleted file mode 100644
index e4c814a..0000000
--- a/wear/protolayout/protolayout-expression-pipeline/src/test/java/androidx/wear/protolayout/expression/pipeline/UnlimitedQuotaManager.java
+++ /dev/null
@@ -1,46 +0,0 @@
-/*
- * Copyright 2023 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package androidx.wear.protolayout.expression.pipeline;
-
-/** Default, unlimited quota manager implementation that always returns true. */
-public class UnlimitedQuotaManager implements QuotaManager {
-    private int mQuotaCounter = 0;
-
-    /**
-     * @see QuotaManager#tryAcquireQuota
-     *     <p>Note that this method is not thread safe.
-     */
-    @Override
-    public boolean tryAcquireQuota(int quota) {
-        mQuotaCounter += quota;
-        return true;
-    }
-
-    /**
-     * @see QuotaManager#releaseQuota
-     *     <p>Note that this method is not thread safe.
-     */
-    @Override
-    public void releaseQuota(int quota) {
-        mQuotaCounter -= quota;
-    }
-
-    /** Returns true if all quota has been released. */
-    public boolean isAllQuotaReleased() {
-        return mQuotaCounter == 0;
-    }
-}
diff --git a/wear/protolayout/protolayout-expression/api/current.txt b/wear/protolayout/protolayout-expression/api/current.txt
index 1e3c3ea..541f553 100644
--- a/wear/protolayout/protolayout-expression/api/current.txt
+++ b/wear/protolayout/protolayout-expression/api/current.txt
@@ -8,20 +8,20 @@
   }
 
   public static final class AnimationParameterBuilders.AnimationSpec {
-    method public int getDelayMillis();
     method public int getDurationMillis();
     method public androidx.wear.protolayout.expression.AnimationParameterBuilders.Easing? getEasing();
     method public androidx.wear.protolayout.expression.AnimationParameterBuilders.Repeatable? getRepeatable();
+    method public int getStartDelayMillis();
   }
 
   public static final class AnimationParameterBuilders.AnimationSpec.Builder {
     ctor public AnimationParameterBuilders.AnimationSpec.Builder();
     method public androidx.wear.protolayout.expression.AnimationParameterBuilders.AnimationSpec build();
-    method public androidx.wear.protolayout.expression.AnimationParameterBuilders.AnimationSpec.Builder setDelayMillis(int);
     method public androidx.wear.protolayout.expression.AnimationParameterBuilders.AnimationSpec.Builder setDurationMillis(int);
     method public androidx.wear.protolayout.expression.AnimationParameterBuilders.AnimationSpec.Builder setEasing(androidx.wear.protolayout.expression.AnimationParameterBuilders.Easing);
     method public androidx.wear.protolayout.expression.AnimationParameterBuilders.AnimationSpec.Builder setInfiniteRepeatable(int);
     method public androidx.wear.protolayout.expression.AnimationParameterBuilders.AnimationSpec.Builder setRepeatable(androidx.wear.protolayout.expression.AnimationParameterBuilders.Repeatable);
+    method public androidx.wear.protolayout.expression.AnimationParameterBuilders.AnimationSpec.Builder setStartDelayMillis(int);
   }
 
   public static final class AnimationParameterBuilders.CubicBezierEasing implements androidx.wear.protolayout.expression.AnimationParameterBuilders.Easing {
@@ -52,16 +52,20 @@
   }
 
   public static final class AnimationParameterBuilders.Repeatable {
+    method public int getForwardRepeatDelayMillis();
     method public int getIterations();
     method public int getRepeatMode();
+    method public int getReverseRepeatDelayMillis();
     method public boolean hasInfiniteIteration();
   }
 
   public static final class AnimationParameterBuilders.Repeatable.Builder {
     ctor public AnimationParameterBuilders.Repeatable.Builder();
     method public androidx.wear.protolayout.expression.AnimationParameterBuilders.Repeatable build();
+    method public androidx.wear.protolayout.expression.AnimationParameterBuilders.Repeatable.Builder setForwardRepeatDelayMillis(int);
     method public androidx.wear.protolayout.expression.AnimationParameterBuilders.Repeatable.Builder setIterations(@IntRange(from=1) int);
     method public androidx.wear.protolayout.expression.AnimationParameterBuilders.Repeatable.Builder setRepeatMode(int);
+    method public androidx.wear.protolayout.expression.AnimationParameterBuilders.Repeatable.Builder setReverseRepeatDelayMillis(int);
   }
 
   public class ConditionScopes {
diff --git a/wear/protolayout/protolayout-expression/api/public_plus_experimental_current.txt b/wear/protolayout/protolayout-expression/api/public_plus_experimental_current.txt
index 3c2d79b..b67c387 100644
--- a/wear/protolayout/protolayout-expression/api/public_plus_experimental_current.txt
+++ b/wear/protolayout/protolayout-expression/api/public_plus_experimental_current.txt
@@ -8,20 +8,20 @@
   }
 
   public static final class AnimationParameterBuilders.AnimationSpec {
-    method public int getDelayMillis();
     method public int getDurationMillis();
     method public androidx.wear.protolayout.expression.AnimationParameterBuilders.Easing? getEasing();
     method public androidx.wear.protolayout.expression.AnimationParameterBuilders.Repeatable? getRepeatable();
+    method public int getStartDelayMillis();
   }
 
   public static final class AnimationParameterBuilders.AnimationSpec.Builder {
     ctor public AnimationParameterBuilders.AnimationSpec.Builder();
     method public androidx.wear.protolayout.expression.AnimationParameterBuilders.AnimationSpec build();
-    method public androidx.wear.protolayout.expression.AnimationParameterBuilders.AnimationSpec.Builder setDelayMillis(int);
     method public androidx.wear.protolayout.expression.AnimationParameterBuilders.AnimationSpec.Builder setDurationMillis(int);
     method public androidx.wear.protolayout.expression.AnimationParameterBuilders.AnimationSpec.Builder setEasing(androidx.wear.protolayout.expression.AnimationParameterBuilders.Easing);
     method public androidx.wear.protolayout.expression.AnimationParameterBuilders.AnimationSpec.Builder setInfiniteRepeatable(int);
     method public androidx.wear.protolayout.expression.AnimationParameterBuilders.AnimationSpec.Builder setRepeatable(androidx.wear.protolayout.expression.AnimationParameterBuilders.Repeatable);
+    method public androidx.wear.protolayout.expression.AnimationParameterBuilders.AnimationSpec.Builder setStartDelayMillis(int);
   }
 
   public static final class AnimationParameterBuilders.CubicBezierEasing implements androidx.wear.protolayout.expression.AnimationParameterBuilders.Easing {
@@ -52,16 +52,20 @@
   }
 
   public static final class AnimationParameterBuilders.Repeatable {
+    method public int getForwardRepeatDelayMillis();
     method public int getIterations();
     method public int getRepeatMode();
+    method public int getReverseRepeatDelayMillis();
     method public boolean hasInfiniteIteration();
   }
 
   public static final class AnimationParameterBuilders.Repeatable.Builder {
     ctor public AnimationParameterBuilders.Repeatable.Builder();
     method public androidx.wear.protolayout.expression.AnimationParameterBuilders.Repeatable build();
+    method public androidx.wear.protolayout.expression.AnimationParameterBuilders.Repeatable.Builder setForwardRepeatDelayMillis(int);
     method public androidx.wear.protolayout.expression.AnimationParameterBuilders.Repeatable.Builder setIterations(@IntRange(from=1) int);
     method public androidx.wear.protolayout.expression.AnimationParameterBuilders.Repeatable.Builder setRepeatMode(int);
+    method public androidx.wear.protolayout.expression.AnimationParameterBuilders.Repeatable.Builder setReverseRepeatDelayMillis(int);
   }
 
   public class ConditionScopes {
diff --git a/wear/protolayout/protolayout-expression/api/restricted_current.txt b/wear/protolayout/protolayout-expression/api/restricted_current.txt
index 1e3c3ea..541f553 100644
--- a/wear/protolayout/protolayout-expression/api/restricted_current.txt
+++ b/wear/protolayout/protolayout-expression/api/restricted_current.txt
@@ -8,20 +8,20 @@
   }
 
   public static final class AnimationParameterBuilders.AnimationSpec {
-    method public int getDelayMillis();
     method public int getDurationMillis();
     method public androidx.wear.protolayout.expression.AnimationParameterBuilders.Easing? getEasing();
     method public androidx.wear.protolayout.expression.AnimationParameterBuilders.Repeatable? getRepeatable();
+    method public int getStartDelayMillis();
   }
 
   public static final class AnimationParameterBuilders.AnimationSpec.Builder {
     ctor public AnimationParameterBuilders.AnimationSpec.Builder();
     method public androidx.wear.protolayout.expression.AnimationParameterBuilders.AnimationSpec build();
-    method public androidx.wear.protolayout.expression.AnimationParameterBuilders.AnimationSpec.Builder setDelayMillis(int);
     method public androidx.wear.protolayout.expression.AnimationParameterBuilders.AnimationSpec.Builder setDurationMillis(int);
     method public androidx.wear.protolayout.expression.AnimationParameterBuilders.AnimationSpec.Builder setEasing(androidx.wear.protolayout.expression.AnimationParameterBuilders.Easing);
     method public androidx.wear.protolayout.expression.AnimationParameterBuilders.AnimationSpec.Builder setInfiniteRepeatable(int);
     method public androidx.wear.protolayout.expression.AnimationParameterBuilders.AnimationSpec.Builder setRepeatable(androidx.wear.protolayout.expression.AnimationParameterBuilders.Repeatable);
+    method public androidx.wear.protolayout.expression.AnimationParameterBuilders.AnimationSpec.Builder setStartDelayMillis(int);
   }
 
   public static final class AnimationParameterBuilders.CubicBezierEasing implements androidx.wear.protolayout.expression.AnimationParameterBuilders.Easing {
@@ -52,16 +52,20 @@
   }
 
   public static final class AnimationParameterBuilders.Repeatable {
+    method public int getForwardRepeatDelayMillis();
     method public int getIterations();
     method public int getRepeatMode();
+    method public int getReverseRepeatDelayMillis();
     method public boolean hasInfiniteIteration();
   }
 
   public static final class AnimationParameterBuilders.Repeatable.Builder {
     ctor public AnimationParameterBuilders.Repeatable.Builder();
     method public androidx.wear.protolayout.expression.AnimationParameterBuilders.Repeatable build();
+    method public androidx.wear.protolayout.expression.AnimationParameterBuilders.Repeatable.Builder setForwardRepeatDelayMillis(int);
     method public androidx.wear.protolayout.expression.AnimationParameterBuilders.Repeatable.Builder setIterations(@IntRange(from=1) int);
     method public androidx.wear.protolayout.expression.AnimationParameterBuilders.Repeatable.Builder setRepeatMode(int);
+    method public androidx.wear.protolayout.expression.AnimationParameterBuilders.Repeatable.Builder setReverseRepeatDelayMillis(int);
   }
 
   public class ConditionScopes {
diff --git a/wear/protolayout/protolayout-expression/src/main/java/androidx/wear/protolayout/expression/AnimationParameterBuilders.java b/wear/protolayout/protolayout-expression/src/main/java/androidx/wear/protolayout/expression/AnimationParameterBuilders.java
index 9b6c389..2259597 100644
--- a/wear/protolayout/protolayout-expression/src/main/java/androidx/wear/protolayout/expression/AnimationParameterBuilders.java
+++ b/wear/protolayout/protolayout-expression/src/main/java/androidx/wear/protolayout/expression/AnimationParameterBuilders.java
@@ -27,6 +27,7 @@
 import androidx.wear.protolayout.expression.proto.AnimationParameterProto;
 import androidx.wear.protolayout.protobuf.ExtensionRegistryLite;
 import androidx.wear.protolayout.protobuf.InvalidProtocolBufferException;
+
 import java.lang.annotation.Retention;
 import java.lang.annotation.RetentionPolicy;
 
@@ -58,7 +59,7 @@
 
     /**
      * Incoming elements are animated using deceleration easing, which starts a transition at peak
-     * velocity (the fastest point of an element's movement) and ends at rest.
+     * velocity (the fastest point of an element’s movement) and ends at rest.
      *
      * <p>This is equivalent to the Compose {@code LinearOutSlowInEasing}.
      */
@@ -137,8 +138,8 @@
      *
      * @since 1.2
      */
-    public int getDelayMillis() {
-      return mImpl.getDelayMillis();
+    public int getStartDelayMillis() {
+      return mImpl.getStartDelayMillis();
     }
 
     /**
@@ -157,7 +158,6 @@
 
     /**
      * Gets the repeatable mode to be used for specifying repetition parameters for the animation.
-     * If not set, animation won't be repeated.
      *
      * @since 1.2
      */
@@ -182,6 +182,18 @@
     }
 
     /**
+     * Creates a new wrapper instance from the proto.
+     *
+     * @hide
+     */
+    @RestrictTo(Scope.LIBRARY_GROUP)
+    @NonNull
+    public static AnimationSpec fromProto(
+        @NonNull AnimationParameterProto.AnimationSpec proto, @Nullable Fingerprint fingerprint) {
+      return new AnimationSpec(proto, fingerprint);
+    }
+
+    /**
      * Creates a new wrapper instance from the proto. Intended for testing purposes only. An object
      * created using this method can't be added to any other wrapper.
      *
@@ -190,7 +202,7 @@
     @RestrictTo(Scope.LIBRARY_GROUP)
     @NonNull
     public static AnimationSpec fromProto(@NonNull AnimationParameterProto.AnimationSpec proto) {
-      return new AnimationSpec(proto, null);
+      return fromProto(proto, null);
     }
 
     /**
@@ -210,8 +222,8 @@
       return "AnimationSpec{"
           + "durationMillis="
           + getDurationMillis()
-          + ", delayMillis="
-          + getDelayMillis()
+          + ", startDelayMillis="
+          + getStartDelayMillis()
           + ", easing="
           + getEasing()
           + ", repeatable="
@@ -245,9 +257,9 @@
        * @since 1.2
        */
       @NonNull
-      public Builder setDelayMillis(int delayMillis) {
-        mImpl.setDelayMillis(delayMillis);
-        mFingerprint.recordPropertyUpdate(2, delayMillis);
+      public Builder setStartDelayMillis(int startDelayMillis) {
+        mImpl.setStartDelayMillis(startDelayMillis);
+        mFingerprint.recordPropertyUpdate(2, startDelayMillis);
         return this;
       }
 
@@ -284,7 +296,7 @@
       @SuppressWarnings("MissingGetterMatchingBuilder")
       public Builder setInfiniteRepeatable(@RepeatMode int mode) {
         Repeatable repeatable =
-                new Repeatable.Builder().setRepeatMode(mode).build();
+            new Repeatable.Builder().setRepeatMode(mode).build();
         return this.setRepeatable(repeatable);
       }
 
@@ -318,8 +330,8 @@
     static Easing fromByteArray(@NonNull byte[] byteArray) {
       try {
         return easingFromProto(
-            AnimationParameterProto.Easing.parseFrom(
-                byteArray, ExtensionRegistryLite.getEmptyRegistry()));
+                AnimationParameterProto.Easing.parseFrom(
+                        byteArray, ExtensionRegistryLite.getEmptyRegistry()));
       } catch (InvalidProtocolBufferException e) {
         throw new IllegalArgumentException("Byte array could not be parsed into Easing", e);
       }
@@ -354,14 +366,26 @@
     }
   }
 
+  /**
+   * Creates a new wrapper instance from the proto.
+   *
+   * @hide
+   */
+  @RestrictTo(Scope.LIBRARY_GROUP)
   @NonNull
-  static Easing easingFromProto(@NonNull AnimationParameterProto.Easing proto) {
+  public static Easing easingFromProto(
+      @NonNull AnimationParameterProto.Easing proto, @Nullable Fingerprint fingerprint) {
     if (proto.hasCubicBezier()) {
-      return CubicBezierEasing.fromProto(proto.getCubicBezier());
+      return CubicBezierEasing.fromProto(proto.getCubicBezier(), fingerprint);
     }
     throw new IllegalStateException("Proto was not a recognised instance of Easing");
   }
 
+  @NonNull
+  static Easing easingFromProto(@NonNull AnimationParameterProto.Easing proto) {
+    return easingFromProto(proto, null);
+  }
+
   /**
    * The cubic polynomial easing that implements third-order Bezier curves. This is equivalent to
    * the Android PathInterpolator.
@@ -426,12 +450,31 @@
       return mFingerprint;
     }
 
+    /**
+     * Creates a new wrapper instance from the proto.
+     *
+     * @hide
+     */
+    @RestrictTo(Scope.LIBRARY_GROUP)
     @NonNull
-    static CubicBezierEasing fromProto(@NonNull AnimationParameterProto.CubicBezierEasing proto) {
-      return new CubicBezierEasing(proto, null);
+    public static CubicBezierEasing fromProto(
+        @NonNull AnimationParameterProto.CubicBezierEasing proto,
+        @Nullable Fingerprint fingerprint) {
+      return new CubicBezierEasing(proto, fingerprint);
     }
 
     @NonNull
+    static CubicBezierEasing fromProto(@NonNull AnimationParameterProto.CubicBezierEasing proto) {
+      return fromProto(proto, null);
+    }
+
+    /**
+     * Returns the internal proto instance.
+     *
+     * @hide
+     */
+    @RestrictTo(Scope.LIBRARY_GROUP)
+    @NonNull
     AnimationParameterProto.CubicBezierEasing toProto() {
       return mImpl;
     }
@@ -569,6 +612,24 @@
     }
 
     /**
+     * Gets the delay before the forward part of the repeat in milliseconds.
+     *
+     * @since 1.2
+     */
+    public int getForwardRepeatDelayMillis() {
+      return mImpl.getForwardRepeatDelayMillis();
+    }
+
+    /**
+     * Gets the delay before the reverse part of repeat in milliseconds.
+     *
+     * @since 1.2
+     */
+    public int getReverseRepeatDelayMillis() {
+      return mImpl.getReverseRepeatDelayMillis();
+    }
+
+    /**
      * Get the fingerprint for this object, or null if unknown.
      *
      * @hide
@@ -579,13 +640,31 @@
       return mFingerprint;
     }
 
+    /**
+     * Creates a new wrapper instance from the proto.
+     *
+     * @hide
+     */
+    @RestrictTo(Scope.LIBRARY_GROUP)
     @NonNull
-    static Repeatable fromProto(@NonNull AnimationParameterProto.Repeatable proto) {
-      return new Repeatable(proto, null);
+    public static Repeatable fromProto(
+        @NonNull AnimationParameterProto.Repeatable proto, @Nullable Fingerprint fingerprint) {
+      return new Repeatable(proto, fingerprint);
     }
 
     @NonNull
-    AnimationParameterProto.Repeatable toProto() {
+    static Repeatable fromProto(@NonNull AnimationParameterProto.Repeatable proto) {
+      return fromProto(proto, null);
+    }
+
+    /**
+     * Returns the internal proto instance.
+     *
+     * @hide
+     */
+    @RestrictTo(Scope.LIBRARY_GROUP)
+    @NonNull
+    public AnimationParameterProto.Repeatable toProto() {
       return mImpl;
     }
 
@@ -601,6 +680,10 @@
           + getIterations()
           + ", repeatMode="
           + getRepeatMode()
+          + ", forwardRepeatDelayMillis="
+          + getForwardRepeatDelayMillis()
+          + ", reverseRepeatDelayMillis="
+          + getReverseRepeatDelayMillis()
           + "}";
     }
 
@@ -638,6 +721,32 @@
         return this;
       }
 
+      /**
+       * Sets the delay before the forward part of the repeat in milliseconds. If not set,
+       * defaults to 0.
+       *
+       * @since 1.2
+       */
+      @NonNull
+      public Builder setForwardRepeatDelayMillis(int forwardRepeatDelayMillis) {
+        mImpl.setForwardRepeatDelayMillis(forwardRepeatDelayMillis);
+        mFingerprint.recordPropertyUpdate(3, forwardRepeatDelayMillis);
+        return this;
+      }
+
+      /**
+       * Sets the delay before the reverse part of the repeat in milliseconds. This delay will
+       * not be used when the repeat mode is restart. If not set, defaults to 0.
+       *
+       * @since 1.2
+       */
+      @NonNull
+      public Builder setReverseRepeatDelayMillis(int reverseRepeatDelayMillis) {
+        mImpl.setReverseRepeatDelayMillis(reverseRepeatDelayMillis);
+        mFingerprint.recordPropertyUpdate(4, reverseRepeatDelayMillis);
+        return this;
+      }
+
       /** Builds an instance from accumulated values. */
       @NonNull
       public Repeatable build() {
diff --git a/wear/protolayout/protolayout-expression/src/test/java/androidx/wear/protolayout/expression/AnimationSpecTest.java b/wear/protolayout/protolayout-expression/src/test/java/androidx/wear/protolayout/expression/AnimationSpecTest.java
index 7f2ec39..cc15685 100644
--- a/wear/protolayout/protolayout-expression/src/test/java/androidx/wear/protolayout/expression/AnimationSpecTest.java
+++ b/wear/protolayout/protolayout-expression/src/test/java/androidx/wear/protolayout/expression/AnimationSpecTest.java
@@ -34,15 +34,16 @@
     assertThat(
             new AnimationSpec.Builder()
                 .setDurationMillis(1)
-                .setDelayMillis(2)
+                .setStartDelayMillis(2)
                 .setEasing(new CubicBezierEasing.Builder().setX1(3f).build())
                 .setRepeatable(new Repeatable.Builder().setIterations(4).build())
                 .build()
                 .toString())
         .isEqualTo(
-            "AnimationSpec{durationMillis=1, delayMillis=2, "
+            "AnimationSpec{durationMillis=1, startDelayMillis=2, "
                 + "easing=CubicBezierEasing{x1=3.0, y1=0.0, x2=0.0, y2=0.0}, "
-                + "repeatable=Repeatable{iterations=4, repeatMode=0}}");
+                + "repeatable=Repeatable{iterations=4, repeatMode=0, "
+                + "forwardRepeatDelayMillis=0, reverseRepeatDelayMillis=0}}");
   }
 
   @Test
@@ -64,8 +65,11 @@
             new Repeatable.Builder()
                 .setIterations(10)
                 .setRepeatMode(REPEAT_MODE_RESTART)
+                .setForwardRepeatDelayMillis(200)
+                .setReverseRepeatDelayMillis(100)
                 .build()
                 .toString())
-        .isEqualTo("Repeatable{iterations=10, repeatMode=1}");
+        .isEqualTo("Repeatable{iterations=10, repeatMode=1, "
+                + "forwardRepeatDelayMillis=200, reverseRepeatDelayMillis=100}");
   }
 }
diff --git a/wear/protolayout/protolayout-expression/src/test/java/androidx/wear/protolayout/expression/DynamicColorTest.java b/wear/protolayout/protolayout-expression/src/test/java/androidx/wear/protolayout/expression/DynamicColorTest.java
index 34e1e00..83cae56 100644
--- a/wear/protolayout/protolayout-expression/src/test/java/androidx/wear/protolayout/expression/DynamicColorTest.java
+++ b/wear/protolayout/protolayout-expression/src/test/java/androidx/wear/protolayout/expression/DynamicColorTest.java
@@ -34,7 +34,7 @@
   @ColorInt private static final int CONSTANT_VALUE = 0xff00ff00;
   private static final AnimationSpec SPEC =
       new AnimationSpec.Builder()
-          .setDelayMillis(1)
+          .setStartDelayMillis(1)
           .setDurationMillis(2)
           .setRepeatable(
               new Repeatable.Builder().setRepeatMode(REPEAT_MODE_REVERSE).setIterations(10).build())
@@ -90,12 +90,12 @@
             DynamicColor.animate(
                     /* start= */ 0x00000001,
                     /* end= */ 0x00000002,
-                    new AnimationSpec.Builder().setDelayMillis(0).build())
+                    new AnimationSpec.Builder().setStartDelayMillis(0).build())
                 .toString())
         .isEqualTo(
             "AnimatableFixedColor{"
                 + "fromArgb=1, toArgb=2, animationSpec=AnimationSpec{"
-                + "durationMillis=0, delayMillis=0, easing=null, repeatable=null}}");
+                + "durationMillis=0, startDelayMillis=0, easing=null, repeatable=null}}");
   }
 
   @Test
@@ -120,12 +120,13 @@
   public void stateAnimatedToString() {
     assertThat(
             DynamicColor.animate(
-                    /* stateKey= */ "key", new AnimationSpec.Builder().setDelayMillis(1).build())
+                    /* stateKey= */ "key",
+                    new AnimationSpec.Builder().setStartDelayMillis(1).build())
                 .toString())
         .isEqualTo(
             "AnimatableDynamicColor{"
                 + "input=StateColorSource{sourceKey=key}, animationSpec=AnimationSpec{"
-                + "durationMillis=0, delayMillis=1, easing=null, repeatable=null}}");
+                + "durationMillis=0, startDelayMillis=1, easing=null, repeatable=null}}");
   }
 
   @Test
diff --git a/wear/protolayout/protolayout-expression/src/test/java/androidx/wear/protolayout/expression/DynamicFloatTest.java b/wear/protolayout/protolayout-expression/src/test/java/androidx/wear/protolayout/expression/DynamicFloatTest.java
index dc0344e..281eb6a 100644
--- a/wear/protolayout/protolayout-expression/src/test/java/androidx/wear/protolayout/expression/DynamicFloatTest.java
+++ b/wear/protolayout/protolayout-expression/src/test/java/androidx/wear/protolayout/expression/DynamicFloatTest.java
@@ -35,7 +35,7 @@
   private static final float CONSTANT_VALUE = 42.42f;
   private static final AnimationSpec SPEC =
       new AnimationSpec.Builder()
-          .setDelayMillis(1)
+          .setStartDelayMillis(1)
           .setDurationMillis(2)
           .setRepeatable(
               new AnimationParameterBuilders.Repeatable.Builder()
@@ -169,11 +169,11 @@
             DynamicFloat.animate(
                     /* start= */ 1f,
                     /* end= */ 2f,
-                    new AnimationSpec.Builder().setDelayMillis(0).build())
+                    new AnimationSpec.Builder().setStartDelayMillis(0).build())
                 .toString())
         .isEqualTo(
             "AnimatableFixedFloat{fromValue=1.0, toValue=2.0, animationSpec=AnimationSpec{"
-                + "durationMillis=0, delayMillis=0, easing=null, repeatable=null}}");
+                + "durationMillis=0, startDelayMillis=0, easing=null, repeatable=null}}");
   }
 
   @Test
@@ -198,12 +198,13 @@
   public void stateAnimatedToString() {
     assertThat(
             DynamicFloat.animate(
-                    /* stateKey= */ "key", new AnimationSpec.Builder().setDelayMillis(1).build())
+                    /* stateKey= */ "key",
+                    new AnimationSpec.Builder().setStartDelayMillis(1).build())
                 .toString())
         .isEqualTo(
             "AnimatableDynamicFloat{"
                 + "input=StateFloatSource{sourceKey=key}, animationSpec=AnimationSpec{"
-                + "durationMillis=0, delayMillis=1, easing=null, repeatable=null}}");
+                + "durationMillis=0, startDelayMillis=1, easing=null, repeatable=null}}");
   }
 
   @Test
diff --git a/wear/protolayout/protolayout-proto/src/main/proto/animation_parameters.proto b/wear/protolayout/protolayout-proto/src/main/proto/animation_parameters.proto
index aee13fe..5581a04 100644
--- a/wear/protolayout/protolayout-proto/src/main/proto/animation_parameters.proto
+++ b/wear/protolayout/protolayout-proto/src/main/proto/animation_parameters.proto
@@ -14,7 +14,7 @@
 
   // The delay to start the animation in milliseconds. If not set, defaults to
   // 0.
-  uint32 delay_millis = 2;
+  uint32 start_delay_millis = 2;
 
   // The easing to be used for adjusting an animation's fraction. If not set,
   // defaults to Linear Interpolator.
@@ -68,6 +68,13 @@
   // The repeat mode to specify how animation will behave when repeated. If not
   // set, defaults to restart.
   RepeatMode repeat_mode = 2;
+
+  // The delay before the forward part of the repeat in milliseconds. If not set, defaults to 0.
+  uint32 forward_repeat_delay_millis = 3;
+
+  // The delay before the reversed part of the repeat in milliseconds. This delay will not be used
+  // when the repeat mode is restart. If not set, defaults to 0.
+  uint32 reverse_repeat_delay_millis = 4;
 }
 
 // The repeat mode to specify how animation will behave when repeated.
diff --git a/wear/protolayout/protolayout-proto/src/main/proto/modifiers.proto b/wear/protolayout/protolayout-proto/src/main/proto/modifiers.proto
index d6f9586..0e765ca 100644
--- a/wear/protolayout/protolayout-proto/src/main/proto/modifiers.proto
+++ b/wear/protolayout/protolayout-proto/src/main/proto/modifiers.proto
@@ -178,11 +178,11 @@
 // underneath it.
 message AnimatedVisibility {
   // The content transition that is triggered when element enters the layout.
-  EnterTransition enter = 1;
+  EnterTransition enter_transition = 1;
 
   // The content transition that is triggered when element exits the layout.
   // Note that indefinite exit animations are ignored.
-  ExitTransition exit = 2;
+  ExitTransition exit_transition = 2;
 }
 
 // The content transition that is triggered when element enters the layout.
diff --git a/wear/protolayout/protolayout-proto/src/main/proto/resources.proto b/wear/protolayout/protolayout-proto/src/main/proto/resources.proto
index 3284bee..23b453c 100644
--- a/wear/protolayout/protolayout-proto/src/main/proto/resources.proto
+++ b/wear/protolayout/protolayout-proto/src/main/proto/resources.proto
@@ -66,34 +66,31 @@
 // resource ID. The animation is started with given trigger, fire and forget.
 message AndroidAnimatedImageResourceByResId {
   // The format for the animated image.
-  AnimatedImageFormat format = 1;
+  AnimatedImageFormat animated_image_format = 1;
 
   // The Android resource ID, e.g. R.drawable.foo.
   int32 resource_id = 2;
 
   // The trigger to start the animation.
-  Trigger trigger = 3;
+  Trigger start_trigger = 3;
 }
 
 // A seekable animated image resource that maps to an Android drawable by
 // resource ID. The animation progress is bound to the provided dynamic float.
 message AndroidSeekableAnimatedImageResourceByResId {
   // The format for the animated image.
-  AnimatedImageFormat format = 1;
+  AnimatedImageFormat animated_image_format = 1;
 
   // The Android resource ID, e.g. R.drawable.foo
   int32 resource_id = 2;
 
-  // A dynamic float, normally transformed from certain states with the data
-  // binding pipeline, controls the progress of the animation. Its value is
-  // required to fall in the range of [0.0, 1.0], any values outside this range
-  // would be clamped.
-  //
-  // Typically, AnimatableFixedFloat or AnimatableDynamicFloat is used for this
-  // progress. With AnimatableFixedFloat, the animation is played from progress
-  // of its from_value to to_value; with AnimatableDynamicFloat, the animation
-  // is set from progress 0 to its first value once it is available, it then
-  // plays from current progress to the new value on subsequent updates.
+  // A DynamicFloat, normally transformed from certain states with the data
+  // binding pipeline to control the progress of the animation. Its value is
+  // required to fall in the range of [0.0, 1.0]. Any values outside this range
+  // would be clamped. When the first value of the DynamicFloat arrives, the
+  // animation starts from progress 0 to that value. After that it plays from
+  // current progress to the new value on subsequent updates. If not set, the
+  // animation will play on load (similar to a non-seekable animated).
   androidx.wear.protolayout.expression.proto.DynamicFloat progress = 3;
 }
 
diff --git a/wear/protolayout/protolayout-proto/src/main/proto/trigger.proto b/wear/protolayout/protolayout-proto/src/main/proto/trigger.proto
index ef42469..f9cc1c5 100644
--- a/wear/protolayout/protolayout-proto/src/main/proto/trigger.proto
+++ b/wear/protolayout/protolayout-proto/src/main/proto/trigger.proto
@@ -21,9 +21,9 @@
 
 // Triggers *every time* the condition switches from false to true. If the
 // condition is true initially, that will fire the trigger on load.
-message OnConditionTrigger {
+message OnConditionMetTrigger {
   // Dynamic boolean used as trigger.
-  androidx.wear.protolayout.expression.proto.DynamicBool dynamic_bool = 1;
+  androidx.wear.protolayout.expression.proto.DynamicBool trigger = 1;
 }
 
 // The triggers that can be fired.
@@ -32,6 +32,6 @@
     OnVisibleTrigger on_visible_trigger = 1;
     OnVisibleOnceTrigger on_visible_once_trigger = 2;
     OnLoadTrigger on_load_trigger = 3;
-    OnConditionTrigger on_condition_trigger = 4;
+    OnConditionMetTrigger on_condition_met_trigger = 4;
   }
 }
diff --git a/wear/protolayout/protolayout-renderer/build.gradle b/wear/protolayout/protolayout-renderer/build.gradle
index d071817..47d3c3f 100644
--- a/wear/protolayout/protolayout-renderer/build.gradle
+++ b/wear/protolayout/protolayout-renderer/build.gradle
@@ -23,10 +23,34 @@
 
 dependencies {
     annotationProcessor(libs.nullaway)
+    api("androidx.annotation:annotation:1.2.0")
+    api("androidx.core:core:1.7.0")
+    api(libs.guavaListenableFuture)
+    implementation("androidx.core:core:1.3.2")
+
+    implementation(project(path: ":wear:protolayout:protolayout-proto", configuration: "shadow"))
+    implementation(project(":wear:protolayout:protolayout-expression"))
+    implementation(project(":wear:protolayout:protolayout-expression-pipeline"))
+    implementation "androidx.concurrent:concurrent-futures:1.1.0"
+    implementation("androidx.core:core:1.7.0")
+    implementation("androidx.vectordrawable:vectordrawable-seekable:1.0.0-beta01")
+    implementation("androidx.collection:collection:1.2.0")
+
+    testImplementation(libs.mockitoCore4)
+    testImplementation(libs.testExtJunit)
+    testImplementation(libs.testExtTruth)
+    testImplementation(libs.testRunner)
+    testImplementation(libs.robolectric)
+    testImplementation("androidx.collection:collection:1.2.0")
+    testImplementation(libs.truth)
 }
 
 android {
     namespace "androidx.wear.protolayout.renderer"
+
+    defaultConfig {
+        minSdkVersion 26
+    }
 }
 
 androidx {
diff --git a/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/ProtoLayoutTheme.java b/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/ProtoLayoutTheme.java
new file mode 100644
index 0000000..6a5d272
--- /dev/null
+++ b/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/ProtoLayoutTheme.java
@@ -0,0 +1,67 @@
+/*
+ * 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.protolayout.renderer;
+
+import android.content.res.Resources.Theme;
+import android.graphics.Typeface;
+
+import androidx.annotation.AttrRes;
+import androidx.annotation.NonNull;
+import androidx.annotation.RestrictTo;
+import androidx.annotation.RestrictTo.Scope;
+
+/**
+ * Theme customization for ProtoLayout texts, which includes Font types and variants.
+ *
+ * @hide
+ */
+@RestrictTo(Scope.LIBRARY)
+public interface ProtoLayoutTheme {
+
+    /** Holder for different weights of the same font variant. */
+    interface FontSet {
+
+        @NonNull
+        Typeface getNormalFont();
+
+        @NonNull
+        Typeface getMediumFont();
+
+        @NonNull
+        Typeface getBoldFont();
+    }
+
+    /**
+     * Gets the FontSet for a given font variant.
+     *
+     * @param fontVariant the numeric value of the proto enum {@link
+     *     androidx.wear.protolayout.proto.LayoutElementProto.FontVariant}.
+     */
+    @NonNull
+    FontSet getFontSet(int fontVariant);
+
+    /** Gets an Android Theme object styled with TextAppearance attributes. */
+    @NonNull
+    Theme getTheme();
+
+    /**
+     * Gets an Attribute resource Id for a fallback TextAppearance. The resource with this id should
+     * be present in the Android Theme returned by {@link ProtoLayoutTheme#getTheme()}.
+     */
+    @AttrRes
+    int getFallbackTextAppearanceResId();
+}
diff --git a/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/ProtoLayoutVisibilityState.java b/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/ProtoLayoutVisibilityState.java
new file mode 100644
index 0000000..e1ce81b
--- /dev/null
+++ b/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/ProtoLayoutVisibilityState.java
@@ -0,0 +1,45 @@
+/*
+ * 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.protolayout.renderer;
+
+import androidx.annotation.IntDef;
+import androidx.annotation.RestrictTo;
+import androidx.annotation.RestrictTo.Scope;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+
+/**
+ * The visibility state of the layout.
+ *
+ * @hide
+ */
+@IntDef({
+    ProtoLayoutVisibilityState.VISIBILITY_STATE_FULLY_VISIBLE,
+    ProtoLayoutVisibilityState.VISIBILITY_STATE_PARTIALLY_VISIBLE,
+    ProtoLayoutVisibilityState.VISIBILITY_STATE_INVISIBLE,
+})
+@RestrictTo(Scope.LIBRARY)
+@Retention(RetentionPolicy.SOURCE)
+public @interface ProtoLayoutVisibilityState {
+    /** Fully visible and on-screen. */
+    int VISIBILITY_STATE_FULLY_VISIBLE = 0;
+    /** The layout is either entering or leaving the screen. */
+    int VISIBILITY_STATE_PARTIALLY_VISIBLE = 1;
+    /** The layout is off screen, or covered up by a foreground activity. */
+    int VISIBILITY_STATE_INVISIBLE = 2;
+}
diff --git a/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/common/ProtoLayoutDiffer.java b/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/common/ProtoLayoutDiffer.java
new file mode 100644
index 0000000..e053473
--- /dev/null
+++ b/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/common/ProtoLayoutDiffer.java
@@ -0,0 +1,477 @@
+/*
+ * 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.protolayout.renderer.common;
+
+import static androidx.core.util.Preconditions.checkState;
+
+import android.annotation.SuppressLint;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.RestrictTo;
+import androidx.annotation.RestrictTo.Scope;
+import androidx.annotation.VisibleForTesting;
+import androidx.wear.protolayout.proto.FingerprintProto.NodeFingerprint;
+import androidx.wear.protolayout.proto.FingerprintProto.TreeFingerprint;
+import androidx.wear.protolayout.proto.LayoutElementProto.ArcLayoutElement;
+import androidx.wear.protolayout.proto.LayoutElementProto.Layout;
+import androidx.wear.protolayout.proto.LayoutElementProto.LayoutElement;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Utility to diff 2 proto layouts in order to be able to partially update the display.
+ *
+ * @hide
+ */
+@RestrictTo(Scope.LIBRARY_GROUP)
+public class ProtoLayoutDiffer {
+    /** Prefix for all node IDs generated by this differ. */
+    @NonNull private static final String NODE_ID_PREFIX = "pT";
+
+    /** Node ID of the root node. @hide */
+    @RestrictTo(Scope.LIBRARY_GROUP)
+    @NonNull
+    public static final String ROOT_NODE_ID = NODE_ID_PREFIX + "1";
+
+    // This must match {@code Fingerprint.DISCARDED_VALUE}
+    @VisibleForTesting static final int DISCARDED_FINGERPRINT_VALUE = -1;
+
+    /**
+     * If true, an element addition or removal forces its parent (and siblings of the changed node)
+     * to reinflate.
+     *
+     * @hide
+     */
+    @RestrictTo(Scope.LIBRARY_GROUP)
+    public static final boolean UPDATE_ALL_CHILDREN_AFTER_ADD_REMOVE = true;
+
+    /**
+     * Index of the first child node under a parent. {@link #createNodePosId} should be called
+     * starting from this value and incremented by one for each child node.
+     *
+     * @hide
+     */
+    @RestrictTo(Scope.LIBRARY_GROUP)
+    public static final int FIRST_CHILD_INDEX = 0;
+
+    private enum NodeChangeType {
+        NO_CHANGE,
+        CHANGE_IN_SELF_ONLY,
+        CHANGE_IN_SELF_AND_ALL_CHILDREN,
+        CHANGE_IN_SELF_AND_SOME_CHILDREN,
+        CHANGE_IN_CHILDREN
+    }
+
+    static final class InconsistentFingerprintException extends Exception {}
+
+    /** A node in a layout tree. */
+    private static final class TreeNode {
+        @Nullable final LayoutElement mLayoutElement;
+        @Nullable final ArcLayoutElement mArcLayoutElement;
+        @NonNull final NodeFingerprint mFingerprint;
+        @NonNull final String mPosId;
+
+        private TreeNode(
+                @Nullable LayoutElement layoutElement,
+                @Nullable ArcLayoutElement arcLayoutElement,
+                @NonNull NodeFingerprint fingerprint,
+                @NonNull String posId) {
+            this.mLayoutElement = layoutElement;
+            this.mArcLayoutElement = arcLayoutElement;
+            this.mFingerprint = fingerprint;
+            this.mPosId = posId;
+        }
+
+        @NonNull
+        static TreeNode ofLayoutElement(
+                @NonNull LayoutElement layoutElement,
+                @NonNull NodeFingerprint fingerprint,
+                @NonNull String posId) {
+            return new TreeNode(layoutElement, null, fingerprint, posId);
+        }
+
+        @NonNull
+        static TreeNode ofArcLayoutElement(
+                @NonNull ArcLayoutElement arcLayoutElement,
+                @NonNull NodeFingerprint fingerprint,
+                @NonNull String id) {
+            return new TreeNode(null, arcLayoutElement, fingerprint, id);
+        }
+
+        @NonNull
+        TreeNodeWithChange withChange(boolean isSelfOnlyChange) {
+            return new TreeNodeWithChange(this, isSelfOnlyChange);
+        }
+    }
+
+    /**
+     * A node in a layout tree, that has a change compared to a previous version.
+     *
+     * @hide
+     */
+    @RestrictTo(Scope.LIBRARY_GROUP)
+    public static final class TreeNodeWithChange {
+        @NonNull private final TreeNode mTreeNode;
+        private final boolean mIsSelfOnlyChange;
+
+        TreeNodeWithChange(@NonNull TreeNode treeNode, boolean isSelfOnlyChange) {
+            this.mTreeNode = treeNode;
+            this.mIsSelfOnlyChange = isSelfOnlyChange;
+        }
+
+        /**
+         * Returns the linear {@link LayoutElement} that this node represents, or null if the node
+         * isn't for a {@link LayoutElement}.
+         *
+         * @hide
+         */
+        @Nullable
+        @RestrictTo(Scope.LIBRARY_GROUP)
+        public LayoutElement getLayoutElement() {
+            return mTreeNode.mLayoutElement;
+        }
+
+        /**
+         * Returns the radial {@link ArcLayoutElement} that this node represents, or null if the
+         * node isn't for a {@link ArcLayoutElement}.
+         *
+         * @hide
+         */
+        @Nullable
+        @RestrictTo(Scope.LIBRARY_GROUP)
+        public ArcLayoutElement getArcLayoutElement() {
+            return mTreeNode.mArcLayoutElement;
+        }
+
+        /** Returns the fingerprint for this node. @hide */
+        @NonNull
+        @RestrictTo(Scope.LIBRARY_GROUP)
+        public NodeFingerprint getFingerprint() {
+            return mTreeNode.mFingerprint;
+        }
+
+        /**
+         * Returns an ID for this node based on its position in the tree. Only comparable against
+         * other position IDs that are generated with {@link #createNodePosId}.
+         *
+         * @hide
+         */
+        @NonNull
+        @RestrictTo(Scope.LIBRARY_GROUP)
+        public String getPosId() {
+            return mTreeNode.mPosId;
+        }
+
+        /**
+         * Returns true if the change in this node affects the node itself only. Otherwise the
+         * change affects both the node and its children.
+         *
+         * @hide
+         */
+        @RestrictTo(Scope.LIBRARY_GROUP)
+        public boolean isSelfOnlyChange() {
+            return mIsSelfOnlyChange;
+        }
+    }
+
+    /** A diff in layout, containing information about the tree nodes that have changed. @hide */
+    @RestrictTo(Scope.LIBRARY_GROUP)
+    public static final class LayoutDiff {
+        @NonNull private final List<TreeNodeWithChange> mChangedNodes;
+
+        LayoutDiff(@NonNull List<TreeNodeWithChange> changedNodes) {
+            this.mChangedNodes = changedNodes;
+        }
+
+        /**
+         * An ordered list of nodes that have changed. A changed node always comes before its
+         * changed descendants in this list.
+         *
+         * @hide
+         */
+        @NonNull
+        @RestrictTo(Scope.LIBRARY_GROUP)
+        public List<TreeNodeWithChange> getChangedNodes() {
+            return mChangedNodes;
+        }
+    }
+
+    private ProtoLayoutDiffer() {}
+
+    /**
+     * Create an ID for a layout element, based on its position. This can be stored as a tag in the
+     * corresponding View and later used with findViewWithTag() to replace changed elements.
+     *
+     * @param parentPosId Position-based ID of the parent node.
+     * @param childIndex Index of this child node. For the first child, use {@link
+     *     #FIRST_CHILD_INDEX}, and increment by one for each.
+     * @hide
+     */
+    @SuppressLint("DefaultLocale")
+    @NonNull
+    @RestrictTo(Scope.LIBRARY_GROUP)
+    public static String createNodePosId(@NonNull String parentPosId, int childIndex) {
+        return String.format("%s.%d", parentPosId, childIndex + 1);
+    }
+
+    /**
+     * Given a position ID generated by {@link #createNodePosId} for a node, extract the position ID
+     * of that node's parent.
+     *
+     * @param posId A position ID for a node.
+     * @return The position ID of the node's parent or null if the parent ID cannot be generated.
+     * @hide
+     */
+    @Nullable
+    @RestrictTo(Scope.LIBRARY_GROUP)
+    public static String getParentNodePosId(@NonNull String posId) {
+        if (!posId.startsWith(NODE_ID_PREFIX)) {
+            return null;
+        }
+        int separatorIdx = posId.lastIndexOf('.');
+        if (separatorIdx <= NODE_ID_PREFIX.length()) {
+            return null;
+        }
+        return posId.substring(0, separatorIdx);
+    }
+
+    /**
+     * Given a position ID for a node and a position ID for a potential parent node, returns if the
+     * node is actually a descendant of that parent node.
+     *
+     * @param posId Position ID of the potential descendant node.
+     * @param parentPosId Position ID of the potential parent node.
+     * @hide
+     */
+    @RestrictTo(Scope.LIBRARY_GROUP)
+    public static boolean isDescendantOf(@NonNull String posId, @NonNull String parentPosId) {
+        return posId.length() > parentPosId.length() + 1
+                && posId.startsWith(parentPosId)
+                && posId.charAt(parentPosId.length()) == '.';
+    }
+
+    /**
+     * Compute the diff from a previous layout tree to a new one.
+     *
+     * @param prevTreeFingerprint Fingerprint for the previous layout tree.
+     * @param layout The new layout.
+     * @return The layout diff or null if the diff cannot be computed, which means the whole layout
+     *     should be refreshed.
+     * @hide
+     */
+    @Nullable
+    @RestrictTo(Scope.LIBRARY_GROUP)
+    public static LayoutDiff getDiff(
+            @NonNull TreeFingerprint prevTreeFingerprint, @NonNull Layout layout) {
+        if (!layout.getFingerprint().hasRoot()) {
+            return null;
+        }
+        NodeFingerprint prevRootFingerprint = prevTreeFingerprint.getRoot();
+        TreeNode rootNode =
+                TreeNode.ofLayoutElement(
+                        layout.getRoot(), layout.getFingerprint().getRoot(), ROOT_NODE_ID);
+
+        List<TreeNodeWithChange> changedNodes = new ArrayList<>();
+        try {
+            addChangedNodes(prevRootFingerprint, rootNode, changedNodes);
+        } catch (InconsistentFingerprintException ignored) {
+            return null;
+        }
+
+        return new LayoutDiff(changedNodes);
+    }
+
+    /** Check whether 2 nodes represented by the given fingerprints are equivalent. @hide */
+    @RestrictTo(Scope.LIBRARY_GROUP)
+    public static boolean areNodesEquivalent(
+            @NonNull NodeFingerprint nodeA, @NonNull NodeFingerprint nodeB) {
+        return getChangeType(nodeA, nodeB) == NodeChangeType.NO_CHANGE;
+    }
+
+    private static void addChangedNodes(
+            @NonNull NodeFingerprint prevNodeFingerprint,
+            @NonNull TreeNode node,
+            @NonNull List<TreeNodeWithChange> changedNodes)
+            throws InconsistentFingerprintException {
+        switch (getChangeType(prevNodeFingerprint, node.mFingerprint)) {
+            case CHANGE_IN_SELF_ONLY:
+                changedNodes.add(node.withChange(/* isSelfOnlyChange= */ true));
+                break;
+            case CHANGE_IN_SELF_AND_ALL_CHILDREN:
+                changedNodes.add(node.withChange(/* isSelfOnlyChange= */ false));
+                break;
+            case CHANGE_IN_SELF_AND_SOME_CHILDREN:
+                changedNodes.add(node.withChange(/* isSelfOnlyChange= */ true));
+                addChangedChildNodes(prevNodeFingerprint, node, changedNodes);
+                break;
+            case CHANGE_IN_CHILDREN:
+                addChangedChildNodes(prevNodeFingerprint, node, changedNodes);
+                break;
+            case NO_CHANGE:
+                break;
+        }
+    }
+
+    @NonNull
+    private static NodeChangeType getChangeType(
+            @NonNull NodeFingerprint prevNode, @Nullable NodeFingerprint node) {
+        if (node == null) {
+            return NodeChangeType.CHANGE_IN_SELF_AND_ALL_CHILDREN;
+        }
+        if (prevNode.getSelfTypeValue() != node.getSelfTypeValue()) {
+            // If the type changes, update everything.
+            return NodeChangeType.CHANGE_IN_SELF_AND_ALL_CHILDREN;
+        }
+        if (node.getSelfPropsValue() == DISCARDED_FINGERPRINT_VALUE
+                && node.getChildNodesValue() == DISCARDED_FINGERPRINT_VALUE) {
+            if (node.getChildNodesCount() == 0) {
+                // Self and children are discarded.
+                return NodeChangeType.CHANGE_IN_SELF_AND_ALL_CHILDREN;
+            } else {
+                // Self is discarded, but children are not discarded at this level. At least one
+                // child is discarded though.
+                return NodeChangeType.CHANGE_IN_SELF_AND_SOME_CHILDREN;
+            }
+        }
+        if (prevNode.getChildNodesCount() != node.getChildNodesCount()) {
+            if (UPDATE_ALL_CHILDREN_AFTER_ADD_REMOVE) {
+                return NodeChangeType.CHANGE_IN_SELF_AND_ALL_CHILDREN;
+            } else {
+
+                throw new UnsupportedOperationException();
+            }
+        }
+        boolean selfChanged =
+                node.getSelfPropsValue() == DISCARDED_FINGERPRINT_VALUE
+                        || prevNode.getSelfPropsValue() != node.getSelfPropsValue();
+        boolean childrenChanged =
+                node.getChildNodesValue() == DISCARDED_FINGERPRINT_VALUE
+                        || prevNode.getChildNodesValue() != node.getChildNodesValue();
+        if (selfChanged && childrenChanged) {
+            return NodeChangeType.CHANGE_IN_SELF_AND_SOME_CHILDREN;
+        } else if (selfChanged) {
+            return NodeChangeType.CHANGE_IN_SELF_ONLY;
+        } else if (childrenChanged) {
+            return NodeChangeType.CHANGE_IN_CHILDREN;
+        } else {
+            return NodeChangeType.NO_CHANGE;
+        }
+    }
+
+    private static void addChangedChildNodes(
+            @NonNull NodeFingerprint prevNodeFingerprint,
+            @NonNull TreeNode node,
+            @NonNull List<TreeNodeWithChange> changedNodes)
+            throws InconsistentFingerprintException {
+        List<TreeNode> childList = getChildNodes(node);
+        if (childList.isEmpty()) {
+            return;
+        }
+        // This must have been checked in getChangeType()
+        checkState(childList.size() == prevNodeFingerprint.getChildNodesCount());
+        for (int i = 0; i < childList.size(); i++) {
+            TreeNode childNode = childList.get(i);
+            NodeFingerprint prevChildNodeFingerprint = prevNodeFingerprint.getChildNodes(i);
+            addChangedNodes(prevChildNodeFingerprint, childNode, changedNodes);
+        }
+    }
+
+    @SuppressWarnings("MixedMutabilityReturnType")
+    @NonNull
+    private static List<TreeNode> getChildNodes(@NonNull TreeNode node)
+            throws InconsistentFingerprintException {
+        @Nullable LayoutElement layoutElement = node.mLayoutElement;
+        if (layoutElement == null) {
+            // Only LayoutElement objects (which includes Arc and Span) can have children.
+            return Collections.emptyList();
+        }
+        NodeFingerprint fingerprint = node.mFingerprint;
+        switch (layoutElement.getInnerCase()) {
+            case BOX:
+                return getLinearChildNodes(
+                        layoutElement.getBox().getContentsList(),
+                        fingerprint.getChildNodesList(),
+                        node.mPosId);
+            case COLUMN:
+                return getLinearChildNodes(
+                        layoutElement.getColumn().getContentsList(),
+                        fingerprint.getChildNodesList(),
+                        node.mPosId);
+            case ROW:
+                return getLinearChildNodes(
+                        layoutElement.getRow().getContentsList(),
+                        fingerprint.getChildNodesList(),
+                        node.mPosId);
+            case ARC:
+                return getRadialChildNodes(
+                        layoutElement.getArc().getContentsList(),
+                        fingerprint.getChildNodesList(),
+                        node.mPosId);
+            default:
+                return Collections.emptyList();
+        }
+    }
+
+    @SuppressWarnings("MixedMutabilityReturnType")
+    @NonNull
+    private static List<TreeNode> getLinearChildNodes(
+            @NonNull List<LayoutElement> childElements,
+            @NonNull List<NodeFingerprint> childElementFingerprints,
+            @NonNull String parentPosId)
+            throws InconsistentFingerprintException {
+        if (childElements.isEmpty()) {
+            return Collections.emptyList();
+        }
+        if (childElements.size() != childElementFingerprints.size()) {
+            throw new InconsistentFingerprintException();
+        }
+        List<TreeNode> nodes = new ArrayList<>(childElements.size());
+        for (int i = 0; i < childElements.size(); i++) {
+            String childPosId = createNodePosId(parentPosId, FIRST_CHILD_INDEX + i);
+            nodes.add(
+                    TreeNode.ofLayoutElement(
+                            childElements.get(i), childElementFingerprints.get(i), childPosId));
+        }
+        return nodes;
+    }
+
+    @SuppressWarnings("MixedMutabilityReturnType")
+    @NonNull
+    private static List<TreeNode> getRadialChildNodes(
+            @NonNull List<ArcLayoutElement> childElements,
+            @NonNull List<NodeFingerprint> childElementFingerprints,
+            @NonNull String parentPosId)
+            throws InconsistentFingerprintException {
+        if (childElements.isEmpty()) {
+            return Collections.emptyList();
+        }
+        if (childElements.size() != childElementFingerprints.size()) {
+            throw new InconsistentFingerprintException();
+        }
+        List<TreeNode> nodes = new ArrayList<>(childElements.size());
+        for (int i = 0; i < childElements.size(); i++) {
+            String childPosId = createNodePosId(parentPosId, FIRST_CHILD_INDEX + i);
+            nodes.add(
+                    TreeNode.ofArcLayoutElement(
+                            childElements.get(i), childElementFingerprints.get(i), childPosId));
+        }
+        return nodes;
+    }
+}
diff --git a/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/dynamicdata/NodeInfo.java b/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/dynamicdata/NodeInfo.java
new file mode 100644
index 0000000..cba6672
--- /dev/null
+++ b/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/dynamicdata/NodeInfo.java
@@ -0,0 +1,313 @@
+/*
+ * 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.protolayout.renderer.dynamicdata;
+
+import android.graphics.drawable.Animatable2.AnimationCallback;
+import android.graphics.drawable.AnimatedVectorDrawable;
+import android.graphics.drawable.Drawable;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.UiThread;
+import androidx.annotation.VisibleForTesting;
+import androidx.collection.ArraySet;
+import androidx.vectordrawable.graphics.drawable.SeekableAnimatedVectorDrawable;
+import androidx.wear.protolayout.expression.pipeline.BoundDynamicType;
+import androidx.wear.protolayout.expression.pipeline.QuotaManager;
+import androidx.wear.protolayout.expression.proto.DynamicProto.DynamicFloat;
+import androidx.wear.protolayout.proto.ModifiersProto.AnimatedVisibility;
+import androidx.wear.protolayout.proto.TriggerProto.Trigger;
+import androidx.wear.protolayout.proto.TriggerProto.Trigger.InnerCase;
+import androidx.wear.protolayout.renderer.dynamicdata.PositionIdTree.TreeNode;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/**
+ * Information about a layout node that has multiple dynamic types or animators to it.
+ *
+ * <p>Note: this class is not thread-safe.
+ */
+class NodeInfo implements TreeNode {
+
+    /** List of active bound dynamic types in the pipeline. */
+    @NonNull private final List<BoundDynamicType> mActiveBoundTypes = new ArrayList<>();
+
+    /** List of bound dynamic types that need to be evaluated. */
+    @NonNull private List<BoundDynamicType> mPendingBoundTypes = Collections.emptyList();
+
+    @NonNull private final QuotaManager mQuotaManager;
+
+    /** Set of animated image resources after they are resolved during inflation. */
+    @NonNull private Set<ResolvedAvd> mResolvedAvds = Collections.emptySet();
+
+    @NonNull private Set<ResolvedSeekableAvd> mResolvedSeekableAvds = Collections.emptySet();
+
+    @Nullable private AnimatedVisibility mAnimatedVisibility = null;
+
+    @NonNull private final String mPosId;
+
+    NodeInfo(@NonNull String posId, @NonNull QuotaManager quotaManager) {
+        this.mPosId = posId;
+        this.mQuotaManager = quotaManager;
+    }
+
+    /**
+     * Adds bound dynamic type returned by {@link
+     * androidx.wear.protolayout.expression.pipeline.DynamicTypeEvaluator#bind} to active and
+     * pending types. It will stop being pending when {@link #initPendingBoundTypes()} is called.
+     */
+    void addBoundType(@NonNull BoundDynamicType boundType) {
+        mActiveBoundTypes.add(boundType);
+        addPendingEvaluationBoundType(boundType);
+    }
+
+    private void addPendingEvaluationBoundType(@NonNull BoundDynamicType boundTYpe) {
+        if (mPendingBoundTypes.isEmpty()) {
+            mPendingBoundTypes = new ArrayList<>();
+        }
+        mPendingBoundTypes.add(boundTYpe);
+    }
+
+    /**
+     * Initializes evaluation on all pending bound types, i.e. those added after the last {@link
+     * #initPendingBoundTypes} call.
+     */
+    @UiThread
+    void initPendingBoundTypes() {
+        mPendingBoundTypes.forEach(BoundDynamicType::startEvaluation);
+        mPendingBoundTypes.clear();
+    }
+
+    @NonNull
+    ResolvedAvd addResolvedAvd(@NonNull AnimatedVectorDrawable drawable, @NonNull Trigger trigger) {
+        if (mResolvedAvds.isEmpty()) {
+            mResolvedAvds = new ArraySet<>();
+        }
+        ResolvedAvd avd =
+                new NodeInfo.ResolvedAvd(
+                        drawable, trigger, new QuotaReleasingAnimationCallback(mQuotaManager));
+        mResolvedAvds.add(avd);
+
+        return avd;
+    }
+
+    void addResolvedSeekableAvd(@NonNull ResolvedSeekableAvd seekableAvd) {
+        if (mResolvedSeekableAvds.isEmpty()) {
+            mResolvedSeekableAvds = new ArraySet<>();
+        }
+        mResolvedSeekableAvds.add(seekableAvd);
+    }
+
+    @UiThread
+    @Override
+    public void destroy() {
+        mActiveBoundTypes.forEach(BoundDynamicType::close);
+        mResolvedAvds.forEach(ResolvedAvd::unregisterCallback);
+    }
+
+    /**
+     * Returns the number of active bound dynamic types.
+     *
+     * @hide
+     */
+    @VisibleForTesting(otherwise = VisibleForTesting.NONE)
+    @SuppressWarnings("RestrictTo")
+    int size() {
+        return mActiveBoundTypes.stream().mapToInt(BoundDynamicType::getDynamicNodeCount).sum();
+    }
+
+    /** Play the animation with the given trigger type */
+    @UiThread
+    void playAvdAnimations(@NonNull InnerCase triggerCase) {
+        for (ResolvedAvd entry : mResolvedAvds) {
+            if (entry.mTrigger.getInnerCase() != triggerCase
+                    || entry.mDrawable == null
+                    || entry.mDrawable.isRunning()) {
+                continue;
+            }
+            if ((triggerCase == InnerCase.ON_VISIBLE_ONCE_TRIGGER
+                            || triggerCase == InnerCase.ON_LOAD_TRIGGER)
+                    && entry.mPlayedAtLeastOnce) {
+                continue;
+            }
+            if (!mQuotaManager.tryAcquireQuota(1)) {
+                continue;
+            }
+            entry.startAnimation();
+        }
+    }
+
+    /** Sets visibility of the animations. This also pauses or resumes animators. */
+    @UiThread
+    @SuppressWarnings("RestrictTo")
+    void setVisibility(boolean visible) {
+        for (ResolvedAvd entry : mResolvedAvds) {
+            entry.mDrawable.setVisible(visible, /* restart= */ false);
+        }
+        for (ResolvedSeekableAvd entry : mResolvedSeekableAvds) {
+            entry.mDrawable.setVisible(visible, /* restart= */ false);
+        }
+        mActiveBoundTypes.forEach(n -> n.setAnimationVisibility(visible));
+    }
+
+    /** Reset the avd animations with the given trigger type */
+    @UiThread
+    void resetAvdAnimations(@NonNull InnerCase triggerCase) {
+        for (ResolvedAvd entry : mResolvedAvds) {
+            if (entry.mTrigger.getInnerCase() == triggerCase && entry.mDrawable != null) {
+                entry.mDrawable.reset();
+            }
+        }
+    }
+
+    /** Reset the avd animations with the given trigger type */
+    @UiThread
+    void stopAvdAnimations() {
+        for (ResolvedAvd entry : mResolvedAvds) {
+            if (entry.mDrawable.isRunning()) {
+                entry.mDrawable.stop();
+                // We need to manually call the callback, as per Javadoc, callback is called later,
+                // on a different thread, meaning that quota won't be released in time.
+                entry.mCallback.onAnimationEnd(entry.mDrawable);
+            }
+        }
+    }
+
+    /**
+     * Returns the total duration in milliseconds of the animated drawable associated with a
+     * StateSource with the given key name; or null if no such SourceKey exists.
+     */
+    @Nullable
+    Long getSeekableAnimationTotalDurationMillis(@NonNull String sourceKey) {
+        for (ResolvedSeekableAvd resourceEntry : mResolvedSeekableAvds) {
+            if (resourceEntry.hasStateSourceKey(sourceKey)) {
+                return resourceEntry.mDrawable.getTotalDuration();
+            }
+        }
+        return null;
+    }
+
+    /**
+     * Returns how many animations are running.
+     *
+     * @hide
+     */
+    @VisibleForTesting(otherwise = VisibleForTesting.NONE)
+    @SuppressWarnings("RestrictTo")
+    int getRunningAnimationCount() {
+        return (int)
+                (mActiveBoundTypes.stream()
+                                .mapToInt(BoundDynamicType::getRunningAnimationCount)
+                                .sum()
+                        + mResolvedAvds.stream().filter(avd -> avd.mDrawable.isRunning()).count());
+    }
+
+    /** Stores the {@link AnimatedVisibility} associated with this node. */
+    void setAnimatedVisibility(@NonNull AnimatedVisibility animatedVisibility) {
+        this.mAnimatedVisibility = animatedVisibility;
+    }
+
+    /**
+     * Returns the {@link AnimatedVisibility} associated with this node. Returns null if no enter
+     * animation is associated with this node.
+     */
+    @Nullable
+    AnimatedVisibility getAnimatedVisibility() {
+        return mAnimatedVisibility;
+    }
+
+    /** Returns the position Id for this node. */
+    @NonNull
+    String getPosId() {
+        return mPosId;
+    }
+
+    static class ResolvedAvd {
+        @NonNull final AnimatedVectorDrawable mDrawable;
+        @NonNull final QuotaReleasingAnimationCallback mCallback;
+        @NonNull final Trigger mTrigger;
+        boolean mPlayedAtLeastOnce;
+
+        ResolvedAvd(
+                @NonNull AnimatedVectorDrawable drawable,
+                @NonNull Trigger trigger,
+                @NonNull QuotaReleasingAnimationCallback callback) {
+            this.mDrawable = drawable;
+            this.mCallback = callback;
+            this.mTrigger = trigger;
+            mPlayedAtLeastOnce = false;
+            this.mDrawable.registerAnimationCallback(callback);
+        }
+
+        void unregisterCallback() {
+            mDrawable.unregisterAnimationCallback(mCallback);
+        }
+
+        void startAnimation() {
+            this.mDrawable.start();
+            this.mCallback.mIsUsingQuota.set(true);
+            this.mPlayedAtLeastOnce = true;
+        }
+    }
+
+    static class ResolvedSeekableAvd {
+        @NonNull final SeekableAnimatedVectorDrawable mDrawable;
+        @NonNull final DynamicFloat mBoundProgress;
+
+        ResolvedSeekableAvd(
+                @NonNull SeekableAnimatedVectorDrawable drawable,
+                @NonNull DynamicFloat boundProgress) {
+            this.mDrawable = drawable;
+            this.mBoundProgress = boundProgress;
+        }
+
+        boolean hasStateSourceKey(@NonNull String sourceKey) {
+            return mBoundProgress.getStateSource().getSourceKey().equals(sourceKey);
+        }
+    }
+
+    /** The callback used for AVD animations to release quota when the animation is finished. */
+    private static final class QuotaReleasingAnimationCallback extends AnimationCallback {
+        @NonNull private final QuotaManager mQuotaManager;
+
+        @NonNull final AtomicBoolean mIsUsingQuota = new AtomicBoolean(false);
+
+        QuotaReleasingAnimationCallback(@NonNull QuotaManager quotaManager) {
+            this.mQuotaManager = quotaManager;
+        }
+
+        @Override
+        public void onAnimationEnd(@NonNull Drawable drawable) {
+            if (mIsUsingQuota.compareAndSet(true, false)) {
+                mQuotaManager.releaseQuota(1);
+            }
+        }
+
+        @Override
+        public void onAnimationStart(@NonNull Drawable drawable) {}
+    }
+
+    @NonNull
+    @Override
+    public String toString() {
+        return mPosId;
+    }
+}
diff --git a/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/dynamicdata/PositionIdTree.java b/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/dynamicdata/PositionIdTree.java
new file mode 100644
index 0000000..789c32c
--- /dev/null
+++ b/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/dynamicdata/PositionIdTree.java
@@ -0,0 +1,174 @@
+/*
+ * 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.protolayout.renderer.dynamicdata;
+
+import static androidx.core.util.Preconditions.checkNotNull;
+import static androidx.wear.protolayout.renderer.common.ProtoLayoutDiffer.FIRST_CHILD_INDEX;
+import static androidx.wear.protolayout.renderer.common.ProtoLayoutDiffer.createNodePosId;
+import static androidx.wear.protolayout.renderer.common.ProtoLayoutDiffer.getParentNodePosId;
+
+import android.util.ArrayMap;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.VisibleForTesting;
+import androidx.wear.protolayout.renderer.dynamicdata.PositionIdTree.TreeNode;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Consumer;
+import java.util.function.Predicate;
+
+/**
+ * A pseudo-tree structure for Layout nodes with Position Id. Note that the relation of each two
+ * nodes can be discovered through their position id.
+ *
+ * <p>NOTE: This class relies on strict ordering of the posIds. It's up to the caller to make sure
+ * there is never a missing posId between two sibling nodes.
+ *
+ * <p>This class is not thread-safe.
+ */
+final class PositionIdTree<T extends TreeNode> {
+
+    /** Interface for nodes stored in this tree. */
+    interface TreeNode {
+        /** Will be called after a node is removed from the tree. */
+        void destroy();
+    }
+
+    @NonNull private final Map<String, T> mPosIdToTreeNode = new ArrayMap<>();
+
+    /** Calls {@code action} on all of the tree nodes. */
+    void forEach(Consumer<T> action) {
+        mPosIdToTreeNode.values().forEach(action);
+    }
+
+    /** Removes all of the nodes in the tree and calls their {@link TreeNode#destroy()}. */
+    void clear() {
+        mPosIdToTreeNode.values().forEach(TreeNode::destroy);
+        mPosIdToTreeNode.clear();
+    }
+
+    /**
+     * Removes all of the nodes in a subtree under the node with {@code posId}. This also calls the
+     * {@link TreeNode#destroy()} on all of the removed node. Note that the {@code posId} node won't
+     * be removed.
+     */
+    void removeChildNodesFor(@NonNull String posId) {
+        removeChildNodesFor(posId, /* removeRoot= */ false);
+    }
+
+    private void removeChildNodesFor(@NonNull String posId, boolean removeRoot) {
+        for (int childIndex = FIRST_CHILD_INDEX; ; childIndex++) {
+            String possibleChildPosId = createNodePosId(posId, childIndex);
+            if (!mPosIdToTreeNode.containsKey(possibleChildPosId)) {
+                break;
+            }
+            removeChildNodesFor(possibleChildPosId, /* removeRoot= */ true);
+        }
+        if (removeRoot) {
+            checkNotNull(mPosIdToTreeNode.remove(posId)).destroy();
+        }
+    }
+
+    /**
+     * Adds the {@code newNode} to the tree. If the tree already contains a node at that position,
+     * the old node will be removed and will be destroyed.
+     */
+    void addOrReplace(@NonNull String posId, @NonNull T newNode) {
+        T oldNode = mPosIdToTreeNode.put(posId, newNode);
+        if (oldNode != null) {
+            oldNode.destroy();
+        }
+    }
+
+    /** Returns the node matching the {@code predicate} or an null if there is no match. */
+    @Nullable
+    T findFirst(@NonNull Predicate<? super T> predicate) {
+        return mPosIdToTreeNode.values().stream().filter(predicate).findFirst().orElse(null);
+    }
+
+    /** Returns the node with {@code posId} or null if it doesn't exist. */
+    @Nullable
+    T get(String posId) {
+        return mPosIdToTreeNode.get(posId);
+    }
+
+    /**
+     * Returns all of the ancestors of the node with {@code posId} matching the {@code predicate}.
+     */
+    @NonNull
+    List<T> findAncestorsFor(@NonNull String posId, @NonNull Predicate<? super T> predicate) {
+        List<T> result = new ArrayList<>();
+        while (true) {
+            String parentPosId = getParentNodePosId(posId);
+            if (parentPosId == null) {
+                break;
+            }
+            T value = mPosIdToTreeNode.get(parentPosId);
+            if (value != null && predicate.test(value)) {
+                result.add(value);
+            }
+            posId = parentPosId;
+        }
+        return result;
+    }
+
+    /** Returns all of the nodes in a subtree under the node with {@code posId}. */
+    @NonNull
+    List<T> findChildrenFor(@NonNull String posId) {
+        return findChildrenFor(posId, node -> true);
+    }
+
+    /**
+     * Returns all of the nodes in a subtree under the node with {@code posId} matching the {@code
+     * predicate}.
+     */
+    @NonNull
+    List<T> findChildrenFor(@NonNull String posId, @NonNull Predicate<? super T> predicate) {
+        List<T> result = new ArrayList<>();
+        addChildrenFor(posId, predicate, result);
+        return result;
+    }
+
+    private void addChildrenFor(
+            @NonNull String posId,
+            @NonNull Predicate<? super T> predicate,
+            @NonNull List<T> result) {
+        for (int childIndex = FIRST_CHILD_INDEX; ; childIndex++) {
+            String possibleChildPosId = createNodePosId(posId, childIndex);
+            if (!mPosIdToTreeNode.containsKey(possibleChildPosId)) {
+                break;
+            }
+            T value = mPosIdToTreeNode.get(possibleChildPosId);
+            if (value != null && predicate.test(value)) {
+                result.add(value);
+            }
+            addChildrenFor(possibleChildPosId, predicate, result);
+        }
+    }
+
+    /** Returns all of the current tree nodes. This is intended to be used only in tests. */
+    @VisibleForTesting(otherwise = VisibleForTesting.NONE)
+    @NonNull
+    Collection<T> getAllNodes() {
+        return Collections.unmodifiableCollection(mPosIdToTreeNode.values());
+    }
+}
diff --git a/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/dynamicdata/ProtoLayoutDynamicDataPipeline.java b/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/dynamicdata/ProtoLayoutDynamicDataPipeline.java
new file mode 100644
index 0000000..3f2e359
--- /dev/null
+++ b/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/dynamicdata/ProtoLayoutDynamicDataPipeline.java
@@ -0,0 +1,1153 @@
+/*
+ * 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.protolayout.renderer.dynamicdata;
+
+import static androidx.core.util.Preconditions.checkNotNull;
+
+import static java.lang.Math.max;
+import static java.lang.Math.min;
+
+import android.annotation.SuppressLint;
+import android.graphics.drawable.AnimatedVectorDrawable;
+import android.icu.util.ULocale;
+import android.util.Log;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.animation.AnimationSet;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.RestrictTo;
+import androidx.annotation.RestrictTo.Scope;
+import androidx.annotation.UiThread;
+import androidx.annotation.VisibleForTesting;
+import androidx.collection.ArrayMap;
+import androidx.collection.ArraySet;
+import androidx.vectordrawable.graphics.drawable.SeekableAnimatedVectorDrawable;
+import androidx.wear.protolayout.expression.pipeline.BoundDynamicType;
+import androidx.wear.protolayout.expression.pipeline.DynamicTypeEvaluator;
+import androidx.wear.protolayout.expression.pipeline.DynamicTypeValueReceiver;
+import androidx.wear.protolayout.expression.pipeline.FixedQuotaManagerImpl;
+import androidx.wear.protolayout.expression.pipeline.ObservableStateStore;
+import androidx.wear.protolayout.expression.pipeline.QuotaManager;
+import androidx.wear.protolayout.expression.pipeline.sensor.SensorGateway;
+import androidx.wear.protolayout.expression.proto.DynamicProto.DynamicBool;
+import androidx.wear.protolayout.expression.proto.DynamicProto.DynamicColor;
+import androidx.wear.protolayout.expression.proto.DynamicProto.DynamicFloat;
+import androidx.wear.protolayout.expression.proto.DynamicProto.DynamicInt32;
+import androidx.wear.protolayout.expression.proto.DynamicProto.DynamicString;
+import androidx.wear.protolayout.proto.ColorProto.ColorProp;
+import androidx.wear.protolayout.proto.DimensionProto.DegreesProp;
+import androidx.wear.protolayout.proto.DimensionProto.DpProp;
+import androidx.wear.protolayout.proto.ModifiersProto.AnimatedVisibility;
+import androidx.wear.protolayout.proto.ModifiersProto.EnterTransition;
+import androidx.wear.protolayout.proto.ModifiersProto.ExitTransition;
+import androidx.wear.protolayout.proto.TriggerProto.Trigger;
+import androidx.wear.protolayout.renderer.dynamicdata.NodeInfo.ResolvedAvd;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Set;
+import java.util.function.BiFunction;
+import java.util.function.Consumer;
+import java.util.function.Predicate;
+
+/**
+ * Pipeline for dynamic data.
+ *
+ * <p>Given a dynamic ProtoLayout data source, this builds up a {@link BoundDynamicType}, which can
+ * source the required data, and transform it into its final form.
+ *
+ * @hide
+ */
+@RestrictTo(Scope.LIBRARY_GROUP)
+public class ProtoLayoutDynamicDataPipeline {
+    @NonNull private static final String TAG = "DynamicDataPipeline";
+
+    @NonNull
+    private static final QuotaManager DISABLED_ANIMATIONS_QUOTA_MANAGER =
+            new FixedQuotaManagerImpl(/* quotaCap= */ 0);
+
+    @NonNull final PositionIdTree<NodeInfo> mPositionIdTree = new PositionIdTree<>();
+    @NonNull final List<QuotaAwareAnimationSet> mEnterAnimations = new ArrayList<>();
+    @NonNull final List<QuotaAwareAnimationSet> mExitAnimations = new ArrayList<>();
+    final boolean mEnableAnimations;
+    boolean mFullyVisible;
+    @NonNull final QuotaManager mAnimationQuotaManager;
+    @NonNull private final DynamicTypeEvaluator mEvaluator;
+
+    /**
+     * Creates a {@link ProtoLayoutDynamicDataPipeline} without animation support.
+     *
+     * @hide
+     */
+    @RestrictTo(Scope.LIBRARY_GROUP)
+    public ProtoLayoutDynamicDataPipeline(
+            boolean canUpdateGateways,
+            @Nullable SensorGateway sensorGateway,
+            @NonNull ObservableStateStore stateStore) {
+        // Build pipeline with quota that doesn't allow any animations.
+        this(
+                canUpdateGateways,
+                sensorGateway,
+                stateStore,
+                /* enableAnimations= */ false,
+                DISABLED_ANIMATIONS_QUOTA_MANAGER);
+    }
+
+    /**
+     * Creates a {@link ProtoLayoutDynamicDataPipeline} with animation support. Maximum number of
+     * concurrently running animations is defined in the given {@link QuotaManager}.
+     *
+     * @hide
+     */
+    @RestrictTo(Scope.LIBRARY_GROUP)
+    public ProtoLayoutDynamicDataPipeline(
+            boolean canUpdateGateways,
+            @Nullable SensorGateway sensorGateway,
+            @NonNull ObservableStateStore stateStore,
+            @NonNull QuotaManager animationQuotaManager) {
+        this(
+                canUpdateGateways,
+                sensorGateway,
+                stateStore,
+                /* enableAnimations= */ true,
+                animationQuotaManager);
+    }
+
+    /** Creates a {@link ProtoLayoutDynamicDataPipeline}. */
+    private ProtoLayoutDynamicDataPipeline(
+            boolean canUpdateGateways,
+            @Nullable SensorGateway sensorGateway,
+            @NonNull ObservableStateStore stateStore,
+            boolean enableAnimations,
+            @NonNull QuotaManager animationQuotaManager) {
+        this.mEnableAnimations = enableAnimations;
+        this.mAnimationQuotaManager = animationQuotaManager;
+        if (enableAnimations) {
+            this.mEvaluator =
+                    new DynamicTypeEvaluator(
+                            canUpdateGateways, sensorGateway, stateStore, animationQuotaManager);
+        } else {
+            this.mEvaluator =
+                    new DynamicTypeEvaluator(canUpdateGateways, sensorGateway, stateStore);
+        }
+    }
+
+    /**
+     * Returns the number of active dynamic types in this pipeline.
+     *
+     * @hide
+     */
+    @VisibleForTesting(otherwise = VisibleForTesting.NONE)
+    @RestrictTo(Scope.TESTS)
+    public int size() {
+        return mPositionIdTree.getAllNodes().stream().mapToInt(NodeInfo::size).sum();
+    }
+
+    @UiThread
+    void clear() {
+        mPositionIdTree.clear();
+    }
+
+    /** Removes all nodes that are descendants of {@code posId}. */
+    @UiThread
+    void removeChildNodesFor(@NonNull String posId) {
+        mPositionIdTree.removeChildNodesFor(posId);
+    }
+
+    /**
+     * Build {@link PipelineMaker}.
+     *
+     * @hide
+     */
+    @NonNull
+    @RestrictTo(Scope.LIBRARY_GROUP)
+    public PipelineMaker newPipelineMaker(
+            @NonNull BiFunction<EnterTransition, View, AnimationSet> enterAnimationInflator,
+            @NonNull BiFunction<ExitTransition, View, AnimationSet> exitAnimationInflator) {
+        return new PipelineMaker(this, enterAnimationInflator, exitAnimationInflator, mEvaluator);
+    }
+
+    /**
+     * Test version of the {@link #newPipelineMaker(BiFunction, BiFunction)} without animation
+     * inflators.
+     *
+     * @hide
+     */
+    @VisibleForTesting
+    @NonNull
+    @RestrictTo(Scope.TESTS)
+    public PipelineMaker newPipelineMaker() {
+        return newPipelineMaker(
+                (enterTransition, view) -> new AnimationSet(/* shareInterpolator= */ false),
+                (exitTransition, view) -> new AnimationSet(/* shareInterpolator= */ false));
+    }
+
+    /**
+     * Sets whether this proto layout can perform updates. If the proto layout cannot update, then
+     * updates through the data pipeline (e.g. health updates) will be suppressed.
+     *
+     * @hide
+     */
+    @UiThread
+    @SuppressWarnings("RestrictTo")
+    @RestrictTo(Scope.LIBRARY_GROUP)
+    public void setUpdatesEnabled(boolean canUpdate) {
+        if (canUpdate) {
+            mEvaluator.enablePlatformDataSources();
+        } else {
+            mEvaluator.disablePlatformDataSources();
+        }
+    }
+
+    /**
+     * Closes existing gateways.
+     *
+     * @hide
+     */
+    @RestrictTo(Scope.LIBRARY_GROUP)
+    @SuppressWarnings("RestrictTo")
+    public void close() {
+        mEvaluator.close();
+    }
+
+    /**
+     * PipelineMaker for a dynamic data pipeline.
+     *
+     * <p>Given a dynamic ProtoLayout data source, this creates a sequence of
+     * {@link BoundDynamicType} instances, which can source the required data, and transform it
+     * into its final form.
+     *
+     * <p>The nodes are accumulated and can be committed to the pipeline.
+     *
+     * <p>Note that this class is not thread-safe.
+     *
+     * @hide
+     */
+    @RestrictTo(Scope.LIBRARY_GROUP)
+    public static final class PipelineMaker {
+        @NonNull private final ProtoLayoutDynamicDataPipeline mPipeline;
+
+        @NonNull
+        private final BiFunction<EnterTransition, View, AnimationSet> mEnterAnimationInflator;
+
+        @NonNull
+        private final BiFunction<ExitTransition, View, AnimationSet> mExitAnimationInflator;
+
+        // Stores pending nodes that are committed to the pipeline after a successful layout update.
+        @NonNull private final Map<String, NodeInfo> mPosIdToNodeInfo = new ArrayMap<>();
+        @NonNull private final List<String> mNodesPendingChildrenRemoval = new ArrayList<>();
+        @NonNull private final Set<String> mChangedNodes = new ArraySet<>();
+        @NonNull private final Set<String> mParentsOfChangedNodes = new ArraySet<>();
+        @NonNull private final DynamicTypeEvaluator mEvaluator;
+        private int mExitAnimationsCounter = 0;
+
+        PipelineMaker(
+                @NonNull ProtoLayoutDynamicDataPipeline pipeline,
+                @NonNull BiFunction<EnterTransition, View, AnimationSet> enterAnimationInflator,
+                @NonNull BiFunction<ExitTransition, View, AnimationSet> exitAnimationInflator,
+                @NonNull DynamicTypeEvaluator evaluator) {
+            this.mPipeline = pipeline;
+            this.mEnterAnimationInflator = enterAnimationInflator;
+            this.mExitAnimationInflator = exitAnimationInflator;
+            this.mEvaluator = evaluator;
+        }
+
+        /**
+         * Clears the current data in the {@link ProtoLayoutDynamicDataPipeline} instance that was
+         * used to create this and then commits any stored changes.
+         *
+         * @param parentView The parent view these nodes are being inflated into. This will be used
+         *     for content transition animations.
+         * @param isReattaching if True, this layout is being reattached and will skip content
+         *     transition animations.
+         * @hide
+         */
+        @RestrictTo(Scope.LIBRARY_GROUP)
+        @UiThread
+        public void clearDataPipelineAndCommit(
+                @NonNull ViewGroup parentView, boolean isReattaching) {
+            this.mPipeline.clear();
+            this.commit(parentView, isReattaching);
+        }
+
+        /**
+         * Plays Exit animations. This method should be called while {@code parentView} still
+         * corresponds to the previous layout. Any subsequent change to the layout should be
+         * schedule through the {@code onEnd} callback.
+         *
+         * @param parentView The parent view these nodes are being inflated into. Note that it
+         *     should be attached to a window (and has gone through its layout passes).
+         * @param isReattaching if True, this layout is being reattached and will skip content
+         *     transition animations.
+         * @param onEnd the callback to execute after all Exit animations have finished.
+         * @hide
+         */
+        @UiThread
+        @RestrictTo(Scope.LIBRARY_GROUP)
+        public void playExitAnimations(
+                @NonNull ViewGroup parentView, boolean isReattaching, @Nullable Runnable onEnd) {
+            mPipeline.cancelContentTransitionAnimations();
+
+            if (!isReattaching && mPipeline.mFullyVisible && mPipeline.mEnableAnimations) {
+                Map<String, ExitTransition> animatingNodes = new ArrayMap<>();
+                for (String parentOfChangedNodes : mParentsOfChangedNodes) {
+                    mPipeline
+                            .mPositionIdTree
+                            .findChildrenFor(parentOfChangedNodes)
+                            .forEach(
+                                    node ->
+                                            addAffectedExitAnimations(
+                                                    node.getPosId(), animatingNodes));
+                }
+                for (String changedNode : mChangedNodes) {
+                    addAffectedExitAnimations(changedNode, animatingNodes);
+                }
+                mExitAnimationsCounter = 0;
+                for (Map.Entry<String, ExitTransition> animatingNode : animatingNodes.entrySet()) {
+                    View associatedView = parentView.findViewWithTag(animatingNode.getKey());
+                    if (associatedView != null) {
+                        AnimationSet animationSet =
+                                mExitAnimationInflator.apply(
+                                        checkNotNull(animatingNode.getValue()), associatedView);
+                        if (animationSet != null && !animationSet.getAnimations().isEmpty()) {
+                            QuotaAwareAnimationSet quotaAwareAnimationSet =
+                                    new QuotaAwareAnimationSet(
+                                            animationSet,
+                                            mPipeline.mAnimationQuotaManager,
+                                            associatedView,
+                                            () -> {
+                                                if (onEnd != null) {
+                                                    mExitAnimationsCounter--;
+                                                    if (mExitAnimationsCounter == 0) {
+                                                        mPipeline.mExitAnimations.clear();
+                                                        onEnd.run();
+                                                    }
+                                                }
+                                            });
+                            quotaAwareAnimationSet.tryStartAnimation(
+                                    () -> {
+                                        mExitAnimationsCounter++;
+                                        mPipeline.mExitAnimations.add(quotaAwareAnimationSet);
+                                    });
+                        }
+                    }
+                }
+            }
+            if (mPipeline.mExitAnimations.isEmpty() && onEnd != null) {
+                // No exit animations.
+                onEnd.run();
+            }
+        }
+
+        private void addAffectedExitAnimations(
+                @NonNull String changedNode, @NonNull Map<String, ExitTransition> animatingNodes) {
+            List<NodeInfo> nodesAffectedBy =
+                    mPipeline.getNodesAffectedBy(
+                            changedNode,
+                            node -> {
+                                AnimatedVisibility animatedVisibility =
+                                        node.getAnimatedVisibility();
+                                return animatedVisibility != null
+                                        && animatedVisibility.hasExitTransition();
+                            });
+            for (NodeInfo affectedNode : nodesAffectedBy) {
+                animatingNodes.putIfAbsent(
+                        affectedNode.getPosId(),
+                        checkNotNull(affectedNode.getAnimatedVisibility()).getExitTransition());
+            }
+        }
+
+        /**
+         * Commits any stored changes into the {@link ProtoLayoutDynamicDataPipeline} instance that
+         * was used to create this. This replaces any already available node and should be called
+         * only once per layout update.
+         *
+         * @param parentView The parent view these nodes are being inflated into. This will be used
+         *     for Enter animations. If this view is not attached to a window, the animations (and
+         *     the rest of pipeline init) will be scheduled to run when the view attaches to a
+         *     window later
+         * @param isReattaching if True, this layout is being reattached and will skip content
+         *     transition animations.
+         * @hide
+         */
+        @UiThread
+        @RestrictTo(Scope.LIBRARY_GROUP)
+        public void commit(@NonNull ViewGroup parentView, boolean isReattaching) {
+            for (String nodePosId : mNodesPendingChildrenRemoval) {
+                mPipeline.removeChildNodesFor(nodePosId);
+            }
+            mNodesPendingChildrenRemoval.clear();
+            for (Entry<String, NodeInfo> entry : mPosIdToNodeInfo.entrySet()) {
+                String key = entry.getKey();
+                if (key.isEmpty()) {
+                    Log.e(TAG, "Ignoring empty posId.");
+                    continue;
+                }
+                mPipeline.mPositionIdTree.addOrReplace(key, entry.getValue());
+            }
+
+            // in the modified levels.
+            if (isReattaching || !mPipeline.mFullyVisible) {
+                // Skip content transition animations.
+                mChangedNodes.clear();
+            }
+            parentView.post(
+                    () -> {
+                        mPipeline.initNewLayout();
+                        playEnterAnimations(parentView, isReattaching);
+                    });
+        }
+
+        @UiThread
+        private void playEnterAnimations(@NonNull ViewGroup parentView, boolean isReattaching) {
+            // Cancel any already running Enter animation.
+            mPipeline.mEnterAnimations.forEach(QuotaAwareAnimationSet::cancelAnimations);
+            mPipeline.mEnterAnimations.clear();
+
+            if (isReattaching || !mPipeline.mFullyVisible || !mPipeline.mEnableAnimations) {
+                return;
+            }
+            Map<String, EnterTransition> animatingNodes = new ArrayMap<>();
+            for (String changedNode : mChangedNodes) {
+                List<NodeInfo> nodesAffectedBy =
+                        mPipeline.getNodesAffectedBy(
+                                changedNode,
+                                node -> {
+                                    AnimatedVisibility animatedVisibility =
+                                            node.getAnimatedVisibility();
+                                    return animatedVisibility != null
+                                            && animatedVisibility.hasEnterTransition();
+                                });
+                for (NodeInfo affectedNode : nodesAffectedBy) {
+                    animatingNodes.putIfAbsent(
+                            affectedNode.getPosId(),
+                            checkNotNull(affectedNode.getAnimatedVisibility())
+                                    .getEnterTransition());
+                }
+            }
+            for (Map.Entry<String, EnterTransition> animatingNode : animatingNodes.entrySet()) {
+                View associatedView = parentView.findViewWithTag(animatingNode.getKey());
+                if (associatedView != null) {
+                    AnimationSet animationSet =
+                            mEnterAnimationInflator.apply(
+                                    checkNotNull(animatingNode.getValue()), associatedView);
+
+                    if (animationSet != null && !animationSet.getAnimations().isEmpty()) {
+                        QuotaAwareAnimationSet quotaAwareAnimationSet =
+                                new QuotaAwareAnimationSet(
+                                        animationSet,
+                                        mPipeline.mAnimationQuotaManager,
+                                        associatedView);
+                        quotaAwareAnimationSet.tryStartAnimation(
+                                () -> mPipeline.mEnterAnimations.add(quotaAwareAnimationSet));
+                    }
+                }
+            }
+        }
+
+        @NonNull
+        private NodeInfo getNodeInfo(@NonNull String posId) {
+            return mPosIdToNodeInfo.computeIfAbsent(
+                    posId, k -> new NodeInfo(posId, mPipeline.mAnimationQuotaManager));
+        }
+
+        /**
+         * Add the given source to the pipeline for future evaluation. Evaluation will start when
+         * {@link PipelineMaker} is committed with {@link PipelineMaker#commit}.
+         *
+         * @hide
+         */
+        @RestrictTo(Scope.LIBRARY_GROUP)
+        @SuppressWarnings("RestrictTo")
+        @NonNull
+        public PipelineMaker addPipelineFor(
+                @NonNull DynamicString stringSource,
+                @NonNull Locale locale,
+                @NonNull String posId,
+                @NonNull DynamicTypeValueReceiver<String> consumer) {
+            BoundDynamicType node =
+                    mEvaluator.bind(stringSource, ULocale.forLocale(locale), consumer);
+            getNodeInfo(posId).addBoundType(node);
+            return this;
+        }
+
+        /**
+         * Add the given source to the pipeline for future evaluation. Evaluation will start when
+         * {@link PipelineMaker} is committed with {@link PipelineMaker#commit}.
+         *
+         * @hide
+         */
+        @RestrictTo(Scope.LIBRARY_GROUP)
+        @NonNull
+        public PipelineMaker addPipelineFor(
+                @NonNull DynamicInt32 int32Source,
+                int invalidData,
+                @NonNull String posId,
+                @NonNull Consumer<Integer> consumer) {
+            return addPipelineFor(
+                    int32Source, posId, buildStateUpdateCallback(invalidData, consumer));
+        }
+
+        /**
+         * Add the given source to the pipeline for future evaluation. Evaluation will start when
+         * {@link PipelineMaker} is committed with {@link PipelineMaker#commit}.
+         *
+         * @hide
+         */
+        @RestrictTo(Scope.LIBRARY_GROUP)
+        @SuppressWarnings("RestrictTo")
+        @NonNull
+        public PipelineMaker addPipelineFor(
+                @NonNull DynamicInt32 int32Source,
+                @NonNull String posId,
+                @NonNull DynamicTypeValueReceiver<Integer> consumer) {
+            BoundDynamicType node = mEvaluator.bind(int32Source, consumer);
+            getNodeInfo(posId).addBoundType(node);
+            return this;
+        }
+
+        /**
+         * Add the given source to the pipeline for future evaluation. Evaluation will start when
+         * {@link PipelineMaker} is committed with {@link PipelineMaker#commit}.
+         *
+         * @hide
+         */
+        @RestrictTo(Scope.LIBRARY_GROUP)
+        @NonNull
+        public PipelineMaker addPipelineFor(
+                @NonNull DynamicString stringSource,
+                @NonNull String invalidData,
+                @NonNull Locale locale,
+                @NonNull String posId,
+                @NonNull Consumer<String> consumer) {
+            return addPipelineFor(
+                    stringSource, locale, posId, buildStateUpdateCallback(invalidData, consumer));
+        }
+
+        /**
+         * Add the given source to the pipeline for future evaluation. Evaluation will start when
+         * {@link PipelineMaker} is committed with {@link PipelineMaker#commit}.
+         *
+         * @hide
+         */
+        @RestrictTo(Scope.LIBRARY_GROUP)
+        @SuppressWarnings("RestrictTo")
+        @NonNull
+        public PipelineMaker addPipelineFor(
+                @NonNull DynamicFloat floatSource,
+                @NonNull String posId,
+                @NonNull DynamicTypeValueReceiver<Float> consumer) {
+            BoundDynamicType node = mEvaluator.bind(floatSource, consumer);
+            getNodeInfo(posId).addBoundType(node);
+            return this;
+        }
+
+        /**
+         * Add the given source to the pipeline for future evaluation. Evaluation will start when
+         * {@link PipelineMaker} is committed with {@link PipelineMaker#commit}.
+         *
+         * @hide
+         */
+        @RestrictTo(Scope.LIBRARY_GROUP)
+        @NonNull
+        public PipelineMaker addPipelineFor(
+                @NonNull DynamicFloat floatSource,
+                float invalidData,
+                @NonNull String posId,
+                @NonNull Consumer<Float> consumer) {
+            return addPipelineFor(
+                    floatSource, posId, buildStateUpdateCallback(invalidData, consumer));
+        }
+
+        /**
+         * Add the given source to the pipeline for future evaluation. Evaluation will start when
+         * {@link PipelineMaker} is committed with {@link PipelineMaker#commit}.
+         *
+         * @hide
+         */
+        @RestrictTo(Scope.LIBRARY_GROUP)
+        @SuppressWarnings("RestrictTo")
+        @NonNull
+        public PipelineMaker addPipelineFor(
+                @NonNull DynamicColor colorSource,
+                @NonNull String posId,
+                @NonNull DynamicTypeValueReceiver<Integer> consumer) {
+            BoundDynamicType node = mEvaluator.bind(colorSource, consumer);
+            getNodeInfo(posId).addBoundType(node);
+            return this;
+        }
+
+        /**
+         * Add the given source to the pipeline for future evaluation. Evaluation will start when
+         * {@link PipelineMaker} is committed with {@link PipelineMaker#commit}.
+         *
+         * @hide
+         */
+        @RestrictTo(Scope.LIBRARY_GROUP)
+        @NonNull
+        public PipelineMaker addPipelineFor(
+                @NonNull DynamicColor colorSource,
+                int invalidData,
+                @NonNull String posId,
+                @NonNull Consumer<Integer> consumer) {
+            return addPipelineFor(
+                    colorSource, posId, buildStateUpdateCallback(invalidData, consumer));
+        }
+
+        /**
+         * Add the given source to the pipeline for future evaluation. Evaluation will start when
+         * {@link PipelineMaker} is committed with {@link PipelineMaker#commit}.
+         *
+         * @hide
+         */
+        @RestrictTo(Scope.LIBRARY_GROUP)
+        @NonNull
+        public PipelineMaker addPipelineFor(
+                @NonNull DynamicBool boolSource,
+                @NonNull String posId,
+                @NonNull Runnable triggerAnimationRunnable) {
+            DynamicTypeValueReceiver<Boolean> consumer =
+                    buildBooleanConditionTriggerCallback(
+                            triggerAnimationRunnable, mPipeline.mAnimationQuotaManager);
+            return addPipelineFor(boolSource, posId, consumer);
+        }
+
+        /**
+         * Add the given source to the pipeline for future evaluation. Evaluation will start when
+         * {@link PipelineMaker} is committed with {@link PipelineMaker#commit}.
+         *
+         * @hide
+         */
+        @RestrictTo(Scope.LIBRARY_GROUP)
+        @SuppressWarnings("RestrictTo")
+        @NonNull
+        public PipelineMaker addPipelineFor(
+                @NonNull DynamicBool boolSource,
+                @NonNull String posId,
+                @NonNull DynamicTypeValueReceiver<Boolean> consumer) {
+            BoundDynamicType node = mEvaluator.bind(boolSource, consumer);
+            getNodeInfo(posId).addBoundType(node);
+            return this;
+        }
+
+        /**
+         * Add the given source to the pipeline for future evaluation. Evaluation will start when
+         * {@link PipelineMaker} is committed with {@link PipelineMaker#commit}.
+         *
+         * @hide
+         */
+        @RestrictTo(Scope.LIBRARY_GROUP)
+        @NonNull
+        public PipelineMaker addPipelineFor(
+                @NonNull DynamicBool boolSource,
+                boolean invalidData,
+                @NonNull String posId,
+                @NonNull Consumer<Boolean> consumer) {
+            return addPipelineFor(
+                    boolSource, posId, buildStateUpdateCallback(invalidData, consumer));
+        }
+
+        /**
+         * Add the given source to the pipeline for future evaluation. Evaluation will start when
+         * {@link PipelineMaker} is committed with {@link PipelineMaker#commit}.
+         *
+         * @hide
+         */
+        @SuppressWarnings("RestrictTo")
+        @NonNull
+        @RestrictTo(Scope.LIBRARY_GROUP)
+        public PipelineMaker addPipelineFor(
+                @NonNull DpProp dpProp,
+                @NonNull String posId,
+                @NonNull DynamicTypeValueReceiver<Float> consumer) {
+            BoundDynamicType node;
+            if (dpProp.hasValue()) {
+                node = mEvaluator.bind(dpProp.getDynamicValue(), consumer, dpProp.getValue());
+            } else {
+                node = mEvaluator.bind(dpProp.getDynamicValue(), consumer);
+            }
+            getNodeInfo(posId).addBoundType(node);
+            return this;
+        }
+
+        /**
+         * Add the given source to the pipeline for future evaluation. Evaluation will start when
+         * {@link PipelineMaker} is committed with {@link PipelineMaker#commit}.
+         *
+         * @hide
+         */
+        @RestrictTo(Scope.LIBRARY_GROUP)
+        @SuppressWarnings("RestrictTo")
+        @NonNull
+        public PipelineMaker addPipelineFor(
+                @NonNull DegreesProp degreesProp,
+                @NonNull String posId,
+                @NonNull DynamicTypeValueReceiver<Float> consumer) {
+            BoundDynamicType node;
+            if (degreesProp.hasValue()) {
+                node =
+                        mEvaluator.bind(
+                                degreesProp.getDynamicValue(), consumer, degreesProp.getValue());
+            } else {
+                node = mEvaluator.bind(degreesProp.getDynamicValue(), consumer);
+            }
+            getNodeInfo(posId).addBoundType(node);
+            return this;
+        }
+
+        /**
+         * Add the given source to the pipeline for future evaluation. Evaluation will start when
+         * {@link PipelineMaker} is committed with {@link PipelineMaker#commit}.
+         *
+         * @hide
+         */
+        @RestrictTo(Scope.LIBRARY_GROUP)
+        @SuppressWarnings("RestrictTo")
+        @NonNull
+        public PipelineMaker addPipelineFor(
+                @NonNull ColorProp colorProp,
+                @NonNull String posId,
+                @NonNull DynamicTypeValueReceiver<Integer> consumer) {
+            BoundDynamicType node;
+            if (colorProp.hasArgb()) {
+                node = mEvaluator.bind(colorProp.getDynamicValue(), consumer, colorProp.getArgb());
+            } else {
+                node = mEvaluator.bind(colorProp.getDynamicValue(), consumer);
+            }
+            getNodeInfo(posId).addBoundType(node);
+            return this;
+        }
+
+        /**
+         * Add the given source to the pipeline for future evaluation. Evaluation will start when
+         * {@link PipelineMaker} is committed with {@link PipelineMaker#commit}.
+         *
+         * @hide
+         */
+        @RestrictTo(Scope.LIBRARY_GROUP)
+        @SuppressWarnings("RestrictTo")
+        @NonNull
+        public PipelineMaker addPipelineFor(
+                @NonNull DpProp dpProp,
+                float invalidData,
+                @NonNull String posId,
+                @NonNull Consumer<Float> consumer) {
+            return addPipelineFor(dpProp, posId, buildStateUpdateCallback(invalidData, consumer));
+        }
+
+        /**
+         * Add the given source to the pipeline for future evaluation. Evaluation will start when
+         * {@link PipelineMaker} is committed with {@link PipelineMaker#commit}.
+         *
+         * @hide
+         */
+        @RestrictTo(Scope.LIBRARY_GROUP)
+        @SuppressWarnings("RestrictTo")
+        @NonNull
+        public PipelineMaker addPipelineFor(
+                @NonNull DegreesProp degreesProp,
+                float invalidData,
+                @NonNull String posId,
+                @NonNull Consumer<Float> consumer) {
+            return addPipelineFor(
+                    degreesProp, posId, buildStateUpdateCallback(invalidData, consumer));
+        }
+
+        /**
+         * Add the given source to the pipeline for future evaluation. Evaluation will start when
+         * {@link PipelineMaker} is committed with {@link PipelineMaker#commit}.
+         *
+         * @hide
+         */
+        @RestrictTo(Scope.LIBRARY_GROUP)
+        @SuppressWarnings("RestrictTo")
+        @NonNull
+        public PipelineMaker addPipelineFor(
+                @NonNull ColorProp colorProp,
+                int invalidData,
+                @NonNull String posId,
+                @NonNull Consumer<Integer> consumer) {
+            return addPipelineFor(
+                    colorProp, posId, buildStateUpdateCallback(invalidData, consumer));
+        }
+
+        /**
+         * This store method shall be called during the layout inflation in a background thread.
+         *
+         * @hide
+         */
+        @RestrictTo(Scope.LIBRARY_GROUP)
+        @SuppressLint("CheckReturnValue") // (b/247804720)
+        @NonNull
+        public PipelineMaker addResolvedAnimatedImage(
+                @NonNull AnimatedVectorDrawable drawable,
+                @NonNull Trigger trigger,
+                @NonNull String posId) {
+            if (!this.mPipeline.mEnableAnimations) {
+                Log.w(TAG, "Cannot use ResolvedAnimationImage; animations are disabled.");
+                return this;
+            }
+
+            getNodeInfo(posId).addResolvedAvd(drawable, trigger);
+            return this;
+        }
+
+        /**
+         * This store method shall be called during the layout inflation in a background thread. It
+         * adds given {@link DynamicBool} to the pipeline too.
+         *
+         * @hide
+         */
+        @RestrictTo(Scope.LIBRARY_GROUP)
+        @NonNull
+        public PipelineMaker addResolvedAnimatedImageWithBoolTrigger(
+                @NonNull AnimatedVectorDrawable drawable,
+                @NonNull Trigger trigger,
+                @NonNull String posId,
+                @NonNull DynamicBool boolTrigger) {
+            if (!this.mPipeline.mEnableAnimations) {
+                Log.w(TAG, "Cannot use ResolvedAnimationImage; animations are disabled.");
+                return this;
+            }
+
+            if (trigger.getInnerCase() != Trigger.InnerCase.ON_CONDITION_MET_TRIGGER) {
+                Log.w(TAG, "Wrong trigger type.");
+                return this;
+            }
+
+            ResolvedAvd avd = getNodeInfo(posId).addResolvedAvd(drawable, trigger);
+            addPipelineFor(boolTrigger, posId, avd::startAnimation);
+            return this;
+        }
+
+        /**
+         * This store method shall be called during the layout inflation in a background thread.
+         *
+         * @hide
+         */
+        @NonNull
+        @RestrictTo(Scope.LIBRARY_GROUP)
+        public PipelineMaker addResolvedSeekableAnimatedImage(
+                @NonNull SeekableAnimatedVectorDrawable seekableDrawable,
+                @NonNull DynamicFloat boundProgress,
+                @NonNull String posId) {
+            if (!this.mPipeline.mEnableAnimations) {
+                Log.w(TAG, "Cannot use ResolveSeekableAvd; animations are disabled.");
+                return this;
+            }
+
+            // Register the bound progress to the seekable animated drawable.
+            addPipelineFor(
+                    boundProgress,
+                    0.0f,
+                    posId,
+                    aFloat -> {
+                        float progress = max(0.0f, min(aFloat, 1.0f));
+                        seekableDrawable.setCurrentPlayTime(
+                                (long) (progress * seekableDrawable.getTotalDuration()));
+                    });
+            getNodeInfo(posId)
+                    .addResolvedSeekableAvd(
+                            new NodeInfo.ResolvedSeekableAvd(seekableDrawable, boundProgress));
+            return this;
+        }
+
+        /**
+         * Stores the {@link AnimatedVisibility} associated with the {@code posId}.
+         *
+         * @hide
+         */
+        @NonNull
+        @RestrictTo(Scope.LIBRARY_GROUP)
+        public PipelineMaker storeAnimatedVisibilityFor(
+                @NonNull String posId, @NonNull AnimatedVisibility animatedVisibility) {
+            if (!mPipeline.mEnableAnimations) {
+                Log.w(TAG, "Can't use AnimatedVisibility; animations are disabled.");
+                return this;
+            }
+            getNodeInfo(posId).setAnimatedVisibility(animatedVisibility);
+            return this;
+        }
+
+        /**
+         * Mark the node {@code posId} as changed. Content transition animations affected by this
+         * node will be triggered when the pipeline is committed.
+         *
+         * @param posId positionId of the node
+         * @param includePreviousChildren if True, the previous children of this node will be marked
+         *     as changed too. This is used for triggering Exit animations.
+         * @hide
+         */
+        @RestrictTo(Scope.LIBRARY_GROUP)
+        @NonNull
+        public PipelineMaker markNodeAsChanged(
+                @NonNull String posId, boolean includePreviousChildren) {
+            if (mPipeline.mEnableAnimations) {
+                mChangedNodes.add(posId);
+                mParentsOfChangedNodes.add(posId);
+            }
+            return this;
+        }
+
+        @NonNull
+        private static DynamicTypeValueReceiver<Boolean> buildBooleanConditionTriggerCallback(
+                @NonNull Runnable triggerAnimationRunnable, @NonNull QuotaManager quotaManager) {
+            return new DynamicTypeValueReceiver<Boolean>() {
+                private boolean mCurrent;
+
+                @Override
+                @SuppressWarnings("RestrictTo")
+                public void onPreUpdate() {}
+
+                @Override
+                public void onData(@NonNull Boolean newData) {
+
+                    if (newData && !mCurrent && quotaManager.tryAcquireQuota(1)) {
+                        triggerAnimationRunnable.run();
+                    }
+                    mCurrent = newData;
+                }
+
+                @Override
+                public void onInvalidated() {}
+            };
+        }
+
+        @NonNull
+        private <T> DynamicTypeValueReceiver<T> buildStateUpdateCallback(
+                @NonNull T invalidData, @NonNull Consumer<T> consumer) {
+            return new DynamicTypeValueReceiver<T>() {
+                @Override
+                @SuppressWarnings("RestrictTo")
+                public void onPreUpdate() {}
+
+                @Override
+                public void onData(@NonNull T newData) {
+                    consumer.accept(newData);
+                }
+
+                @Override
+                public void onInvalidated() {
+                    consumer.accept(invalidData);
+                }
+            };
+        }
+
+        /**
+         * Add the given source to the pipeline for future evaluation. Evaluation will start when
+         * {@link PipelineMaker} is committed with {@link PipelineMaker#commit}.
+         *
+         * @hide
+         */
+        @NonNull
+        @RestrictTo(Scope.LIBRARY_GROUP)
+        public PipelineMaker markForChildRemoval(@NonNull String nodePosId) {
+            mNodesPendingChildrenRemoval.add(nodePosId);
+            return this;
+        }
+
+        /**
+         * Stores a node if doesn't exist. Otherwise does nothing.
+         *
+         * @hide
+         */
+        @RestrictTo(Scope.LIBRARY_GROUP)
+        @NonNull
+        public PipelineMaker rememberNode(@NonNull String nodePosId) {
+            NodeInfo ignored = getNodeInfo(nodePosId);
+            return this;
+        }
+    }
+
+    /**
+     * Initialize the data pipeline without playing content transition animations. Normally this is
+     * called automatically when the parent {@link ViewGroup} associated with this pipeline is
+     * attached to a {@link View} hierarchy. This is so that the content transition animations can
+     * be executed before this (if needed).
+     *
+     * <p>This method can be called directly in screenshot tests and when the renderer output is
+     * never supposed to be attached to a window.
+     *
+     * @hide
+     */
+    @RestrictTo(Scope.LIBRARY_GROUP)
+    @UiThread
+    public void initWithoutContentTransition() {
+        initNewLayout();
+    }
+
+    /** Initialize the data pipeline after a new layout is pushed. */
+    @UiThread
+    @SuppressWarnings("RestrictTo")
+    void initNewLayout() {
+        if (mFullyVisible) {
+            playAvdAnimations(Trigger.InnerCase.ON_VISIBLE_TRIGGER);
+            playAvdAnimations(Trigger.InnerCase.ON_VISIBLE_ONCE_TRIGGER);
+        }
+        playAvdAnimations(Trigger.InnerCase.ON_LOAD_TRIGGER);
+        setAnimationVisibility(mFullyVisible);
+        mPositionIdTree.forEach(NodeInfo::initPendingBoundTypes);
+    }
+
+    /**
+     * Play the animation with the given trigger type.
+     *
+     * @hide
+     */
+    @RestrictTo(Scope.LIBRARY_GROUP)
+    @VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
+    public void playAvdAnimations(@NonNull Trigger.InnerCase triggerCase) {
+        mPositionIdTree.forEach(info -> info.playAvdAnimations(triggerCase));
+    }
+
+    /** Sets visibility of animations. Also pauses or resumes any animators. */
+    @UiThread
+    private void setAnimationVisibility(boolean visible) {
+        mPositionIdTree.forEach(info -> info.setVisibility(visible));
+    }
+
+    /**
+     * Reset the avd animations with the given trigger type.
+     *
+     * @hide
+     */
+    @UiThread
+    @VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
+    @RestrictTo(Scope.LIBRARY_GROUP)
+    public void resetAvdAnimations(@NonNull Trigger.InnerCase triggerCase) {
+        mPositionIdTree.forEach(info -> info.resetAvdAnimations(triggerCase));
+    }
+
+    /**
+     * Stops running avd animations and releases their quota.
+     *
+     * @hide
+     */
+    @UiThread
+    @VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
+    @RestrictTo(Scope.LIBRARY_GROUP)
+    public void stopAvdAnimations() {
+        mPositionIdTree.forEach(NodeInfo::stopAvdAnimations);
+    }
+
+    /** Cancel any already running content transition animations. */
+    @UiThread
+    void cancelContentTransitionAnimations() {
+        mExitAnimations.forEach(QuotaAwareAnimationSet::cancelAnimations);
+        mExitAnimations.clear();
+        mEnterAnimations.forEach(QuotaAwareAnimationSet::cancelAnimations);
+        mEnterAnimations.clear();
+    }
+
+    /**
+     * Sets visibility for resources tracked by the pipeline and plays / stops any affected
+     * animations.
+     *
+     * @hide
+     */
+    @RestrictTo(Scope.LIBRARY_GROUP)
+    @UiThread
+    public void setFullyVisible(boolean fullyVisible) {
+        if (this.mFullyVisible == fullyVisible) {
+            return;
+        }
+
+        this.mFullyVisible = fullyVisible;
+        setAnimationVisibility(fullyVisible);
+        if (fullyVisible) {
+            playAvdAnimations(Trigger.InnerCase.ON_VISIBLE_TRIGGER);
+            playAvdAnimations(Trigger.InnerCase.ON_VISIBLE_ONCE_TRIGGER);
+        } else {
+            cancelContentTransitionAnimations();
+            stopAvdAnimations();
+            resetAvdAnimations(Trigger.InnerCase.ON_VISIBLE_TRIGGER);
+        }
+    }
+
+    /**
+     * Returns the total duration in milliseconds of the animated drawable associated with a
+     * StateSource with the given key name; or null if no such SourceKey exists.
+     *
+     * @hide
+     */
+    @RestrictTo(Scope.LIBRARY_GROUP)
+    @Nullable
+    public Long getSeekableAnimationTotalDurationMillis(@NonNull String sourceKey) {
+        NodeInfo node =
+                mPositionIdTree.findFirst(
+                        nodeInfo ->
+                                nodeInfo.getSeekableAnimationTotalDurationMillis(sourceKey)
+                                        != null);
+        if (node != null) {
+            return node.getSeekableAnimationTotalDurationMillis(sourceKey);
+        }
+        return null;
+    }
+
+    /**
+     * Returns the list of nodes with matching {@code predicate} affected by a change to the node
+     * {@code posId}
+     */
+    @UiThread
+    @NonNull
+    List<NodeInfo> getNodesAffectedBy(
+            @NonNull String posId, @NonNull Predicate<NodeInfo> predicate) {
+        List<NodeInfo> affectedNodes = mPositionIdTree.findAncestorsFor(posId, predicate);
+        NodeInfo currentNode = mPositionIdTree.get(posId);
+        if (currentNode != null && predicate.test(currentNode)) {
+            affectedNodes.add(currentNode);
+        }
+
+        return affectedNodes;
+    }
+
+    /**
+     * Returns how many animations are running.
+     *
+     * @hide
+     */
+    @VisibleForTesting(otherwise = VisibleForTesting.NONE)
+    @RestrictTo(Scope.TESTS)
+    public int getRunningAnimationsCount() {
+        return mPositionIdTree.getAllNodes().stream()
+                        .mapToInt(NodeInfo::getRunningAnimationCount)
+                        .sum()
+                + mEnterAnimations.stream()
+                        .mapToInt(QuotaAwareAnimationSet::getRunningAnimationCount)
+                        .sum()
+                + mExitAnimations.stream()
+                        .mapToInt(QuotaAwareAnimationSet::getRunningAnimationCount)
+                        .sum();
+    }
+
+    /**
+     * Returns whether all quota has been released.
+     *
+     * @hide
+     */
+    @VisibleForTesting(otherwise = VisibleForTesting.NONE)
+    @RestrictTo(Scope.TESTS)
+    public boolean isAllQuotaReleased() {
+        return mAnimationQuotaManager instanceof FixedQuotaManagerImpl
+                && ((FixedQuotaManagerImpl) mAnimationQuotaManager).isAllQuotaReleased();
+    }
+}
diff --git a/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/dynamicdata/QuotaAwareAnimationSet.java b/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/dynamicdata/QuotaAwareAnimationSet.java
new file mode 100644
index 0000000..af2d843
--- /dev/null
+++ b/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/dynamicdata/QuotaAwareAnimationSet.java
@@ -0,0 +1,190 @@
+/*
+ * Copyright 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.wear.protolayout.renderer.dynamicdata;
+
+import android.os.Handler;
+import android.os.Looper;
+import android.view.View;
+import android.view.animation.Animation;
+import android.view.animation.Animation.AnimationListener;
+import android.view.animation.AnimationSet;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.UiThread;
+import androidx.core.os.HandlerCompat;
+import androidx.wear.protolayout.expression.pipeline.QuotaManager;
+
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/**
+ * Wrapper for AnimationSet that is aware of quota. Animation will be played only if given quota
+ * manager allows. Any existing listeners on wrapped {@link AnimationSet} will be replaced.
+ */
+final class QuotaAwareAnimationSet {
+    @NonNull private final AnimationSet mAnimationSet;
+    @NonNull private final QuotaManager mQuotaManager;
+
+    @NonNull private final View mAssociatedView;
+    @NonNull private final QuotaReleasingAnimationListener mListener;
+
+    @Nullable private final Runnable mOnAnimationEnd;
+    private final long mCommonDelay;
+    @NonNull private final Handler mUiHandler;
+
+    // Suppress initialization warnings here. These are only used inside of methods, and this class
+    // is final, so these cannot actually be referenced while the class is under initialization.
+    @SuppressWarnings("methodref.receiver.bound")
+    @NonNull
+    private final Runnable mTryAcquireQuotaAndStartAnimation =
+            this::tryAcquireQuotaAndStartAnimation;
+
+    QuotaAwareAnimationSet(
+            @NonNull AnimationSet animation,
+            @NonNull QuotaManager quotaManager,
+            @NonNull View associatedView) {
+        this(animation, quotaManager, associatedView, /* onAnimationEnd= */ null);
+    }
+
+    QuotaAwareAnimationSet(
+            @NonNull AnimationSet animation,
+            @NonNull QuotaManager quotaManager,
+            @NonNull View associatedView,
+            @Nullable Runnable onAnimationEnd) {
+        this.mAnimationSet = animation;
+        this.mQuotaManager = quotaManager;
+        this.mAssociatedView = associatedView;
+        this.mOnAnimationEnd = onAnimationEnd;
+        this.mUiHandler = new Handler(Looper.getMainLooper());
+
+        // AnimationSet contains multiple animation, of which each of them can have set start delay,
+        // that is offset. To prevent consuming quota before animation is due to be played, we're
+        // going to get the minimum starting offset among animations in the set and implement
+        // delaying starting animation set for that period of time. This way, quota will be consumed
+        // when the earliest animation in the set should be played. In order to preserve set delay
+        // in animations, each animation in the set will have their delay updated relatively to the
+        // minimum delay.
+
+        // Getting minimum offset
+        this.mCommonDelay =
+                mAnimationSet.getAnimations().stream()
+                        .mapToLong(Animation::getStartOffset)
+                        .min()
+                        .orElse(0L);
+
+        // Updating children offsets.
+        mAnimationSet
+                .getAnimations()
+                .forEach(anim -> anim.setStartOffset(anim.getStartOffset() - this.mCommonDelay));
+
+        mListener =
+                new QuotaReleasingAnimationListener(
+                        mQuotaManager, animation.getAnimations().size(), onAnimationEnd);
+        this.mAnimationSet.setAnimationListener(mListener);
+    }
+
+    /**
+     * Tries to start animations in the given set. Animation will try to start after the delay it
+     * has set.
+     *
+     * <p>The Runnables {@code beforeAnimationStart} and {@code onAnimationEnd} will still be run
+     * even if animation could not start due to quota being unavailable.
+     */
+    @UiThread
+    void tryStartAnimation(@NonNull Runnable beforeAnimationStart) {
+        // Don't start new animation if there are already running ones.
+        if (getRunningAnimationCount() > 0) {
+            return;
+        }
+
+        beforeAnimationStart.run();
+        // We are implementing start offset ourselves, because we don't want quota to be consumed
+        // before animation is running.
+        if (mCommonDelay > 0) {
+            if (!HandlerCompat.hasCallbacks(mUiHandler, mTryAcquireQuotaAndStartAnimation)) {
+                mUiHandler.postDelayed(mTryAcquireQuotaAndStartAnimation, mCommonDelay);
+            }
+        } else {
+            tryAcquireQuotaAndStartAnimation();
+        }
+    }
+
+    @UiThread
+    private void tryAcquireQuotaAndStartAnimation() {
+        if (mQuotaManager.tryAcquireQuota(mAnimationSet.getAnimations().size())) {
+            mListener.mIsUsingQuota.set(true);
+            mAssociatedView.startAnimation(mAnimationSet);
+        } else if (mOnAnimationEnd != null) {
+            mOnAnimationEnd.run();
+        }
+        // No need to jump to an end of animation, because if animation is not played, the changed
+        // node will be replaced in its place, the same way as if it'd be when content transition is
+        // not set.
+    }
+
+    /** Cancels all animation in this set and notifies the listener on the same thread. */
+    @UiThread
+    void cancelAnimations() {
+        mAnimationSet.cancel();
+        mListener.onAnimationEnd(mAnimationSet);
+        mUiHandler.removeCallbacks(mTryAcquireQuotaAndStartAnimation);
+    }
+
+    /** Returns the number of currently running animations. */
+    int getRunningAnimationCount() {
+        return (mAnimationSet.hasStarted() && !mAnimationSet.hasEnded())
+                ? mAnimationSet.getAnimations().size()
+                : 0;
+    }
+
+    private static final class QuotaReleasingAnimationListener implements AnimationListener {
+
+        @Nullable private final Runnable mOnAnimationEnd;
+        @NonNull private final QuotaManager mQuotaManager;
+        private final int mAnimationNum;
+        @NonNull final AtomicBoolean mIsUsingQuota = new AtomicBoolean(false);
+
+        QuotaReleasingAnimationListener(
+                @NonNull QuotaManager mQuotaManager,
+                int animationNum,
+                @Nullable Runnable mOnAnimationEnd) {
+            this.mOnAnimationEnd = mOnAnimationEnd;
+            this.mQuotaManager = mQuotaManager;
+            this.mAnimationNum = animationNum;
+        }
+
+        @Override
+        @UiThread
+        public void onAnimationStart(@NonNull Animation animation) {}
+
+        @Override
+        @UiThread
+        public void onAnimationEnd(@NonNull Animation animation) {
+            if (mIsUsingQuota.compareAndSet(true, false)) {
+                mQuotaManager.releaseQuota(mAnimationNum);
+
+                if (mOnAnimationEnd != null) {
+                    mOnAnimationEnd.run();
+                }
+            }
+        }
+
+        @Override
+        @UiThread
+        public void onAnimationRepeat(@NonNull Animation animation) {}
+    }
+}
diff --git a/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/inflater/ProtoLayoutThemeImpl.java b/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/inflater/ProtoLayoutThemeImpl.java
new file mode 100644
index 0000000..51d44c4
--- /dev/null
+++ b/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/inflater/ProtoLayoutThemeImpl.java
@@ -0,0 +1,194 @@
+/*
+ * 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.protolayout.renderer.inflater;
+
+import static androidx.core.util.Preconditions.checkNotNull;
+
+import android.content.Context;
+import android.content.res.Resources;
+import android.content.res.Resources.Theme;
+import android.content.res.TypedArray;
+import android.graphics.Typeface;
+import android.util.TypedValue;
+
+import androidx.annotation.AttrRes;
+import androidx.annotation.NonNull;
+import androidx.annotation.StyleRes;
+import androidx.annotation.StyleableRes;
+import androidx.collection.ArrayMap;
+import androidx.wear.protolayout.proto.LayoutElementProto.FontVariant;
+import androidx.wear.protolayout.renderer.ProtoLayoutTheme;
+import androidx.wear.protolayout.renderer.R;
+
+import java.util.Map;
+
+/** Theme customization for ProtoLayout texts, which includes Font types and variants. */
+public class ProtoLayoutThemeImpl implements ProtoLayoutTheme {
+
+    /** Holder for different weights of the same font variant. */
+    public static class FontSetImpl implements FontSet {
+        final Typeface mNormalFont;
+        final Typeface mMediumFont;
+        final Typeface mBoldFont;
+
+        FontSetImpl(@NonNull Theme theme, @StyleRes int style) {
+            TypedArray a = theme.obtainStyledAttributes(style, R.styleable.ProtoLayoutFontSet);
+            this.mNormalFont =
+                    loadTypeface(a, R.styleable.ProtoLayoutFontSet_protoLayoutNormalFont);
+            this.mMediumFont =
+                    loadTypeface(a, R.styleable.ProtoLayoutFontSet_protoLayoutMediumFont);
+            this.mBoldFont = loadTypeface(a, R.styleable.ProtoLayoutFontSet_protoLayoutBoldFont);
+            a.recycle();
+        }
+
+        private static Typeface loadTypeface(TypedArray array, @StyleableRes int styleableResId) {
+            // Resources are a little nasty; we can't just check if resType =
+            // TypedValue.TYPE_REFERENCE, because it never is (if you use @font/foo inside of
+            // styles.xml, the value will be a string of the form res/font/foo.ttf). Instead, see if
+            // there's a resource ID at all, and use that, otherwise assume it's a well known font
+            // family.
+            int resType = array.getType(styleableResId);
+
+            if (array.getResourceId(styleableResId, -1) != -1
+                    && array.getFont(styleableResId) != null) {
+                return checkNotNull(array.getFont(styleableResId));
+            } else if (resType == TypedValue.TYPE_STRING
+                    && array.getString(styleableResId) != null) {
+                // Load the normal typeface; we customise this into BOLD/ITALIC later in
+                // ProtoLayoutRenderer.
+                return Typeface.create(
+                        checkNotNull(array.getString(styleableResId)), Typeface.NORMAL);
+            } else {
+                throw new IllegalArgumentException("Unknown resource value type " + resType);
+            }
+        }
+
+        @Override
+        @NonNull
+        public Typeface getNormalFont() {
+            return mNormalFont;
+        }
+
+        @Override
+        @NonNull
+        public Typeface getMediumFont() {
+            return mMediumFont;
+        }
+
+        @Override
+        @NonNull
+        public Typeface getBoldFont() {
+            return mBoldFont;
+        }
+    }
+
+    /**
+     * Creates a ProtoLayoutTheme for the default theme, based on R.style.ProtoLayoutBaseTheme and
+     * R.attr.protoLayoutFallbackAppearance from the local package.
+     */
+    @NonNull
+    public static ProtoLayoutTheme defaultTheme(@NonNull Context context) {
+        return new ProtoLayoutThemeImpl(context.getResources(), R.style.ProtoLayoutBaseTheme);
+    }
+
+    /**
+     * Creates a ProtoLayoutTheme for the default theme, based on R.style.ProtoLayoutBaseTheme and
+     * R.attr.protoLayoutFallbackAppearance from the local package.
+     */
+    @NonNull
+    public static ProtoLayoutTheme defaultTheme(@NonNull Resources resources) {
+        return new ProtoLayoutThemeImpl(resources, R.style.ProtoLayoutBaseTheme);
+    }
+
+    private final Map<Integer, FontSet> mVariantToFontSet = new ArrayMap<>();
+    private final Theme mTheme;
+    @AttrRes private final int mFallbackTextAppearanceAttrId;
+
+    /** Constructor with default fallbackTextAppearanceAttrId. */
+    public ProtoLayoutThemeImpl(@NonNull Context context, @StyleRes int themeResId) {
+        this(context.getResources(), themeResId, R.attr.protoLayoutFallbackTextAppearance);
+    }
+
+    /** Constructor with default fallbackTextAppearanceAttrId. */
+    public ProtoLayoutThemeImpl(@NonNull Resources resources, @StyleRes int themeResId) {
+        this(resources, themeResId, R.attr.protoLayoutFallbackTextAppearance);
+    }
+
+    /**
+     * Constructor.
+     *
+     * @param resources Resources reference containing the styles.
+     * @param themeResId a Style resource id for ProtoLayoutTheme that can be read from the
+     *     specified resources.
+     * @param fallbackTextAppearanceAttrId a attribute id for the fallbackTextAppearance that can be
+     *     read from the specified resources
+     */
+    public ProtoLayoutThemeImpl(
+            @NonNull Resources resources,
+            @StyleRes int themeResId,
+            @AttrRes int fallbackTextAppearanceAttrId) {
+        mTheme = resources.newTheme();
+        mTheme.applyStyle(themeResId, true);
+        mFallbackTextAppearanceAttrId = fallbackTextAppearanceAttrId;
+
+        TypedArray a = mTheme.obtainStyledAttributes(R.styleable.ProtoLayoutTheme);
+
+        mVariantToFontSet.put(
+                FontVariant.FONT_VARIANT_TITLE_VALUE,
+                new FontSetImpl(
+                        mTheme,
+                        a.getResourceId(R.styleable.ProtoLayoutTheme_protoLayoutTitleFont, -1)));
+
+        mVariantToFontSet.put(
+                FontVariant.FONT_VARIANT_BODY_VALUE,
+                new FontSetImpl(
+                        mTheme,
+                        a.getResourceId(R.styleable.ProtoLayoutTheme_protoLayoutBodyFont, -1)));
+
+        a.recycle();
+    }
+
+    /**
+     * Gets the FontSet for a given font variant.
+     *
+     * @param fontVariant the numeric value of the proto enum {@link FontVariant}.
+     */
+    @Override
+    @NonNull
+    public FontSet getFontSet(int fontVariant) {
+        FontSet defaultFontSet =
+                checkNotNull(mVariantToFontSet.get(FontVariant.FONT_VARIANT_BODY_VALUE));
+        return mVariantToFontSet.getOrDefault(fontVariant, defaultFontSet);
+    }
+
+    /** Gets an Android Theme object styled with TextAppearance attributes. */
+    @Override
+    @NonNull
+    public Theme getTheme() {
+        return mTheme;
+    }
+
+    /**
+     * Gets a Attribute resource Id for a fallback TextAppearance. The resource with this id should
+     * be present in the Android Theme returned by {@link ProtoLayoutTheme#getTheme()}.
+     */
+    @Override
+    @AttrRes
+    public int getFallbackTextAppearanceResId() {
+        return mFallbackTextAppearanceAttrId;
+    }
+}
diff --git a/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/inflater/ResourceResolvers.java b/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/inflater/ResourceResolvers.java
new file mode 100644
index 0000000..963a4b3
--- /dev/null
+++ b/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/inflater/ResourceResolvers.java
@@ -0,0 +1,413 @@
+/*
+ * 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.protolayout.renderer.inflater;
+
+import android.annotation.SuppressLint;
+import android.graphics.drawable.Drawable;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.concurrent.futures.ResolvableFuture;
+import androidx.wear.protolayout.expression.proto.DynamicProto.DynamicFloat;
+import androidx.wear.protolayout.proto.ResourceProto;
+import androidx.wear.protolayout.proto.ResourceProto.AndroidAnimatedImageResourceByResId;
+import androidx.wear.protolayout.proto.ResourceProto.AndroidImageResourceByResId;
+import androidx.wear.protolayout.proto.ResourceProto.AndroidSeekableAnimatedImageResourceByResId;
+import androidx.wear.protolayout.proto.ResourceProto.InlineImageResource;
+import androidx.wear.protolayout.proto.TriggerProto.Trigger;
+
+import com.google.common.util.concurrent.ListenableFuture;
+
+/**
+ * Class for resolving resources. Delegates the actual work to different types of resolver classes,
+ * and allows each type of resolver to be configured individually, as well as instantiation from
+ * common resolver implementations.
+ */
+public class ResourceResolvers {
+    private final ResourceProto.Resources mProtoResources;
+
+    @Nullable
+    private final AndroidImageResourceByResIdResolver mAndroidImageResourceByResIdResolver;
+
+    @Nullable
+    private final AndroidAnimatedImageResourceByResIdResolver
+            mAndroidAnimatedImageResourceByResIdResolver;
+
+    @Nullable
+    private final AndroidSeekableAnimatedImageResourceByResIdResolver
+            mAndroidSeekableAnimatedImageResourceByResIdResolver;
+
+    @Nullable private final InlineImageResourceResolver mInlineImageResourceResolver;
+
+    ResourceResolvers(
+            @NonNull ResourceProto.Resources protoResources,
+            @Nullable AndroidImageResourceByResIdResolver androidImageResourceByResIdResolver,
+            @Nullable
+                    AndroidAnimatedImageResourceByResIdResolver
+                            androidAnimatedImageResourceByResIdResolver,
+            @Nullable
+                    AndroidSeekableAnimatedImageResourceByResIdResolver
+                            androidSeekableAnimatedImageResourceByResIdResolver,
+            @Nullable InlineImageResourceResolver inlineImageResourceResolver) {
+        this.mProtoResources = protoResources;
+        this.mAndroidImageResourceByResIdResolver = androidImageResourceByResIdResolver;
+        this.mAndroidAnimatedImageResourceByResIdResolver =
+                androidAnimatedImageResourceByResIdResolver;
+        this.mAndroidSeekableAnimatedImageResourceByResIdResolver =
+                androidSeekableAnimatedImageResourceByResIdResolver;
+        this.mInlineImageResourceResolver = inlineImageResourceResolver;
+    }
+
+    /** Exception thrown when accessing resources. */
+    public static final class ResourceAccessException extends Exception {
+        public ResourceAccessException(@NonNull String description) {
+            super(description);
+        }
+
+        public ResourceAccessException(@NonNull String description, @NonNull Exception cause) {
+            super(description, cause);
+        }
+    }
+
+    /** Interface that can provide a Drawable for an AndroidImageResourceByResId */
+    public interface AndroidImageResourceByResIdResolver {
+        /**
+         * Should immediately return the drawable specified by {@code resource}.
+         *
+         * @throws ResourceAccessException If the drawable cannot be found
+         */
+        @NonNull
+        Drawable getDrawableOrThrow(@NonNull AndroidImageResourceByResId resource)
+                throws ResourceAccessException;
+    }
+
+    /** Interface that can provide a Drawable for an AndroidAnimatedImageResourceByResId */
+    public interface AndroidAnimatedImageResourceByResIdResolver {
+        /**
+         * Should immediately return the drawable specified by {@code resource}.
+         *
+         * @throws ResourceAccessException If the drawable cannot be found.
+         */
+        @NonNull
+        Drawable getDrawableOrThrow(@NonNull AndroidAnimatedImageResourceByResId resource)
+                throws ResourceAccessException;
+    }
+
+    /** Interface that can provide a Drawable for an AndroidSeekableAnimatedImageResourceByResId */
+    public interface AndroidSeekableAnimatedImageResourceByResIdResolver {
+        /**
+         * Should immediately return the drawable specified by {@code resource}.
+         *
+         * @throws ResourceAccessException If the drawable cannot be found.
+         */
+        @NonNull
+        Drawable getDrawableOrThrow(@NonNull AndroidSeekableAnimatedImageResourceByResId resource)
+                throws ResourceAccessException;
+    }
+
+    /** Interface that can provide a Drawable for an InlineImageResource */
+    public interface InlineImageResourceResolver {
+        /**
+         * Should immediately return the drawable specified by {@code resource}.
+         *
+         * @throws ResourceAccessException If the drawable cannot be found,.
+         */
+        @NonNull
+        Drawable getDrawableOrThrow(@NonNull InlineImageResource resource)
+                throws ResourceAccessException;
+    }
+
+    /** Get an empty builder to build {@link ResourceResolvers} with. */
+    @NonNull
+    public static Builder builder(@NonNull ResourceProto.Resources protoResources) {
+        return new Builder(protoResources);
+    }
+
+    /**
+     * Returns whether the resource specified by {@code protoResourceId} has a placeholder resource
+     * associated with it.
+     */
+    public boolean hasPlaceholderDrawable(@NonNull String protoResourceId) {
+        return getPlaceholderResourceId(protoResourceId) != null;
+    }
+
+    /**
+     * Returns the placeholder drawable for the resource specified by {@code protoResourceId}.
+     *
+     * @throws ResourceAccessException If the specified resource does not have a placeholder
+     *     associated, or the placeholder could not be loaded.
+     * @throws IllegalArgumentException If the specified resource, or its placeholder, does not
+     *     exist.
+     * @see ResourceResolvers#hasPlaceholderDrawable(String)
+     */
+    @NonNull
+    public Drawable getPlaceholderDrawableOrThrow(@NonNull String protoResourceId)
+            throws ResourceAccessException {
+        String placeholderResourceId = getPlaceholderResourceId(protoResourceId);
+
+        if (placeholderResourceId == null) {
+            throw new ResourceAccessException(
+                    "Resource " + protoResourceId + " does not have a placeholder resource.");
+        }
+
+        ResourceProto.ImageResource placeholderImageResource =
+                mProtoResources.getIdToImageMap().get(placeholderResourceId);
+
+        if (placeholderImageResource == null) {
+            throw new IllegalArgumentException(
+                    "Resource " + placeholderResourceId + " is not defined in resources bundle");
+        }
+
+        Drawable placeHolderDrawable =
+                getDrawableForImageResourceSynchronously(placeholderImageResource);
+        if (placeHolderDrawable != null) {
+            return placeHolderDrawable;
+        }
+
+        throw new ResourceAccessException("Can't find resolver for image resource.");
+    }
+
+    /** Get the drawable corresponding to the given resource ID. */
+    @NonNull
+    public ListenableFuture<Drawable> getDrawable(@NonNull String protoResourceId) {
+        ResourceProto.ImageResource imageResource =
+                mProtoResources.getIdToImageMap().get(protoResourceId);
+
+        if (imageResource == null) {
+            return createFailedFuture(
+                    new IllegalArgumentException(
+                            "Resource " + protoResourceId + " is not defined in resources bundle"));
+        }
+
+        @Nullable
+        ListenableFuture<Drawable> drawableFutureOrNull =
+                getDrawableForImageResource(imageResource);
+        if (drawableFutureOrNull == null) {
+            return createFailedFuture(
+                    new ResourceAccessException(
+                            "Can't find resolver for image resource " + protoResourceId));
+        }
+        return drawableFutureOrNull;
+    }
+
+    /**
+     * Get the animation trigger for the given animated image resource id
+     *
+     * @throws IllegalArgumentException If the resource is not an animated resource.
+     */
+    @Nullable
+    public Trigger getAnimationTrigger(@NonNull String protoResourceId) {
+        ResourceProto.ImageResource imageResource =
+                mProtoResources.getIdToImageMap().get(protoResourceId);
+        if (imageResource != null && imageResource.hasAndroidAnimatedResourceByResId()) {
+            return imageResource.getAndroidAnimatedResourceByResId().getStartTrigger();
+        }
+        throw new IllegalArgumentException(
+                "Resource "
+                        + protoResourceId
+                        + " is not an animated resource, thus no animation trigger");
+    }
+
+    /**
+     * Get the animation bound progress for the given animated image resource id
+     *
+     * @throws IllegalArgumentException If the resource is not a seekable animated resource.
+     */
+    @Nullable
+    public DynamicFloat getBoundProgress(@NonNull String protoResourceId) {
+        ResourceProto.ImageResource imageResource =
+                mProtoResources.getIdToImageMap().get(protoResourceId);
+        if (imageResource != null && imageResource.hasAndroidSeekableAnimatedResourceByResId()) {
+            return imageResource.getAndroidSeekableAnimatedResourceByResId().getProgress();
+        }
+        throw new IllegalArgumentException(
+                "Resource "
+                        + protoResourceId
+                        + " is not a seekable animated resource, thus no bound progress to a"
+                        + " DynamicFloat");
+    }
+
+    @Nullable
+    Drawable getDrawableForImageResourceSynchronously(
+            @NonNull ResourceProto.ImageResource imageResource) throws ResourceAccessException {
+        if (imageResource.hasAndroidAnimatedResourceByResId()
+                && mAndroidAnimatedImageResourceByResIdResolver != null) {
+            AndroidAnimatedImageResourceByResIdResolver resolver =
+                    mAndroidAnimatedImageResourceByResIdResolver;
+            return resolver.getDrawableOrThrow(imageResource.getAndroidAnimatedResourceByResId());
+        }
+
+        if (imageResource.hasAndroidSeekableAnimatedResourceByResId()
+                && mAndroidSeekableAnimatedImageResourceByResIdResolver != null) {
+            AndroidSeekableAnimatedImageResourceByResIdResolver resolver =
+                    mAndroidSeekableAnimatedImageResourceByResIdResolver;
+            return resolver.getDrawableOrThrow(
+                    imageResource.getAndroidSeekableAnimatedResourceByResId());
+        }
+
+        if (imageResource.hasAndroidResourceByResId()
+                && mAndroidImageResourceByResIdResolver != null) {
+            AndroidImageResourceByResIdResolver resolver = mAndroidImageResourceByResIdResolver;
+            return resolver.getDrawableOrThrow(imageResource.getAndroidResourceByResId());
+        }
+
+        if (imageResource.hasInlineResource() && mInlineImageResourceResolver != null) {
+            InlineImageResourceResolver resolver = mInlineImageResourceResolver;
+            return resolver.getDrawableOrThrow(imageResource.getInlineResource());
+        }
+
+        return null;
+    }
+
+    /**
+     * Get the drawable for the known ImageResource. Can return null if there's no resolver for the
+     * image resource.
+     */
+    @Nullable
+    protected ListenableFuture<Drawable> getDrawableForImageResource(
+            @NonNull ResourceProto.ImageResource imageResource) {
+        try {
+            Drawable drawable = getDrawableForImageResourceSynchronously(imageResource);
+            if (drawable != null) {
+                return createImmediateFuture(drawable);
+            }
+        } catch (ResourceAccessException e) {
+            return createFailedFuture(e);
+        }
+
+        // Can't find resolver for image resource.
+        return null;
+    }
+
+    public boolean canImageBeTinted(@NonNull String protoResourceId) {
+        // Only Android image resources can be tinted for now. This is because we don't really know
+        // what is in an inline image.
+        ResourceProto.ImageResource imageResource =
+                mProtoResources.getIdToImageMap().get(protoResourceId);
+
+        if (imageResource == null) {
+            throw new IllegalArgumentException(
+                    "Resource " + protoResourceId + " is not defined in resources bundle");
+        }
+
+        if (imageResource.hasAndroidResourceByResId()
+                || imageResource.hasAndroidAnimatedResourceByResId()
+                || imageResource.hasAndroidSeekableAnimatedResourceByResId()) {
+            return true;
+        }
+
+        return false;
+    }
+
+    @Nullable
+    protected String getPlaceholderResourceId(@NonNull String originalResourceId) {
+        ResourceProto.ImageResource imageResource =
+                mProtoResources.getIdToImageMap().get(originalResourceId);
+
+        if (imageResource == null) {
+            throw new IllegalArgumentException(
+                    "Resource " + originalResourceId + " is not defined in resources bundle");
+        }
+
+        return null;
+    }
+
+    static <T> ListenableFuture<T> createImmediateFuture(@NonNull T value) {
+        ResolvableFuture<T> future = ResolvableFuture.create();
+        future.set(value);
+        return future;
+    }
+
+    static <T> ListenableFuture<T> createFailedFuture(@NonNull Throwable throwable) {
+        ResolvableFuture<T> errorFuture = ResolvableFuture.create();
+        errorFuture.setException(throwable);
+        return errorFuture;
+    }
+
+    /** Builder for ResourceResolvers */
+    public static final class Builder {
+        @NonNull private final ResourceProto.Resources mProtoResources;
+        @Nullable private AndroidImageResourceByResIdResolver mAndroidImageResourceByResIdResolver;
+
+        @Nullable
+        private AndroidAnimatedImageResourceByResIdResolver
+                mAndroidAnimatedImageResourceByResIdResolver;
+
+        @Nullable
+        private AndroidSeekableAnimatedImageResourceByResIdResolver
+                mAndroidSeekableAnimatedImageResourceByResIdResolver;
+
+        @Nullable private InlineImageResourceResolver mInlineImageResourceResolver;
+
+        Builder(@NonNull ResourceProto.Resources protoResources) {
+            this.mProtoResources = protoResources;
+        }
+
+        /** Set the resource loader for {@link AndroidImageResourceByResIdResolver} resources. */
+        @NonNull
+        @SuppressLint("MissingGetterMatchingBuilder")
+        public Builder setAndroidImageResourceByResIdResolver(
+                @NonNull AndroidImageResourceByResIdResolver resolver) {
+            mAndroidImageResourceByResIdResolver = resolver;
+            return this;
+        }
+
+        /**
+         * Set the resource loader for {@link AndroidAnimatedImageResourceByResIdResolver}
+         * resources.
+         */
+        @NonNull
+        @SuppressLint("MissingGetterMatchingBuilder")
+        public Builder setAndroidAnimatedImageResourceByResIdResolver(
+                @NonNull AndroidAnimatedImageResourceByResIdResolver resolver) {
+            mAndroidAnimatedImageResourceByResIdResolver = resolver;
+            return this;
+        }
+
+        /**
+         * Set the resource loader for {@link AndroidSeekableAnimatedImageResourceByResIdResolver}
+         * resources.
+         */
+        @NonNull
+        @SuppressLint("MissingGetterMatchingBuilder")
+        public Builder setAndroidSeekableAnimatedImageResourceByResIdResolver(
+                @NonNull AndroidSeekableAnimatedImageResourceByResIdResolver resolver) {
+            mAndroidSeekableAnimatedImageResourceByResIdResolver = resolver;
+            return this;
+        }
+
+        /** Set the resource loader for {@link InlineImageResourceResolver} resources. */
+        @NonNull
+        @SuppressLint("MissingGetterMatchingBuilder")
+        public Builder setInlineImageResourceResolver(
+                @NonNull InlineImageResourceResolver resolver) {
+            mInlineImageResourceResolver = resolver;
+            return this;
+        }
+
+        /** Build a {@link ResourceResolvers} instance. */
+        @NonNull
+        public ResourceResolvers build() {
+            return new ResourceResolvers(
+                    mProtoResources,
+                    mAndroidImageResourceByResIdResolver,
+                    mAndroidAnimatedImageResourceByResIdResolver,
+                    mAndroidSeekableAnimatedImageResourceByResIdResolver,
+                    mInlineImageResourceResolver);
+        }
+    }
+}
diff --git a/appactions/interaction/interaction-service/src/main/java/androidx/appactions/interaction/service/proto/package-info.java b/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/inflater/package-info.java
similarity index 85%
rename from appactions/interaction/interaction-service/src/main/java/androidx/appactions/interaction/service/proto/package-info.java
rename to wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/inflater/package-info.java
index 93db9d1..a2206ab 100644
--- a/appactions/interaction/interaction-service/src/main/java/androidx/appactions/interaction/service/proto/package-info.java
+++ b/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/inflater/package-info.java
@@ -14,8 +14,12 @@
  * limitations under the License.
  */
 
-/** @hide */
+/**
+ * Internal implementation of the inflater.
+ *
+ * @hide
+ */
 @RestrictTo(RestrictTo.Scope.LIBRARY)
-package androidx.appactions.interaction.service.proto;
+package androidx.wear.protolayout.renderer.inflater;
 
 import androidx.annotation.RestrictTo;
diff --git a/wear/protolayout/protolayout-renderer/src/main/res/values-sw210dp-round/styles.xml b/wear/protolayout/protolayout-renderer/src/main/res/values-sw210dp-round/styles.xml
new file mode 100644
index 0000000..50e9e8e
--- /dev/null
+++ b/wear/protolayout/protolayout-renderer/src/main/res/values-sw210dp-round/styles.xml
@@ -0,0 +1,8 @@
+<resources>
+  <style name="ProtoLayoutFallbackTextAppearance">
+    <!-- Note that many properties are non-overridable, as they are explicitly
+         set by inflateText (e.g. bold, italic, size etc).  -->
+    <item name="android:fontFamily">sans-serif</item>
+    <item name="android:textSize">18sp</item>
+  </style>
+</resources>
diff --git a/wear/protolayout/protolayout-renderer/src/main/res/values/attrs.xml b/wear/protolayout/protolayout-renderer/src/main/res/values/attrs.xml
new file mode 100644
index 0000000..a71a29e
--- /dev/null
+++ b/wear/protolayout/protolayout-renderer/src/main/res/values/attrs.xml
@@ -0,0 +1,96 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+  <attr name="anchorAngleDegrees" format="float"/>
+
+  <attr name="anchorPosition" format="enum">
+    <enum name="start" value="0" />
+    <enum name="center" value="1" />
+    <enum name="end" value="2" />
+  </attr>
+
+  <attr name="clockwise" format="boolean"/>
+  <attr name="sweepAngleDegrees" format="float" />
+  <attr name="thickness" format="dimension" />
+
+  <declare-styleable name="TextAppearance">
+    <!-- Text color. -->
+    <attr name="android:textColor" />
+    <!-- Size of the text. Recommended dimension type for text is "sp" for scaled-pixels (example: 15sp). -->
+    <attr name="android:textSize" />
+    <!-- Style (normal, bold, italic, bold|italic) for the text. -->
+    <attr name="android:textStyle" />
+    <!-- Weight for the font used in the TextView. -->
+    <attr name="android:textFontWeight" />
+    <!-- Typeface (normal, sans, serif, monospace) for the text. -->
+    <attr name="android:typeface" />
+    <!-- Font family (named by string or as a font resource reference) for the text. -->
+    <attr name="android:fontFamily" />
+    <!-- Text letter-spacing. -->
+    <attr name="android:letterSpacing" />
+    <!-- Text variation settings. -->
+    <attr name="android:fontVariationSettings" />
+    <!-- Font feature settings. -->
+    <attr name="android:fontFeatureSettings" />
+  </declare-styleable>
+
+  <declare-styleable name="TextViewAppearance">
+    <!-- Base text color, typeface, size, and style. -->
+    <attr name="android:textAppearance" />
+  </declare-styleable>
+
+  <declare-styleable name="WearCurvedLineView">
+    <attr name="maxSweepAngleDegrees" format="float" />
+    <attr name="sweepAngleDegrees" />
+    <attr name="thickness" />
+    <attr name="color" format="color" />
+    <attr name="strokeCap" format="enum">
+      <enum name="butt" value="0" />
+      <enum name="round" value="1" />
+      <enum name="square" value="2" />
+    </attr>
+  </declare-styleable>
+
+  <declare-styleable name="WearCurvedSpacer">
+    <attr name="sweepAngleDegrees" />
+    <attr name="thickness" />
+  </declare-styleable>
+
+  <attr name="angularAlignment" format="enum">
+    <enum name="start" value="0" />
+    <enum name="center" value="1" />
+    <enum name="end" value="2" />
+  </attr>
+
+  <declare-styleable name="SizedArcContainer">
+    <attr name="sweepAngleDegrees" />
+  </declare-styleable>
+
+  <declare-styleable name="SizedArcContainer_Layout">
+    <attr name="angularAlignment" />
+  </declare-styleable>
+
+  <declare-styleable name="ProtoLayoutFontSet">
+    <!-- We need to list the different fonts here too. The "proper" way to do
+         this would be to have multiple TextAppearance entries for each one,
+         which specifies a typeface or a weight. That works for normal Text, but
+         can't work for Spannables as there's no Span which applies
+         TextAppearance properly (TextAppearanceSpan doesn't work with weight).
+
+         Instead, just list the fonts here, and we'll go and put them into a
+         Typeface inside the renderer.
+
+         Note that the regular font should also be set in the TextAppearance
+         above, as this drives the defaults for some elements (Spannable). -->
+    <attr name="protoLayoutNormalFont" format="reference|string" />
+    <attr name="protoLayoutMediumFont" format="reference|string" />
+    <attr name="protoLayoutBoldFont" format="reference|string" />
+  </declare-styleable>
+
+  <!-- ProtoLayout theme attributes. -->
+  <declare-styleable name="ProtoLayoutTheme">
+    <attr name="protoLayoutFallbackTextAppearance" format="reference" />
+    <attr name="protoLayoutTitleFont" format="reference" />
+    <attr name="protoLayoutBodyFont" format="reference" />
+    <attr name="protoLayoutVendor1Font" format="reference" />
+  </declare-styleable>
+</resources>
diff --git a/wear/protolayout/protolayout-renderer/src/main/res/values/styles.xml b/wear/protolayout/protolayout-renderer/src/main/res/values/styles.xml
new file mode 100644
index 0000000..ad64828
--- /dev/null
+++ b/wear/protolayout/protolayout-renderer/src/main/res/values/styles.xml
@@ -0,0 +1,21 @@
+<resources>
+  <style name="ProtoLayoutFallbackTextAppearance">
+    <!-- Note that many properties are non-overridable, as they are explicitly
+         set by inflateText (e.g. bold, italic, size etc).  -->
+    <item name="android:fontFamily">sans-serif</item>
+    <item name="android:textSize">16sp</item>
+  </style>
+
+  <style name="ProtoLayoutBaseFont">
+    <item name="protoLayoutNormalFont">sans-serif</item>
+    <item name="protoLayoutMediumFont">sans-serif-medium</item>
+    <item name="protoLayoutBoldFont">sans-serif</item>
+  </style>
+
+  <style name="ProtoLayoutBaseTheme">
+    <item name="protoLayoutFallbackTextAppearance">@style/ProtoLayoutFallbackTextAppearance</item>
+    <item name="protoLayoutTitleFont">@style/ProtoLayoutBaseFont</item>
+    <item name="protoLayoutBodyFont">@style/ProtoLayoutBaseFont</item>
+    <item name="protoLayoutVendor1Font">@style/ProtoLayoutBaseFont</item>
+  </style>
+</resources>
diff --git a/wear/protolayout/protolayout-renderer/src/main/res/values/tags.xml b/wear/protolayout/protolayout-renderer/src/main/res/values/tags.xml
new file mode 100644
index 0000000..effb0a5
--- /dev/null
+++ b/wear/protolayout/protolayout-renderer/src/main/res/values/tags.xml
@@ -0,0 +1,4 @@
+<resources>
+  <item name="rendered_metadata_tag" type="id" />
+  <item name="clickable_id_tag" type="id" />
+</resources>
diff --git a/wear/protolayout/protolayout-renderer/src/test/java/androidx/wear/protolayout/renderer/common/ProtoLayoutDifferTest.java b/wear/protolayout/protolayout-renderer/src/test/java/androidx/wear/protolayout/renderer/common/ProtoLayoutDifferTest.java
new file mode 100644
index 0000000..b86f294
--- /dev/null
+++ b/wear/protolayout/protolayout-renderer/src/test/java/androidx/wear/protolayout/renderer/common/ProtoLayoutDifferTest.java
@@ -0,0 +1,482 @@
+/*
+ * 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.protolayout.renderer.common;
+
+import static androidx.wear.protolayout.renderer.common.ProtoLayoutDiffer.DISCARDED_FINGERPRINT_VALUE;
+import static androidx.wear.protolayout.renderer.helper.TestDsl.arc;
+import static androidx.wear.protolayout.renderer.helper.TestDsl.arcText;
+import static androidx.wear.protolayout.renderer.helper.TestDsl.column;
+import static androidx.wear.protolayout.renderer.helper.TestDsl.layout;
+import static androidx.wear.protolayout.renderer.helper.TestDsl.row;
+import static androidx.wear.protolayout.renderer.helper.TestDsl.spanText;
+import static androidx.wear.protolayout.renderer.helper.TestDsl.spannable;
+import static androidx.wear.protolayout.renderer.helper.TestDsl.text;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.wear.protolayout.proto.FingerprintProto.NodeFingerprint;
+import androidx.wear.protolayout.proto.FingerprintProto.TreeFingerprint;
+import androidx.wear.protolayout.proto.LayoutElementProto.ArcLayoutElement;
+import androidx.wear.protolayout.proto.LayoutElementProto.Layout;
+import androidx.wear.protolayout.proto.LayoutElementProto.LayoutElement;
+import androidx.wear.protolayout.proto.LayoutElementProto.Span;
+import androidx.wear.protolayout.renderer.common.ProtoLayoutDiffer.LayoutDiff;
+import androidx.wear.protolayout.renderer.common.ProtoLayoutDiffer.TreeNodeWithChange;
+
+import com.google.common.collect.ImmutableList;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.util.List;
+
+@RunWith(AndroidJUnit4.class)
+public class ProtoLayoutDifferTest {
+    @Test
+    public void createNodePosId_withSameParams() {
+        String posId1 = ProtoLayoutDiffer.createNodePosId("parent", 1);
+        String posId2 = ProtoLayoutDiffer.createNodePosId("parent", 1);
+        assertThat(posId1).isNotEmpty();
+        assertThat(posId2).isNotEmpty();
+        assertThat(posId1).isEqualTo(posId2);
+    }
+
+    @Test
+    public void createNodePosId_withDifferentParams() {
+        String posId1 = ProtoLayoutDiffer.createNodePosId("parent", 1);
+        String posId2 = ProtoLayoutDiffer.createNodePosId("parent", 2);
+        String posId3 = ProtoLayoutDiffer.createNodePosId("foo", 2);
+        assertThat(posId1).isNotEmpty();
+        assertThat(posId2).isNotEmpty();
+        assertThat(posId1).isNotEqualTo(posId2);
+        assertThat(posId2).isNotEqualTo(posId3);
+    }
+
+    @Test
+    public void getParentNodePosId_withValidValue() {
+        String grandParentNodePosId = ProtoLayoutDiffer.ROOT_NODE_ID;
+        String parentNodePosId = ProtoLayoutDiffer.createNodePosId(grandParentNodePosId, 1);
+        String nodePosId = ProtoLayoutDiffer.createNodePosId(parentNodePosId, 3);
+
+        assertThat(ProtoLayoutDiffer.getParentNodePosId(nodePosId)).isEqualTo(parentNodePosId);
+        assertThat(ProtoLayoutDiffer.getParentNodePosId(parentNodePosId))
+                .isEqualTo(grandParentNodePosId);
+    }
+
+    @Test
+    public void getParentNodePosId_withInvalidValues() {
+        assertThat(ProtoLayoutDiffer.getParentNodePosId(ProtoLayoutDiffer.ROOT_NODE_ID)).isNull();
+        assertThat(ProtoLayoutDiffer.getParentNodePosId("not_a_pos_id")).isNull();
+    }
+
+    @Test
+    public void getChangedNodes_withNoChange() {
+        LayoutDiff diff =
+                ProtoLayoutDiffer.getDiff(referenceLayout().getFingerprint(), referenceLayout());
+        assertThat(diff).isNotNull();
+        assertThat(diff.getChangedNodes()).isEmpty();
+    }
+
+    @Test
+    public void getChangedNodes_forLayoutWithNoFingerprint() {
+        LayoutDiff diff =
+                ProtoLayoutDiffer.getDiff(
+                        referenceLayout().getFingerprint(),
+                        referenceLayout().toBuilder().clearFingerprint().build());
+        assertThat(diff).isNull();
+    }
+
+    @Test
+    public void getChangedNodes_bothFingerprintsDiscarded_allDiffs() {
+        Layout layout =
+                layout(
+                        column( // 1
+                                row( // 1.1
+                                        text("Foo"), // 1.1.1
+                                        text("Bar") // 1.1.2
+                                        )));
+        NodeFingerprint discardedFingerPrintRoot =
+                buildShadowDiscardedFingerprint(
+                        layout.getFingerprint().getRoot(),
+                        "1",
+                        ImmutableList.of("1", "1.1", "1.1.1", "1.1.2"),
+                        ImmutableList.of("1", "1.1"));
+
+        Layout discardedFingerprintLayout =
+                Layout.newBuilder()
+                        .setRoot(layout.getRoot())
+                        .setFingerprint(
+                                TreeFingerprint.newBuilder().setRoot(discardedFingerPrintRoot))
+                        .build();
+
+        LayoutDiff diff =
+                ProtoLayoutDiffer.getDiff(
+                        discardedFingerprintLayout.getFingerprint(), discardedFingerprintLayout);
+        assertThat(diff.getChangedNodes()).hasSize(1);
+    }
+
+    @Test
+    public void getChangedNodes_selfChange_childrenUnaffected() {
+        Layout layout =
+                layout(
+                        column( // 1
+                                row( // 1.1
+                                        text("Foo"), // 1.1.1
+                                        text("Bar") // 1.1.2
+                                        )));
+        NodeFingerprint discardedFingerPrintRoot =
+                buildShadowDiscardedFingerprint(
+                        layout.getFingerprint().getRoot(),
+                        "1",
+                        ImmutableList.of("1.1"),
+                        ImmutableList.of());
+
+        Layout discardedFingerprintLayout =
+                Layout.newBuilder()
+                        .setRoot(layout.getRoot())
+                        .setFingerprint(
+                                TreeFingerprint.newBuilder().setRoot(discardedFingerPrintRoot))
+                        .build();
+
+        LayoutDiff diff =
+                ProtoLayoutDiffer.getDiff(layout.getFingerprint(), discardedFingerprintLayout);
+        assertThat(diff.getChangedNodes()).hasSize(1);
+        assertThat(diff.getChangedNodes().get(0).isSelfOnlyChange()).isTrue();
+    }
+
+    @Test
+    public void getChangedNodes_selfChangeAndChildren_isAllChange() {
+        Layout layout =
+                layout(
+                        column( // 1
+                                row( // 1.1
+                                        text("Foo"), // 1.1.1
+                                        text("Bar") // 1.1.2
+                                        )));
+        NodeFingerprint discardedFingerPrintRoot =
+                buildShadowDiscardedFingerprint(
+                        layout.getFingerprint().getRoot(),
+                        "1",
+                        ImmutableList.of("1.1"),
+                        ImmutableList.of("1.1"));
+
+        Layout discardedFingerprintLayout =
+                Layout.newBuilder()
+                        .setRoot(layout.getRoot())
+                        .setFingerprint(
+                                TreeFingerprint.newBuilder().setRoot(discardedFingerPrintRoot))
+                        .build();
+
+        LayoutDiff diff =
+                ProtoLayoutDiffer.getDiff(layout.getFingerprint(), discardedFingerprintLayout);
+        assertThat(diff.getChangedNodes()).hasSize(1);
+        assertThat(diff.getChangedNodes().get(0).isSelfOnlyChange()).isFalse();
+    }
+
+    @Test
+    public void getChangedNodes_withOneUpdatedNode() {
+        LayoutDiff diff =
+                ProtoLayoutDiffer.getDiff(
+                        referenceLayout().getFingerprint(), layoutWithOneUpdatedNode());
+        assertThat(diff).isNotNull();
+        assertThat(diff.getChangedNodes()).hasSize(1);
+        TreeNodeWithChange changedNode = diff.getChangedNodes().get(0);
+        assertThat(changedNode.getLayoutElement()).isNotNull();
+        assertThat(changedNode.getPosId()).isEqualTo("pT1.1.2");
+        assertThat(textValue(changedNode.getLayoutElement())).isEqualTo("UPDATED");
+        assertThat(changedNode.isSelfOnlyChange()).isTrue();
+    }
+
+    @Test
+    public void getChangedNodes_withOneUpdatedParentAndOneUpdatedChild() {
+        LayoutDiff diff =
+                ProtoLayoutDiffer.getDiff(
+                        referenceLayout().getFingerprint(),
+                        layoutWithOneUpdatedParentAndOneUpdatedChild());
+        assertThat(diff).isNotNull();
+        assertThat(diff.getChangedNodes()).hasSize(2);
+
+        TreeNodeWithChange changedParentNode = diff.getChangedNodes().get(0);
+        assertThat(changedParentNode.getLayoutElement()).isNotNull();
+        assertThat(changedParentNode.getPosId()).isEqualTo("pT1.1");
+        assertThat(changedParentNode.isSelfOnlyChange()).isTrue();
+
+        TreeNodeWithChange changedChildNode = diff.getChangedNodes().get(1);
+        assertThat(changedChildNode.getLayoutElement()).isNotNull();
+        assertThat(changedChildNode.getPosId()).isEqualTo("pT1.1.2");
+        assertThat(textValue(changedChildNode.getLayoutElement())).isEqualTo("UPDATED");
+        assertThat(changedChildNode.isSelfOnlyChange()).isTrue();
+    }
+
+    @Test
+    public void getChangedNodes_withTwoUpdatedNodes() {
+        LayoutDiff diff =
+                ProtoLayoutDiffer.getDiff(
+                        referenceLayout().getFingerprint(), layoutWithTwoUpdatedNodes());
+        assertThat(diff).isNotNull();
+        assertThat(diff.getChangedNodes()).hasSize(2);
+        TreeNodeWithChange changedNode1 = diff.getChangedNodes().get(0);
+        assertThat(changedNode1.getLayoutElement()).isNotNull();
+        assertThat(changedNode1.getPosId()).isEqualTo("pT1.1.1");
+        assertThat(textValue(changedNode1.getLayoutElement())).isEqualTo("UPDATED1");
+        assertThat(changedNode1.isSelfOnlyChange()).isTrue();
+        TreeNodeWithChange changedNode2 = diff.getChangedNodes().get(1);
+        assertThat(changedNode2.getLayoutElement()).isNotNull();
+        assertThat(textValue(changedNode2.getLayoutElement())).isEqualTo("UPDATED2");
+        assertThat(changedNode2.isSelfOnlyChange()).isTrue();
+    }
+
+    @Test
+    public void getChangedNodes_withDifferentNumberOfChildren() {
+        LayoutDiff diff =
+                ProtoLayoutDiffer.getDiff(
+                        referenceLayout().getFingerprint(), layoutWithDifferentNumberOfChildren());
+        assertThat(diff).isNotNull();
+        assertThat(diff.getChangedNodes()).hasSize(1);
+        TreeNodeWithChange changedNode = diff.getChangedNodes().get(0);
+        assertThat(changedNode.getPosId()).isEqualTo("pT1.1");
+        assertThat(changedNode.getLayoutElement()).isNotNull();
+        assertThat(changedNode.getLayoutElement().hasRow()).isTrue();
+        assertThat(changedNode.isSelfOnlyChange()).isFalse();
+    }
+
+    @Test
+    public void getChangedNodes_withChangeInArc() {
+        LayoutDiff diff =
+                ProtoLayoutDiffer.getDiff(
+                        referenceLayout().getFingerprint(), layoutWithChangeInArc());
+        assertThat(diff).isNotNull();
+        assertThat(diff.getChangedNodes()).hasSize(1);
+        TreeNodeWithChange changedNode = diff.getChangedNodes().get(0);
+        assertThat(changedNode.getPosId()).isEqualTo("pT1.4.1");
+        assertThat(changedNode.getArcLayoutElement()).isNotNull();
+        assertThat(textValue(changedNode.getArcLayoutElement())).isEqualTo("UPDATED");
+        assertThat(changedNode.isSelfOnlyChange()).isTrue();
+    }
+
+    @Test
+    public void getChangedNodes_withChangeInSpannable() {
+        Layout layout1 = layout(spannable(spanText("Hello"), spanText("World")));
+        Layout layout2 = layout(spannable(spanText("Hello"), spanText("Mars")));
+        LayoutDiff diff = ProtoLayoutDiffer.getDiff(layout1.getFingerprint(), layout2);
+        assertThat(diff).isNotNull();
+        assertThat(diff.getChangedNodes()).hasSize(1);
+        TreeNodeWithChange changedNode = diff.getChangedNodes().get(0);
+        // Although the change is in one of the Spans, we consider the Spannable itself to have
+        // changed.
+        assertThat(changedNode.getPosId()).isEqualTo("pT1");
+        assertThat(changedNode.getLayoutElement()).isNotNull();
+        assertThat(changedNode.getLayoutElement().hasSpannable()).isTrue();
+        assertThat(textValue(changedNode.getLayoutElement().getSpannable().getSpans(1)))
+                .isEqualTo("Mars");
+        assertThat(changedNode.isSelfOnlyChange()).isTrue();
+    }
+
+    @Test
+    public void getChangedNodes_withUpdateToNodeSelfFingerprint() {
+        LayoutDiff diff =
+                ProtoLayoutDiffer.getDiff(
+                        referenceLayout().getFingerprint(),
+                        layoutWithUpdateToNodeSelfFingerprint());
+        assertThat(diff).isNotNull();
+        assertThat(diff.getChangedNodes()).hasSize(1);
+        TreeNodeWithChange changedNode = diff.getChangedNodes().get(0);
+        assertThat(changedNode.getPosId()).isEqualTo("pT1.2");
+        assertThat(changedNode.getLayoutElement()).isNotNull();
+        assertThat(changedNode.getLayoutElement().hasRow()).isTrue();
+        assertThat(changedNode.isSelfOnlyChange()).isTrue();
+    }
+
+    @Test
+    public void isChildOf_forAnActualChild_returnsTrue() {
+        String childPosId = "pT1.2.3";
+        String parentPosId = "pT1.2";
+        assertThat(ProtoLayoutDiffer.isDescendantOf(childPosId, parentPosId)).isTrue();
+    }
+
+    @Test
+    public void isChildOf_forANonChild_returnsFalse() {
+        String childPosId = "pT1.22.3";
+        String parentPosId = "pT1.2";
+        assertThat(ProtoLayoutDiffer.isDescendantOf(childPosId, parentPosId)).isFalse();
+    }
+
+    private NodeFingerprint buildShadowDiscardedFingerprint(
+            NodeFingerprint fingerprintRoot,
+            String rootPosId,
+            List<String> discardedNodes,
+            List<String> discardedChilds) {
+        NodeFingerprint.Builder shadowNodeBuilder = NodeFingerprint.newBuilder();
+        shadowNodeBuilder.setSelfTypeValue(fingerprintRoot.getSelfTypeValue());
+        if (discardedNodes.contains(rootPosId)) {
+            shadowNodeBuilder.setSelfPropsValue(DISCARDED_FINGERPRINT_VALUE);
+        } else {
+            shadowNodeBuilder.setSelfPropsValue(fingerprintRoot.getSelfPropsValue());
+        }
+        boolean discardChildren = discardedChilds.contains(rootPosId);
+        if (discardChildren) {
+            shadowNodeBuilder.setChildNodesValue(DISCARDED_FINGERPRINT_VALUE);
+        } else {
+            shadowNodeBuilder.setChildNodesValue(fingerprintRoot.getChildNodesValue());
+        }
+        int childIndex = 1;
+        for (NodeFingerprint childNode : fingerprintRoot.getChildNodesList()) {
+            NodeFingerprint childNodeFingerprint =
+                    buildShadowDiscardedFingerprint(
+                            childNode,
+                            rootPosId + "." + childIndex++,
+                            discardedNodes,
+                            discardedChilds);
+            if (!discardChildren) {
+                shadowNodeBuilder.addChildNodes(childNodeFingerprint);
+            }
+            if (childNodeFingerprint.getSelfPropsValue() == DISCARDED_FINGERPRINT_VALUE) {
+                shadowNodeBuilder.setChildNodesValue(DISCARDED_FINGERPRINT_VALUE);
+            }
+        }
+        return shadowNodeBuilder.build();
+    }
+
+    private static Layout referenceLayout() {
+        return layout(
+                column( // 1
+                        row( // 1.1
+                                text("Foo"), // 1.1.1
+                                text("Bar") // 1.1.2
+                                ),
+                        row( // 1.2
+                                text("Baz") // 1.2.1
+                                ),
+                        text("blah blah"), // 1.3
+                        arc( // 1.4
+                                arcText("arctext") // 1.4.1
+                                )));
+    }
+
+    private static Layout layoutWithOneUpdatedNode() {
+        return layout(
+                column( // 1
+                        row( // 1.1
+                                text("Foo"), // 1.1.1
+                                text("UPDATED") // 1.1.2
+                                ),
+                        row( // 1.2
+                                text("Baz") // 1.2.1
+                                ),
+                        text("blah blah"), // 1.3
+                        arc( // 1.4
+                                arcText("arctext") // 1.4.1
+                                )));
+    }
+
+    private static Layout layoutWithOneUpdatedParentAndOneUpdatedChild() {
+        return layout(
+                column( // 1
+                        row( // 1.1
+                                modifiers -> modifiers.widthDp = 123,
+                                text("Foo"), // 1.1.1
+                                text("UPDATED") // 1.1.2
+                                ),
+                        row( // 1.2
+                                text("Baz") // 1.2.1
+                                ),
+                        text("blah blah"), // 1.3
+                        arc( // 1.4
+                                arcText("arctext") // 1.4.1
+                                )));
+    }
+
+    private static Layout layoutWithTwoUpdatedNodes() {
+        return layout(
+                column( // 1
+                        row( // 1.1
+                                text("UPDATED1"), // 1.1.1
+                                text("Bar") // 1.1.2
+                                ),
+                        row( // 1.2
+                                text("Baz") // 1.2.1
+                                ),
+                        text("UPDATED2"), // 1.3
+                        arc( // 1.4
+                                arcText("arctext") // 1.4.1
+                                )));
+    }
+
+    private static Layout layoutWithDifferentNumberOfChildren() {
+        return layout(
+                column( // 1
+                        row( // 1.1
+                                text("Foo"), // 1.1.1
+                                text("Bar"), // 1.1.2
+                                text("EXTRA") // 1.1.3
+                                ),
+                        row( // 1.2
+                                text("Baz") // 1.2.1
+                                ),
+                        text("blah blah"), // 1.3
+                        arc( // 1.4
+                                arcText("arctext") // 1.4.1
+                                )));
+    }
+
+    private static Layout layoutWithChangeInArc() {
+        return layout(
+                column( // 1
+                        row( // 1.1
+                                text("Foo"), // 1.1.1
+                                text("Bar") // 1.1.2
+                                ),
+                        row( // 1.2
+                                text("Baz") // 1.2.1
+                                ),
+                        text("blah blah"), // 1.3
+                        arc( // 1.4
+                                arcText("UPDATED") // 1.4.1
+                                )));
+    }
+
+    private static Layout layoutWithUpdateToNodeSelfFingerprint() {
+        return layout(
+                column( // 1
+                        row( // 1.1
+                                text("Foo"), // 1.1.1
+                                text("Bar") // 1.1.2
+                                ),
+                        row( // 1.2
+                                props -> {
+                                    props.modifiers.border.widthDp = 5;
+                                },
+                                text("Baz") // 1.2.1
+                                ),
+                        text("blah blah"), // 1.3
+                        arc(arcText("arctext")) // 1.4
+                        ));
+    }
+
+    private static String textValue(LayoutElement element) {
+        return element.getText().getText().getValue();
+    }
+
+    private static String textValue(ArcLayoutElement element) {
+        return element.getText().getText().getValue();
+    }
+
+    private static String textValue(Span element) {
+        return element.getText().getText().getValue();
+    }
+}
diff --git a/wear/protolayout/protolayout-renderer/src/test/java/androidx/wear/protolayout/renderer/dynamicdata/AddToListCallback.java b/wear/protolayout/protolayout-renderer/src/test/java/androidx/wear/protolayout/renderer/dynamicdata/AddToListCallback.java
new file mode 100644
index 0000000..3840255
--- /dev/null
+++ b/wear/protolayout/protolayout-renderer/src/test/java/androidx/wear/protolayout/renderer/dynamicdata/AddToListCallback.java
@@ -0,0 +1,53 @@
+/*
+ * 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.protolayout.renderer.dynamicdata;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.wear.protolayout.expression.pipeline.DynamicTypeValueReceiver;
+
+import java.util.List;
+
+public class AddToListCallback<T> implements DynamicTypeValueReceiver<T> {
+    private final List<T> mListToUpdate;
+    @Nullable private final List<Boolean> mInvalidListToUpdate;
+
+    public AddToListCallback(List<T> list) {
+        this.mListToUpdate = list;
+        this.mInvalidListToUpdate = null;
+    }
+
+    public AddToListCallback(List<T> list, @Nullable List<Boolean> invalidList) {
+        this.mListToUpdate = list;
+        this.mInvalidListToUpdate = invalidList;
+    }
+
+    @Override
+    public void onPreUpdate() {}
+
+    @Override
+    public void onData(@NonNull T newData) {
+        mListToUpdate.add(newData);
+    }
+
+    @Override
+    public void onInvalidated() {
+        if (mInvalidListToUpdate != null) {
+            mInvalidListToUpdate.add(true);
+        }
+    }
+}
diff --git a/wear/protolayout/protolayout-renderer/src/test/java/androidx/wear/protolayout/renderer/dynamicdata/PositionIdTreeTest.java b/wear/protolayout/protolayout-renderer/src/test/java/androidx/wear/protolayout/renderer/dynamicdata/PositionIdTreeTest.java
new file mode 100644
index 0000000..14f19af
--- /dev/null
+++ b/wear/protolayout/protolayout-renderer/src/test/java/androidx/wear/protolayout/renderer/dynamicdata/PositionIdTreeTest.java
@@ -0,0 +1,237 @@
+/*
+ * 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.protolayout.renderer.dynamicdata;
+
+import static androidx.wear.protolayout.renderer.common.ProtoLayoutDiffer.FIRST_CHILD_INDEX;
+import static androidx.wear.protolayout.renderer.common.ProtoLayoutDiffer.ROOT_NODE_ID;
+import static androidx.wear.protolayout.renderer.common.ProtoLayoutDiffer.createNodePosId;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.wear.protolayout.renderer.dynamicdata.PositionIdTree.TreeNode;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+@RunWith(AndroidJUnit4.class)
+public class PositionIdTreeTest {
+    private static final String NODE_ROOT = ROOT_NODE_ID;
+    private static final String NODE_1 = createNodePosId(ROOT_NODE_ID, FIRST_CHILD_INDEX);
+    private static final String NODE_2 = createNodePosId(ROOT_NODE_ID, FIRST_CHILD_INDEX + 1);
+    private static final String NODE_2_1 = createNodePosId(NODE_2, FIRST_CHILD_INDEX);
+    private static final String NODE_2_2 = createNodePosId(NODE_2, FIRST_CHILD_INDEX + 1);
+    private static final String NODE_3 = createNodePosId(ROOT_NODE_ID, FIRST_CHILD_INDEX + 2);
+    private static final String NODE_3_1 = createNodePosId(NODE_3, FIRST_CHILD_INDEX);
+    private static final String NODE_3_1_1 = createNodePosId(NODE_3_1, FIRST_CHILD_INDEX);
+    private static final String NODE_3_1_1_1 = createNodePosId(NODE_3_1_1, FIRST_CHILD_INDEX);
+
+    @Rule public final MockitoRule mMocks = MockitoJUnit.rule();
+
+    @Mock TreeNode mNodeRoot;
+    @Mock TreeNode mNode1;
+    @Mock TreeNode mNode2;
+    @Mock TreeNode mNode2Child1;
+    @Mock TreeNode mNode2Child2;
+    @Mock TreeNode mNode3;
+    @Mock TreeNode mNode3Child1;
+    @Mock TreeNode mNode3Child1Child1;
+    @Mock TreeNode mNode3Child1Child1Child1;
+    @Mock TreeNode mTestNode;
+
+    PositionIdTree<TreeNode> mTree;
+    List<TreeNode> mAllNodes;
+
+    @Before
+    public void setUp() {
+        mTree = new PositionIdTree<>();
+        mTree.addOrReplace(NODE_ROOT, mNodeRoot);
+        mTree.addOrReplace(NODE_1, mNode1);
+        mTree.addOrReplace(NODE_2, mNode2);
+        mTree.addOrReplace(NODE_2_1, mNode2Child1);
+        mTree.addOrReplace(NODE_2_2, mNode2Child2);
+        mTree.addOrReplace(NODE_3, mNode3);
+
+        mAllNodes = Arrays.asList(mNodeRoot, mNode1, mNode2, mNode2Child1, mNode2Child2, mNode3);
+    }
+
+    @Test
+    public void emptyTree_getAllNodesIsEmpty() {
+        mTree = new PositionIdTree<>();
+
+        assertThat(mTree.getAllNodes()).isEmpty();
+    }
+
+    @Test
+    public void clear_emptiesTheTreeAndDestroysAllNodes() {
+        mTree.clear();
+
+        assertThat(mTree.getAllNodes()).isEmpty();
+        mAllNodes.forEach(treeNode -> verify(treeNode).destroy());
+    }
+
+    @Test
+    public void addOrReplace_noOldValue_insertsInTheTree() {
+        mTree.addOrReplace(createNodePosId(NODE_3, FIRST_CHILD_INDEX), mTestNode);
+
+        assertThat(mTree.getAllNodes()).hasSize(mAllNodes.size() + 1);
+        assertThat(mTree.getAllNodes()).contains(mTestNode);
+    }
+
+    @Test
+    public void addOrReplace_oldValue_destroysTheOldValueAndReplacesIt() {
+        List<TreeNode> expectedNodes =
+                Stream.concat(
+                                Stream.of(mTestNode),
+                                mAllNodes.stream().filter(treeNode -> !treeNode.equals(mNode1)))
+                        .collect(Collectors.toList());
+
+        mTree.addOrReplace(NODE_1, mTestNode);
+
+        assertThat(mTree.getAllNodes()).doesNotContain(mNode1);
+        verify(mNode1).destroy();
+        assertThat(mTree.getAllNodes()).containsExactlyElementsIn(expectedNodes);
+        expectedNodes.forEach(treeNode -> verify(treeNode, never()).destroy());
+    }
+
+    @Test
+    public void removeSubtreeByPosId_removesAndDestroysSubtree() {
+        mTree.removeChildNodesFor(NODE_2);
+
+        assertThat(mTree.getAllNodes()).contains(mNode2);
+        assertThat(mTree.getAllNodes()).containsNoneOf(mNode2Child1, mNode2Child2);
+        verify(mNode2, never()).destroy();
+        verify(mNode2Child1).destroy();
+        verify(mNode2Child2).destroy();
+    }
+
+    @Test
+    public void foreach_runsOnEachNodeOnce() {
+        List<TreeNode> loopedOverNodes = new ArrayList<>();
+
+        mTree.forEach(loopedOverNodes::add);
+
+        assertThat(loopedOverNodes).containsExactlyElementsIn(mAllNodes);
+    }
+
+    @Test
+    public void findFirst_emptyTree_returnsNull() {
+        mTree.clear();
+
+        assertThat(mTree.findFirst(treeNode -> true)).isNull();
+    }
+
+    @Test
+    public void findFirst_noMatch_returnsNull() {
+        assertThat(mTree.findFirst(treeNode -> false)).isNull();
+    }
+
+    @Test
+    public void findFirst_twoMatches_returnsFirstOne() {
+        assertThat(mTree.findFirst(treeNode -> treeNode == mNode1 || treeNode == mNode2))
+                .isEqualTo(mNode1);
+    }
+
+    @Test
+    public void findAncestor_onlySearchesNodesAboveTheNode() {
+        List<TreeNode> nodesOfInterest = Arrays.asList(mNodeRoot, mNode2, mNode2Child1, mNode1);
+
+        assertThat(mTree.findAncestorsFor(NODE_2_1, nodesOfInterest::contains))
+                .containsExactly(mNodeRoot, mNode2);
+    }
+
+    @Test
+    public void findAncestor_disjointTree_searchesAllAboveNodes() {
+        // Missing NODE_3_1
+        mTree.addOrReplace(NODE_3_1_1, mNode3Child1Child1);
+        mTree.addOrReplace(NODE_3_1_1_1, mNode3Child1Child1Child1);
+        List<TreeNode> nodesOfInterest = Arrays.asList(mNodeRoot, mNode1, mNode3Child1Child1, mNode3);
+
+        assertThat(mTree.findAncestorsFor(NODE_3_1_1_1, nodesOfInterest::contains))
+                .containsExactly(mNodeRoot, mNode3Child1Child1, mNode3);
+    }
+
+    @Test
+    public void findAncestor_emptyTree_returnsNothing() {
+        mTree.clear();
+        List<TreeNode> nodesOfInterest = Arrays.asList(mNodeRoot, mNode2, mNode2Child1, mNode1);
+
+        assertThat(mTree.findAncestorsFor(NODE_2_1, nodesOfInterest::contains)).isEmpty();
+    }
+
+    @Test
+    public void findAncestor_noMatch_returnsNothing() {
+        assertThat(mTree.findAncestorsFor(NODE_2_1, treeNode -> false)).isEmpty();
+    }
+
+    @Test
+    public void findChildren_onlySearchesBelowTheNode() {
+        List<TreeNode> nodesOfInterest = Arrays.asList(mNodeRoot, mNode2, mNode2Child1, mNode1);
+
+        assertThat(mTree.findChildrenFor(NODE_2, nodesOfInterest::contains))
+                .containsExactly(mNode2Child1);
+    }
+
+    @Test
+    public void findChildren_emptyTree_returnsNothing() {
+        mTree.clear();
+        List<TreeNode> nodesOfInterest = Arrays.asList(mNodeRoot, mNode2, mNode2Child1, mNode1);
+
+        assertThat(mTree.findChildrenFor(NODE_2_1, nodesOfInterest::contains)).isEmpty();
+    }
+
+    @Test
+    public void findChildren_noMatch_returnsNothing() {
+        assertThat(mTree.findChildrenFor(NODE_2_1, treeNode -> false)).isEmpty();
+    }
+
+    @Test
+    public void findChildren_disjointTree_onlySearchesUpToTheMissingNode() {
+        mTree.addOrReplace(NODE_3_1, mNode3Child1);
+        // Missing NODE_3_1_1
+        mTree.addOrReplace(NODE_3_1_1_1, mNode3Child1Child1Child1);
+        List<TreeNode> nodesOfInterest = Arrays.asList(mNode3, mNode3Child1,
+                mNode3Child1Child1Child1);
+
+        assertThat(mTree.findChildrenFor(NODE_3, nodesOfInterest::contains))
+                .containsExactly(mNode3Child1);
+    }
+
+    @Test
+    public void get_nodeExists_returnsTheNode() {
+        assertThat(mTree.get(NODE_2)).isEqualTo(mNode2);
+    }
+
+    @Test
+    public void get_nonExistentNode_returnsNull() {
+        assertThat(mTree.get("NON_EXISTENT")).isNull();
+    }
+}
diff --git a/wear/protolayout/protolayout-renderer/src/test/java/androidx/wear/protolayout/renderer/dynamicdata/ProtoLayoutDynamicDataPipelineTest.java b/wear/protolayout/protolayout-renderer/src/test/java/androidx/wear/protolayout/renderer/dynamicdata/ProtoLayoutDynamicDataPipelineTest.java
new file mode 100644
index 0000000..fe03f6f
--- /dev/null
+++ b/wear/protolayout/protolayout-renderer/src/test/java/androidx/wear/protolayout/renderer/dynamicdata/ProtoLayoutDynamicDataPipelineTest.java
@@ -0,0 +1,1396 @@
+package androidx.wear.protolayout.renderer.dynamicdata;
+
+import static android.os.Looper.getMainLooper;
+
+import static androidx.test.core.app.ApplicationProvider.getApplicationContext;
+import static androidx.wear.protolayout.renderer.common.ProtoLayoutDiffer.FIRST_CHILD_INDEX;
+import static androidx.wear.protolayout.renderer.common.ProtoLayoutDiffer.ROOT_NODE_ID;
+import static androidx.wear.protolayout.renderer.common.ProtoLayoutDiffer.createNodePosId;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.robolectric.Shadows.shadowOf;
+
+import static java.lang.Integer.MAX_VALUE;
+
+import android.app.Activity;
+import android.graphics.drawable.AnimatedVectorDrawable;
+import android.widget.FrameLayout;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.vectordrawable.graphics.drawable.SeekableAnimatedVectorDrawable;
+import androidx.wear.protolayout.expression.pipeline.FixedQuotaManagerImpl;
+import androidx.wear.protolayout.expression.pipeline.ObservableStateStore;
+import androidx.wear.protolayout.expression.pipeline.QuotaManager;
+import androidx.wear.protolayout.expression.proto.AnimationParameterProto.AnimationSpec;
+import androidx.wear.protolayout.expression.proto.AnimationParameterProto.RepeatMode;
+import androidx.wear.protolayout.expression.proto.AnimationParameterProto.Repeatable;
+import androidx.wear.protolayout.expression.proto.DynamicProto.AnimatableDynamicColor;
+import androidx.wear.protolayout.expression.proto.DynamicProto.AnimatableDynamicFloat;
+import androidx.wear.protolayout.expression.proto.DynamicProto.AnimatableFixedColor;
+import androidx.wear.protolayout.expression.proto.DynamicProto.AnimatableFixedFloat;
+import androidx.wear.protolayout.expression.proto.DynamicProto.ArithmeticInt32Op;
+import androidx.wear.protolayout.expression.proto.DynamicProto.ArithmeticOpType;
+import androidx.wear.protolayout.expression.proto.DynamicProto.DynamicBool;
+import androidx.wear.protolayout.expression.proto.DynamicProto.DynamicColor;
+import androidx.wear.protolayout.expression.proto.DynamicProto.DynamicFloat;
+import androidx.wear.protolayout.expression.proto.DynamicProto.DynamicInt32;
+import androidx.wear.protolayout.expression.proto.DynamicProto.StateBoolSource;
+import androidx.wear.protolayout.expression.proto.DynamicProto.StateFloatSource;
+import androidx.wear.protolayout.expression.proto.FixedProto.FixedBool;
+import androidx.wear.protolayout.expression.proto.FixedProto.FixedColor;
+import androidx.wear.protolayout.expression.proto.FixedProto.FixedFloat;
+import androidx.wear.protolayout.expression.proto.FixedProto.FixedInt32;
+import androidx.wear.protolayout.expression.proto.StateEntryProto.StateEntryValue;
+import androidx.wear.protolayout.proto.ColorProto.ColorProp;
+import androidx.wear.protolayout.proto.DimensionProto.DegreesProp;
+import androidx.wear.protolayout.proto.DimensionProto.DpProp;
+import androidx.wear.protolayout.proto.TriggerProto.OnConditionMetTrigger;
+import androidx.wear.protolayout.proto.TriggerProto.OnLoadTrigger;
+import androidx.wear.protolayout.proto.TriggerProto.OnVisibleOnceTrigger;
+import androidx.wear.protolayout.proto.TriggerProto.OnVisibleTrigger;
+import androidx.wear.protolayout.proto.TriggerProto.Trigger;
+import androidx.wear.protolayout.proto.TriggerProto.Trigger.InnerCase;
+import androidx.wear.protolayout.renderer.dynamicdata.ProtoLayoutDynamicDataPipeline.PipelineMaker;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Range;
+import com.google.common.truth.Expect;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.Robolectric;
+import org.robolectric.shadows.ShadowLooper;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Comparator;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
+
+// Note: Most of the functionality of DynamicDataPipeline should be tested using //
+// DynamicDataPipelineProtoTest instead. This test class only exists for the cases that cannot be //
+// trivially tested there (e.g. throwing exceptions in response to feature flags or handling //
+// animations).
+@RunWith(AndroidJUnit4.class)
+public class ProtoLayoutDynamicDataPipelineTest {
+    private static final String NODE_1_1 = createNodePosId(ROOT_NODE_ID, FIRST_CHILD_INDEX);
+    private static final String NODE_1_2 = createNodePosId(ROOT_NODE_ID, FIRST_CHILD_INDEX + 1);
+    private static final String NODE_1_11 = createNodePosId(ROOT_NODE_ID, FIRST_CHILD_INDEX + 10);
+    private static final String NODE_1_1_1 = createNodePosId(NODE_1_1, FIRST_CHILD_INDEX);
+    private static final String NODE_1_1_1_1 = createNodePosId(NODE_1_1_1, FIRST_CHILD_INDEX);
+    private final ObservableStateStore mStateStore = new ObservableStateStore(ImmutableMap.of());
+    public static final String TEST_POS_ID = ROOT_NODE_ID;
+    @Rule public final Expect expect = Expect.create();
+
+    FrameLayout mRootContainer;
+
+    @Before
+    public void setUp() {
+        mRootContainer = new FrameLayout(getApplicationContext());
+        // This needs to be an attached view to test animations in data pipeline.
+        Robolectric.buildActivity(Activity.class).setup().get().setContentView(mRootContainer);
+    }
+
+    @Test
+    public void
+            buildPipeline_animatableFixedFloat_animationsDisabled_noStaticValueSet_assignsEndValue() {
+        List<Float> results = new ArrayList<>();
+        float startValue = 5.0f;
+        float endValue = 10.0f;
+        DynamicFloat dynamicFloat = animatableFixedFloat(startValue, endValue);
+
+        ProtoLayoutDynamicDataPipeline pipeline =
+                initPipelineAnimationsDisabled(results, dynamicFloat);
+
+        expect.that(pipeline.getRunningAnimationsCount()).isEqualTo(0);
+        shadowOf(getMainLooper()).idle();
+        expect.that(results).hasSize(1);
+        expect.that(results).containsExactly(endValue);
+    }
+
+    @Test
+    public void
+            buildPipeline_animatableFixedColor_animationsDisabled_noStaticValueSet_assignsEndValue() {
+        List<Integer> results = new ArrayList<>();
+        int startValue = 0;
+        int endValue = 1;
+        DynamicColor dynamicColor = animatableFixedColor(startValue, endValue);
+
+        ProtoLayoutDynamicDataPipeline pipeline =
+                initPipelineAnimationsDisabled(results, dynamicColor);
+
+        expect.that(pipeline.getRunningAnimationsCount()).isEqualTo(0);
+        shadowOf(getMainLooper()).idle();
+        expect.that(results).hasSize(1);
+        expect.that(results).containsExactly(endValue);
+    }
+
+    @Test
+    public void buildPipeline_dpProp_animatable_animationsDisabled_assignsStaticValue() {
+        List<Float> results = new ArrayList<>();
+        float staticValue = -5f;
+        DynamicFloat dynamicFloat = animatableFixedFloat(5.0f, 10.0f);
+        DpProp dpProp =
+                DpProp.newBuilder().setDynamicValue(dynamicFloat).setValue(staticValue).build();
+
+        ProtoLayoutDynamicDataPipeline pipeline = initPipelineAnimationsDisabled(results, dpProp);
+
+        expect.that(pipeline.getRunningAnimationsCount()).isEqualTo(0);
+        expect.that(results).hasSize(1);
+        expect.that(results).containsExactly(staticValue);
+    }
+
+    @Test
+    public void buildPipeline_degreesProp_animatable_animationsDisabled_assignsStaticValue() {
+        List<Float> results = new ArrayList<>();
+        float staticValue = -5f;
+        DynamicFloat dynamicFloat = animatableFixedFloat(5.0f, 10.0f);
+        DegreesProp degreesProp =
+                DegreesProp.newBuilder()
+                        .setDynamicValue(dynamicFloat)
+                        .setValue(staticValue)
+                        .build();
+
+        ProtoLayoutDynamicDataPipeline pipeline =
+                initPipelineAnimationsDisabled(results, degreesProp);
+
+        expect.that(pipeline.getRunningAnimationsCount()).isEqualTo(0);
+        expect.that(results).hasSize(1);
+        expect.that(results).containsExactly(staticValue);
+    }
+
+    @Test
+    public void buildPipeline_colorProp_animatable_animationsDisabled_assignsStaticValue() {
+        List<Integer> results = new ArrayList<>();
+        int staticValue = 0x12345678;
+        DynamicColor dynamicColor = animatableFixedColor(0, 1);
+        ColorProp colorProp =
+                ColorProp.newBuilder().setDynamicValue(dynamicColor).setArgb(staticValue).build();
+
+        ProtoLayoutDynamicDataPipeline pipeline =
+                initPipelineAnimationsDisabled(results, colorProp);
+
+        expect.that(pipeline.getRunningAnimationsCount()).isEqualTo(0);
+        expect.that(results).hasSize(1);
+        expect.that(results).containsExactly(staticValue);
+    }
+
+    @Test
+    public void
+            buildPipeline_colorProp_animatable_animationsDisabled_noStaticValueSet_assignsEndValue() {
+        List<Integer> results = new ArrayList<>();
+        DynamicColor dynamicColor = animatableFixedColor(0, 1);
+        ColorProp colorProp = ColorProp.newBuilder().setDynamicValue(dynamicColor).build();
+
+        ProtoLayoutDynamicDataPipeline pipeline =
+                initPipelineAnimationsDisabled(results, colorProp);
+
+        expect.that(pipeline.getRunningAnimationsCount()).isEqualTo(0);
+        expect.that(results).hasSize(1);
+        expect.that(results).containsExactly(1);
+    }
+
+    @Test
+    public void buildPipeline_animatableFixedFloat_animationsEnabled_builds() {
+        List<Float> results = new ArrayList<>();
+        DynamicFloat dynamicFloat = animatableFixedFloat(5.0f, 10.0f);
+
+        // Leave this as-is to assert it doesn't throw...
+        initPipeline(results, /* enableAnimations= */ true, dynamicFloat, /* animationsNum= */ 1);
+    }
+
+    @Test
+    public void animatableFixedFloat_emitsAnimatedValuesOnStart() {
+        List<Float> results = new ArrayList<>();
+        DynamicFloat dynamicFloat = animatableFixedFloat(5.0f, 10.0f);
+
+        // Leave this as-is to assert it doesn't throw...
+        initPipeline(results, /* enableAnimations= */ true, dynamicFloat, /* animationsNum= */ 1);
+
+        assertAnimation(results, 5.0f, 10.0f);
+    }
+
+    @Test
+    public void
+            buildPipeline_animatableDynamicFloat_animationsDisabled_noStaticValueSet_assignsEndValue() {
+        List<Float> results = new ArrayList<>();
+        float endValue = 1.0f;
+        DynamicFloat dynamicFloat =
+                DynamicFloat.newBuilder()
+                        .setAnimatableDynamic(
+                                AnimatableDynamicFloat.newBuilder()
+                                        .setInput(
+                                                DynamicFloat.newBuilder()
+                                                        .setFixed(
+                                                                FixedFloat.newBuilder()
+                                                                        .setValue(endValue))))
+                        .build();
+
+        ProtoLayoutDynamicDataPipeline pipeline =
+                initPipelineAnimationsDisabled(results, dynamicFloat);
+
+        expect.that(pipeline.getRunningAnimationsCount()).isEqualTo(0);
+        expect.that(results).hasSize(1);
+        expect.that(results).containsExactly(endValue);
+    }
+
+    @Test
+    public void
+            buildPipeline_animatableDynamicColor_animationsDisabled_noStaticValueSet_assignsEndValue() {
+        List<Integer> results = new ArrayList<>();
+        int endValue = 1;
+        DynamicColor dynamicColor =
+                DynamicColor.newBuilder()
+                        .setAnimatableDynamic(
+                                AnimatableDynamicColor.newBuilder()
+                                        .setInput(
+                                                DynamicColor.newBuilder()
+                                                        .setFixed(
+                                                                FixedColor.newBuilder()
+                                                                        .setArgb(endValue))))
+                        .build();
+
+        ProtoLayoutDynamicDataPipeline pipeline =
+                initPipelineAnimationsDisabled(results, dynamicColor);
+
+        expect.that(pipeline.getRunningAnimationsCount()).isEqualTo(0);
+        expect.that(results).hasSize(1);
+        expect.that(results).containsExactly(endValue);
+    }
+
+    @Test
+    public void buildPipeline_animatableDynamicFloat_animationsEnabled_builds() {
+        List<Float> results = new ArrayList<>();
+        DynamicFloat dynamicFloat =
+                DynamicFloat.newBuilder()
+                        .setAnimatableDynamic(
+                                AnimatableDynamicFloat.newBuilder()
+                                        .setInput(
+                                                DynamicFloat.newBuilder()
+                                                        .setFixed(
+                                                                FixedFloat.newBuilder()
+                                                                        .setValue(1.0f))))
+                        .build();
+
+        // Leave this as-is to assert it doesn't throw...
+        initPipeline(results, /* enableAnimations= */ true, dynamicFloat, /* animationsNum= */ 0);
+    }
+
+    @Test
+    public void buildPipeline_animatableDynamicFloat_animationsEnabled_withAnimations_builds() {
+        List<Float> results = new ArrayList<>();
+        DynamicFloat dynamicFloat =
+                DynamicFloat.newBuilder()
+                        .setAnimatableDynamic(
+                                AnimatableDynamicFloat.newBuilder()
+                                        .setInput(
+                                                DynamicFloat.newBuilder()
+                                                        .setAnimatableFixed(
+                                                                AnimatableFixedFloat.newBuilder()
+                                                                        .setFromValue(1)
+                                                                        .setToValue(2))))
+                        .build();
+
+        // Leave this as-is to assert it doesn't throw...
+        initPipeline(results, /* enableAnimations= */ true, dynamicFloat, /* animationsNum= */ 1);
+    }
+
+    @Test
+    public void buildPipeline_animatableDynamicFloat_emitsAnimatedValues() {
+        List<Float> results = new ArrayList<>();
+        setFloatStateVal("anim_val", 5.0f);
+
+        DynamicFloat dynamicFloat =
+                DynamicFloat.newBuilder()
+                        .setAnimatableDynamic(
+                                AnimatableDynamicFloat.newBuilder()
+                                        .setInput(
+                                                DynamicFloat.newBuilder()
+                                                        .setStateSource(
+                                                                StateFloatSource.newBuilder()
+                                                                        .setSourceKey("anim_val"))))
+                        .build();
+        initPipeline(results, /* enableAnimations= */ true, dynamicFloat, /* animationsNum= */ 0);
+
+        assertThat(results).containsExactly(5.0f);
+        results.clear();
+
+        setFloatStateVal("anim_val", 15.0f);
+        assertAnimation(results, 5.0f, 15.0f);
+    }
+
+    @Test
+    public void buildPipeline_animatableDynamicFloat_skipsToEndIfInvisible() {
+        List<Float> results = new ArrayList<>();
+        AddToListCallback<Float> receiver = new AddToListCallback<>(results);
+        setFloatStateVal("anim_val", 5.0f);
+
+        DynamicFloat dynamicFloat =
+                DynamicFloat.newBuilder()
+                        .setAnimatableDynamic(
+                                AnimatableDynamicFloat.newBuilder()
+                                        .setInput(
+                                                DynamicFloat.newBuilder()
+                                                        .setStateSource(
+                                                                StateFloatSource.newBuilder()
+                                                                        .setSourceKey("anim_val"))))
+                        .build();
+        ProtoLayoutDynamicDataPipeline pipeline =
+                new ProtoLayoutDynamicDataPipeline(
+                        /* canUpdateGateways= */ true,
+                        /* sensorGateway= */ null,
+                        mStateStore,
+                        new FixedQuotaManagerImpl(MAX_VALUE));
+
+        pipeline.setFullyVisible(false);
+        pipeline.newPipelineMaker()
+                .addPipelineFor(dynamicFloat, TEST_POS_ID, receiver)
+                .commit(mRootContainer, /* isReattaching= */ false);
+        assertThat(pipeline.size()).isEqualTo(2);
+        shadowOf(getMainLooper()).idle();
+        assertThat(results).containsExactly(5.0f);
+        results.clear();
+
+        setFloatStateVal("anim_val", 15.0f);
+        assertThat(results).containsExactly(15.0f);
+    }
+
+    @Test
+    public void buildPipeline_animatableDynamicFloat_noInitialValueEmitsInvalid() {
+        List<Float> results = new ArrayList<>();
+        List<Boolean> invalidResults = new ArrayList<>();
+
+        DynamicFloat dynamicFloat =
+                DynamicFloat.newBuilder()
+                        .setAnimatableDynamic(
+                                AnimatableDynamicFloat.newBuilder()
+                                        .setInput(
+                                                DynamicFloat.newBuilder()
+                                                        .setStateSource(
+                                                                StateFloatSource.newBuilder()
+                                                                        .setSourceKey("anim_val"))))
+                        .build();
+        initPipeline(
+                results,
+                invalidResults,
+                /* enableAnimations= */ true,
+                dynamicFloat,
+                /* animationsNum= */ 0);
+
+        assertThat(results).isEmpty();
+        assertThat(invalidResults).contains(true);
+        invalidResults.clear();
+
+        // First value is not animated.
+        setFloatStateVal("anim_val", 1.0f);
+
+        assertThat(results).containsExactly(1.0f);
+        assertThat(invalidResults).isEmpty();
+        results.clear();
+
+        // Second update is animated...
+        setFloatStateVal("anim_val", 10.0f);
+        assertAnimation(results, 1.0f, 10.0f);
+        assertThat(invalidResults).isEmpty();
+    }
+
+    @Test
+    public void buildPipeline_noCommit() {
+        List<Integer> results = new ArrayList<>();
+        List<Boolean> invalidResults = new ArrayList<>();
+        AddToListCallback<Integer> receiver = new AddToListCallback<>(results, invalidResults);
+
+        DynamicInt32 dynamicInt = fixedDynamicInt32(1);
+        ProtoLayoutDynamicDataPipeline pipeline =
+                new ProtoLayoutDynamicDataPipeline(
+                        /* canUpdateGateways= */ true,
+                        /* sensorGateway= */ null,
+                        mStateStore,
+                        new FixedQuotaManagerImpl(MAX_VALUE));
+        // Add pipeline to PipelineMaker but do not commit nodes.
+        ProtoLayoutDynamicDataPipeline.PipelineMaker unusedPipelineMaker =
+                pipeline.newPipelineMaker().addPipelineFor(dynamicInt, TEST_POS_ID, receiver);
+        shadowOf(getMainLooper()).idle();
+
+        assertThat(results).isEmpty();
+        assertThat(invalidResults).isEmpty();
+    }
+
+    @Test
+    public void buildPipeline_multipleCommits() {
+        List<Integer> results = new ArrayList<>();
+        AddToListCallback<Integer> receiver = new AddToListCallback<>(results);
+
+        ProtoLayoutDynamicDataPipeline pipeline =
+                new ProtoLayoutDynamicDataPipeline(
+                        /* canUpdateGateways= */ true,
+                        /* sensorGateway= */ null,
+                        mStateStore,
+                        new FixedQuotaManagerImpl(MAX_VALUE));
+
+        DynamicInt32 dynamicInt1 = fixedDynamicInt32(1);
+        pipeline.newPipelineMaker()
+                .addPipelineFor(dynamicInt1, TEST_POS_ID, receiver)
+                .commit(mRootContainer, /* isReattaching= */ false);
+        assertThat(pipeline.size()).isEqualTo(1);
+        shadowOf(getMainLooper()).idle();
+        assertThat(results).containsExactly(1);
+
+        // A second commit with the same PosId resets the content for that PosId.
+        DynamicInt32 dynamicInt2 = fixedDynamicInt32(2);
+        pipeline.newPipelineMaker()
+                .addPipelineFor(dynamicInt2, TEST_POS_ID, receiver)
+                .commit(mRootContainer, /* isReattaching= */ false);
+        assertThat(pipeline.size()).isEqualTo(1);
+        shadowOf(getMainLooper()).idle();
+        assertThat(results).containsExactly(1, 2).inOrder();
+    }
+
+    @Test
+    public void buildPipeline_multipleCommitFromSameMaker() {
+        List<Integer> results = new ArrayList<>();
+        AddToListCallback<Integer> receiver = new AddToListCallback<>(results);
+
+        ProtoLayoutDynamicDataPipeline pipeline =
+                new ProtoLayoutDynamicDataPipeline(
+                        /* canUpdateGateways= */ true,
+                        /* sensorGateway= */ null,
+                        mStateStore,
+                        new FixedQuotaManagerImpl(MAX_VALUE));
+
+        DynamicInt32 dynamicInt1 = fixedDynamicInt32(1);
+        ProtoLayoutDynamicDataPipeline.PipelineMaker pMaker =
+                pipeline.newPipelineMaker().addPipelineFor(dynamicInt1, TEST_POS_ID, receiver);
+
+        pMaker.commit(mRootContainer, /* isReattaching= */ false);
+        assertThat(pipeline.size()).isEqualTo(1);
+        shadowOf(getMainLooper()).idle();
+        assertThat(results).containsExactly(1);
+
+        // Successive commit should be a no-op.
+        pMaker.commit(mRootContainer, /* isReattaching= */ false);
+        assertThat(pipeline.size()).isEqualTo(1);
+        shadowOf(getMainLooper()).idle();
+        assertThat(results).containsExactly(1);
+    }
+
+    @Test
+    public void buildPipeline_removeNodesRecursively() {
+        List<Integer> results = new ArrayList<>();
+        AddToListCallback<Integer> receiver = new AddToListCallback<>(results);
+
+        ProtoLayoutDynamicDataPipeline pipeline =
+                new ProtoLayoutDynamicDataPipeline(
+                        /* canUpdateGateways= */ true,
+                        /* sensorGateway= */ null,
+                        mStateStore,
+                        new FixedQuotaManagerImpl(MAX_VALUE));
+
+        DynamicInt32 dynamicInt1 =
+                DynamicInt32.newBuilder()
+                        .setArithmeticOperation(
+                                ArithmeticInt32Op.newBuilder()
+                                        .setOperationType(ArithmeticOpType.ARITHMETIC_OP_TYPE_ADD)
+                                        .setInputLhs(fixedDynamicInt32(1))
+                                        .setInputRhs(fixedDynamicInt32(2)))
+                        .build();
+        pipeline.newPipelineMaker()
+                .addPipelineFor(dynamicInt1, NODE_1_1, receiver)
+                .commit(mRootContainer, /* isReattaching= */ false);
+        assertThat(pipeline.size()).isEqualTo(3);
+        shadowOf(getMainLooper()).idle();
+        assertThat(results).containsExactly(3);
+
+        DynamicInt32 dynamicInt2 = fixedDynamicInt32(4);
+        pipeline.newPipelineMaker()
+                .addPipelineFor(dynamicInt2, NODE_1_1_1, receiver)
+                .commit(mRootContainer, /* isReattaching= */ false);
+        assertThat(pipeline.size()).isEqualTo(4);
+        shadowOf(getMainLooper()).idle();
+        assertThat(results).containsExactly(3, 4).inOrder();
+
+        DynamicInt32 dynamicInt3 = fixedDynamicInt32(5);
+        pipeline.newPipelineMaker()
+                .addPipelineFor(dynamicInt3, NODE_1_11, receiver)
+                .commit(mRootContainer, /* isReattaching= */ false);
+        assertThat(pipeline.size()).isEqualTo(5);
+        shadowOf(getMainLooper()).idle();
+        assertThat(results).containsExactly(3, 4, 5).inOrder();
+
+        DynamicInt32 dynamicInt4 = fixedDynamicInt32(6);
+        pipeline.newPipelineMaker()
+                .addPipelineFor(dynamicInt4, NODE_1_2, receiver)
+                .commit(mRootContainer, /* isReattaching= */ false);
+        assertThat(pipeline.size()).isEqualTo(6);
+        shadowOf(getMainLooper()).idle();
+        assertThat(results).containsExactly(3, 4, 5, 6).inOrder();
+
+        // Remove node NODE_1_1_1. NODE_1_1, NODE_1_11 and NODE_1_2 should remain in the pipeline.
+        pipeline.removeChildNodesFor(NODE_1_1);
+        assertThat(pipeline.size()).isEqualTo(5);
+    }
+
+    @Test
+    public void buildPipeline_clearPipeline() {
+        List<Integer> results = new ArrayList<>();
+        AddToListCallback<Integer> receiver = new AddToListCallback<>(results);
+
+        ProtoLayoutDynamicDataPipeline pipeline =
+                new ProtoLayoutDynamicDataPipeline(
+                        /* canUpdateGateways= */ true,
+                        /* sensorGateway= */ null,
+                        mStateStore,
+                        new FixedQuotaManagerImpl(MAX_VALUE));
+
+        DynamicInt32 dynamicInt1 =
+                DynamicInt32.newBuilder()
+                        .setArithmeticOperation(
+                                ArithmeticInt32Op.newBuilder()
+                                        .setOperationType(ArithmeticOpType.ARITHMETIC_OP_TYPE_ADD)
+                                        .setInputLhs(fixedDynamicInt32(1))
+                                        .setInputRhs(fixedDynamicInt32(2)))
+                        .build();
+        pipeline.newPipelineMaker()
+                .addPipelineFor(dynamicInt1, NODE_1_1, receiver)
+                .commit(mRootContainer, /* isReattaching= */ false);
+        assertThat(pipeline.size()).isEqualTo(3);
+        shadowOf(getMainLooper()).idle();
+        assertThat(results).containsExactly(3);
+
+        DynamicInt32 dynamicInt2 = fixedDynamicInt32(4);
+        pipeline.newPipelineMaker()
+                .addPipelineFor(dynamicInt2, NODE_1_1_1, receiver)
+                .commit(mRootContainer, /* isReattaching= */ false);
+        assertThat(pipeline.size()).isEqualTo(4);
+        shadowOf(getMainLooper()).idle();
+        assertThat(results).containsExactly(3, 4).inOrder();
+
+        // Clear all nodes.
+        pipeline.clear();
+        assertThat(pipeline.size()).isEqualTo(0);
+    }
+
+    @Test
+    public void getNodesAffectedBy_checksInTreeHierarchy() {
+        List<String> expected = Arrays.asList(NODE_1_1, NODE_1_1_1);
+        ProtoLayoutDynamicDataPipeline pipeline =
+                new ProtoLayoutDynamicDataPipeline(
+                        /* canUpdateGateways= */ true,
+                        /* sensorGateway= */ null,
+                        mStateStore,
+                        new FixedQuotaManagerImpl(MAX_VALUE));
+        PipelineMaker pipelineMaker = pipeline.newPipelineMaker();
+        expected.forEach(pipelineMaker::rememberNode);
+        pipelineMaker.rememberNode(NODE_1_1_1_1);
+        pipelineMaker.rememberNode(NODE_1_2);
+        pipelineMaker.commit(mRootContainer, /* isReattaching= */ false);
+
+        assertThat(
+                        pipeline.getNodesAffectedBy(NODE_1_1_1, nodeInfo -> true).stream()
+                                .map(NodeInfo::getPosId)
+                                .collect(Collectors.toList())).containsExactlyElementsIn(expected);
+    }
+
+    @Test
+    public void resolvedAnimatedImage_canStorePlayAndResetOnVisible() {
+        ProtoLayoutDynamicDataPipeline pipeline =
+                new ProtoLayoutDynamicDataPipeline(
+                        /* canUpdateGateways= */ true,
+                        /* sensorGateway= */ null,
+                        mStateStore,
+                        new FixedQuotaManagerImpl(MAX_VALUE));
+        TestAnimatedVectorDrawable drawableAvd = new TestAnimatedVectorDrawable();
+        Trigger triggerTileVisible =
+                Trigger.newBuilder()
+                        .setOnVisibleTrigger(OnVisibleTrigger.getDefaultInstance())
+                        .build();
+
+        pipeline.newPipelineMaker()
+                .addResolvedAnimatedImage(drawableAvd, triggerTileVisible, TEST_POS_ID)
+                .commit(mRootContainer, /* isReattaching= */ false);
+
+        pipeline.playAvdAnimations(InnerCase.ON_LOAD_TRIGGER);
+        expect.that(drawableAvd.started).isFalse();
+
+        pipeline.playAvdAnimations(InnerCase.ON_VISIBLE_TRIGGER);
+        expect.that(drawableAvd.started).isTrue();
+
+        pipeline.resetAvdAnimations(InnerCase.ON_VISIBLE_TRIGGER);
+        expect.that(drawableAvd.started).isFalse();
+        expect.that(drawableAvd.reset).isTrue();
+
+        // Animate the drawable again.
+        pipeline.playAvdAnimations(InnerCase.ON_VISIBLE_TRIGGER);
+        expect.that(drawableAvd.started).isTrue();
+    }
+
+    @Test
+    public void resolvedAnimatedImage_canStoreAndPlayOnVisibleOnce() {
+        ProtoLayoutDynamicDataPipeline pipeline =
+                new ProtoLayoutDynamicDataPipeline(
+                        /* canUpdateGateways= */ true,
+                        /* sensorGateway= */ null,
+                        mStateStore,
+                        new FixedQuotaManagerImpl(MAX_VALUE));
+        TestAnimatedVectorDrawable drawableAvd = new TestAnimatedVectorDrawable();
+        Trigger triggerTileVisibleOnce =
+                Trigger.newBuilder()
+                        .setOnVisibleOnceTrigger(OnVisibleOnceTrigger.getDefaultInstance())
+                        .build();
+
+        pipeline.newPipelineMaker()
+                .addResolvedAnimatedImage(drawableAvd, triggerTileVisibleOnce, TEST_POS_ID)
+                .commit(mRootContainer, /* isReattaching= */ false);
+
+        pipeline.playAvdAnimations(InnerCase.ON_LOAD_TRIGGER);
+        expect.that(drawableAvd.started).isFalse();
+
+        pipeline.playAvdAnimations(InnerCase.ON_VISIBLE_ONCE_TRIGGER);
+        expect.that(drawableAvd.started).isTrue();
+
+        drawableAvd.stop();
+
+        // Animation could be started only once.
+        pipeline.playAvdAnimations(InnerCase.ON_VISIBLE_ONCE_TRIGGER);
+        expect.that(drawableAvd.started).isFalse();
+    }
+
+    @Test
+    public void resolvedAnimatedImage_canStorePlayAndResetOnLoad() {
+        ProtoLayoutDynamicDataPipeline pipeline =
+                new ProtoLayoutDynamicDataPipeline(
+                        /* canUpdateGateways= */ true,
+                        /* sensorGateway= */ null,
+                        mStateStore,
+                        new FixedQuotaManagerImpl(MAX_VALUE));
+        TestAnimatedVectorDrawable drawableAvd = new TestAnimatedVectorDrawable();
+        Trigger triggerTileLoad =
+                Trigger.newBuilder().setOnLoadTrigger(OnLoadTrigger.getDefaultInstance()).build();
+
+        pipeline.newPipelineMaker()
+                .addResolvedAnimatedImage(drawableAvd, triggerTileLoad, TEST_POS_ID)
+                .commit(mRootContainer, /* isReattaching= */ false);
+
+        pipeline.playAvdAnimations(InnerCase.ON_VISIBLE_TRIGGER);
+        expect.that(drawableAvd.started).isFalse();
+
+        pipeline.playAvdAnimations(InnerCase.ON_LOAD_TRIGGER);
+        expect.that(drawableAvd.started).isTrue();
+
+        pipeline.resetAvdAnimations(InnerCase.ON_LOAD_TRIGGER);
+        expect.that(drawableAvd.started).isFalse();
+        expect.that(drawableAvd.reset).isTrue();
+    }
+
+    @Test
+    public void conditionTriggerCallback_boolInitiallyFalse_playWhenTurnsTrue() {
+        String boolStateKey = "KEY";
+        ProtoLayoutDynamicDataPipeline pipeline =
+                new ProtoLayoutDynamicDataPipeline(
+                        /* canUpdateGateways= */ true,
+                        /* sensorGateway= */ null,
+                        mStateStore,
+                        new FixedQuotaManagerImpl(MAX_VALUE));
+        TestAnimatedVectorDrawable drawableAvd = new TestAnimatedVectorDrawable();
+        DynamicBool dynamicBool = dynamicBool(boolStateKey);
+        Trigger trigger = conditionTrigger(dynamicBool);
+        makePipelineForConditionalBoolTrigger(pipeline, drawableAvd, dynamicBool, trigger);
+
+        // First value is false. Shouldn't Trigger animation
+        setBoolStateVal(boolStateKey, false);
+        expect.that(drawableAvd.started).isFalse();
+
+        // Should Trigger animation
+        setBoolStateVal(boolStateKey, true);
+        expect.that(drawableAvd.started).isTrue();
+
+        // Shouldn't Trigger animation
+        drawableAvd.started = false;
+        setBoolStateVal(boolStateKey, true);
+        expect.that(drawableAvd.started).isFalse();
+
+        // Shouldn't Trigger animation
+        drawableAvd.started = false;
+        setBoolStateVal(boolStateKey, false);
+        expect.that(drawableAvd.started).isFalse();
+
+        // Should Trigger animation
+        setBoolStateVal(boolStateKey, true);
+        expect.that(drawableAvd.started).isTrue();
+    }
+
+    @Test
+    public void conditionTriggerCallback_boolInitiallyTrue_playOnLoad() {
+        String boolStateKey = "KEY";
+        ProtoLayoutDynamicDataPipeline pipeline =
+                new ProtoLayoutDynamicDataPipeline(
+                        /* canUpdateGateways= */ true,
+                        /* sensorGateway= */ null,
+                        mStateStore,
+                        new FixedQuotaManagerImpl(MAX_VALUE));
+        TestAnimatedVectorDrawable drawableAvd = new TestAnimatedVectorDrawable();
+        DynamicBool dynamicBool = dynamicBool(boolStateKey);
+        Trigger trigger = conditionTrigger(dynamicBool);
+        makePipelineForConditionalBoolTrigger(pipeline, drawableAvd, dynamicBool, trigger);
+
+        // Should trigger animation onLoad.
+        setBoolStateVal(boolStateKey, true);
+        expect.that(drawableAvd.started).isTrue();
+    }
+
+    @Test
+    public void
+            conditionTriggerCallback_boolInitiallyFalse_onLoadAndOnVisibilityChangeDoNotTriggerAnimation() {
+        String boolStateKey = "KEY";
+        ProtoLayoutDynamicDataPipeline pipeline =
+                new ProtoLayoutDynamicDataPipeline(
+                        /* canUpdateGateways= */ true,
+                        /* sensorGateway= */ null,
+                        mStateStore,
+                        new FixedQuotaManagerImpl(MAX_VALUE));
+        TestAnimatedVectorDrawable drawableAvd = new TestAnimatedVectorDrawable();
+        DynamicBool dynamicBool = dynamicBool(boolStateKey);
+        Trigger trigger = conditionTrigger(dynamicBool);
+        makePipelineForConditionalBoolTrigger(pipeline, drawableAvd, dynamicBool, trigger);
+
+        pipeline.playAvdAnimations(InnerCase.ON_VISIBLE_TRIGGER);
+        expect.that(drawableAvd.started).isFalse();
+
+        pipeline.playAvdAnimations(InnerCase.ON_LOAD_TRIGGER);
+        expect.that(drawableAvd.started).isFalse();
+    }
+
+    @Test
+    public void conditionTriggerCallback_playWhenTurnsTrue_quotaIsReleased() {
+        String boolStateKey = "KEY";
+        FixedQuotaManagerImpl quotaManager = new FixedQuotaManagerImpl(MAX_VALUE);
+        ProtoLayoutDynamicDataPipeline pipeline =
+                new ProtoLayoutDynamicDataPipeline(
+                        /* canUpdateGateways= */ true,
+                        /* sensorGateway= */ null,
+                        mStateStore,
+                        quotaManager);
+        TestAnimatedVectorDrawable drawableAvd = new TestAnimatedVectorDrawable();
+        DynamicBool dynamicBool = dynamicBool(boolStateKey);
+        Trigger trigger = conditionTrigger(dynamicBool);
+        makePipelineForConditionalBoolTrigger(pipeline, drawableAvd, dynamicBool, trigger);
+
+        // Should Trigger animation
+        setBoolStateVal(boolStateKey, true);
+        expect.that(drawableAvd.started).isTrue();
+        expect.that(pipeline.getRunningAnimationsCount()).isEqualTo(1);
+        expect.that(quotaManager.isAllQuotaReleased()).isFalse();
+
+        // Animation is stopped, quota should be released.
+        pipeline.stopAvdAnimations();
+        expect.that(pipeline.getRunningAnimationsCount()).isEqualTo(0);
+        expect.that(quotaManager.isAllQuotaReleased()).isTrue();
+    }
+
+    @Test
+    public void conditionTriggerCallback_noQuota_notPlayed() {
+        String boolStateKey = "KEY";
+        FixedQuotaManagerImpl quotaManager = new FixedQuotaManagerImpl(/* quotaCap= */ 0);
+        ProtoLayoutDynamicDataPipeline pipeline =
+                new ProtoLayoutDynamicDataPipeline(
+                        /* canUpdateGateways= */ true,
+                        /* sensorGateway= */ null,
+                        mStateStore,
+                        quotaManager);
+        TestAnimatedVectorDrawable drawableAvd = new TestAnimatedVectorDrawable();
+        DynamicBool dynamicBool = dynamicBool(boolStateKey);
+        Trigger trigger = conditionTrigger(dynamicBool);
+        makePipelineForConditionalBoolTrigger(pipeline, drawableAvd, dynamicBool, trigger);
+
+        // Should Trigger animation, but animation shouldn't be played.
+        setBoolStateVal(boolStateKey, true);
+        expect.that(drawableAvd.started).isFalse();
+        expect.that(pipeline.getRunningAnimationsCount()).isEqualTo(0);
+        expect.that(quotaManager.isAllQuotaReleased()).isTrue();
+    }
+
+    @NonNull
+    private static Trigger conditionTrigger(DynamicBool dynamicBool) {
+        return Trigger.newBuilder()
+                .setOnConditionMetTrigger(
+                        OnConditionMetTrigger.newBuilder().setTrigger(dynamicBool).build())
+                .build();
+    }
+
+    @NonNull
+    private static DynamicBool dynamicBool(String boolStateKey) {
+        return DynamicBool.newBuilder()
+                .setStateSource(StateBoolSource.newBuilder().setSourceKey(boolStateKey).build())
+                .build();
+    }
+
+    private void makePipelineForConditionalBoolTrigger(
+            ProtoLayoutDynamicDataPipeline pipeline,
+            TestAnimatedVectorDrawable drawableAvd,
+            DynamicBool dynamicBool,
+            Trigger trigger) {
+        pipeline.newPipelineMaker()
+                .addResolvedAnimatedImageWithBoolTrigger(
+                        drawableAvd, trigger, TEST_POS_ID, dynamicBool)
+                .commit(mRootContainer, /* isReattaching= */ false);
+    }
+
+    @Test
+    public void delayedAnimations_notConsumeQuota_beforePlaying_freeQuota() {
+        float start1 = 1.0f;
+        float start2 = 100.0f;
+        float end1 = 10.0f;
+        float end2 = 1000.0f;
+
+        DynamicFloat dynamicFloat1 =
+                animatableFixedFloat(start1, end1, /* duration= */ 100, /* delay= */ 0);
+        List<Float> results1 = new ArrayList<>();
+        AddToListCallback<Float> receiver1 =
+                new AddToListCallback<>(results1, /* invalidList= */ null);
+
+        DynamicFloat dynamicFloat2 =
+                animatableFixedFloat(start2, end2, /* duration= */ 200, /* delay= */ 600);
+        List<Float> results2 = new ArrayList<>();
+        AddToListCallback<Float> receiver2 =
+                new AddToListCallback<>(results2, /* invalidList= */ null);
+
+        // Quota allows 1 animation at the time, but the given floats are starting in different
+        // time, so both should be played.
+        FixedQuotaManagerImpl quotaManager = new FixedQuotaManagerImpl(1);
+
+        ProtoLayoutDynamicDataPipeline pipeline =
+                new ProtoLayoutDynamicDataPipeline(
+                        /* canUpdateGateways= */ true,
+                        /* sensorGateway= */ null,
+                        mStateStore,
+                        quotaManager);
+        shadowOf(getMainLooper()).idle();
+
+        pipeline.setFullyVisible(true);
+        PipelineMaker pipelineMaker = pipeline.newPipelineMaker();
+
+        pipelineMaker.addPipelineFor(dynamicFloat1, TEST_POS_ID, receiver1);
+        pipelineMaker.addPipelineFor(dynamicFloat2, TEST_POS_ID, receiver2);
+        pipelineMaker.commit(mRootContainer, /* isReattaching= */ false);
+        shadowOf(getMainLooper()).runOneTask();
+
+        // one running, delayed animation is not started yet.
+        expect.that(pipeline.getRunningAnimationsCount()).isEqualTo(1);
+        assertThat(results2).hasSize(0);
+
+        shadowOf(getMainLooper()).idleFor(100, TimeUnit.MILLISECONDS);
+        assertAnimation(results1, start1, end1);
+
+        ShadowLooper.runUiThreadTasksIncludingDelayedTasks();
+        shadowOf(getMainLooper()).idle();
+        assertAnimation(results2, start2, end2);
+
+        expect.that(quotaManager.isAllQuotaReleased()).isTrue();
+    }
+
+    @Test
+    public void delayedAnimations_notConsumeQuota_beforePlaying_notEnoughQuota() {
+        float start1 = 1.0f;
+        float start2 = 100.0f;
+        float end1 = 10.0f;
+        float end2 = 1000.0f;
+
+        DynamicFloat dynamicFloat1 =
+                animatableFixedFloat(start1, end1, /* duration= */ 600, /* delay= */ 0);
+        List<Float> results1 = new ArrayList<>();
+        AddToListCallback<Float> receiver1 =
+                new AddToListCallback<>(results1, /* invalidList= */ null);
+
+        DynamicFloat dynamicFloat2 =
+                animatableFixedFloat(start2, end2, /* duration= */ 200, /* delay= */ 300);
+        List<Float> results2 = new ArrayList<>();
+        AddToListCallback<Float> receiver2 =
+                new AddToListCallback<>(results2, /* invalidList= */ null);
+
+        // Quota allows 1 animation at the time, but the given floats are starting in different
+        // time, so both should be played.
+        FixedQuotaManagerImpl quotaManager = new FixedQuotaManagerImpl(1);
+
+        ProtoLayoutDynamicDataPipeline pipeline =
+                new ProtoLayoutDynamicDataPipeline(
+                        /* canUpdateGateways= */ true,
+                        /* sensorGateway= */ null,
+                        mStateStore,
+                        quotaManager);
+        shadowOf(getMainLooper()).idle();
+
+        pipeline.setFullyVisible(true);
+        PipelineMaker pipelineMaker = pipeline.newPipelineMaker();
+
+        pipelineMaker.addPipelineFor(dynamicFloat1, TEST_POS_ID, receiver1);
+        pipelineMaker.addPipelineFor(dynamicFloat2, TEST_POS_ID, receiver2);
+        pipelineMaker.commit(mRootContainer, /* isReattaching= */ false);
+        shadowOf(getMainLooper()).runOneTask();
+
+        // one running, delayed animation is not started yet.
+        expect.that(pipeline.getRunningAnimationsCount()).isEqualTo(1);
+        assertThat(results2).hasSize(0);
+
+        shadowOf(getMainLooper()).idle();
+        assertAnimation(results1, start1, end1);
+
+        ShadowLooper.runUiThreadTasksIncludingDelayedTasks();
+        shadowOf(getMainLooper()).idle();
+        assertThat(results2).hasSize(1);
+        expect.that(results2).containsExactly(end2);
+
+        expect.that(quotaManager.isAllQuotaReleased()).isTrue();
+    }
+
+    @Test
+    public void floatAnimation_noQuota_notPlayed_assignsEndValue() {
+        float endValue = 10.0f;
+        float startValue = 5.0f;
+        DynamicFloat dynamicFloat = animatableFixedFloat(startValue, endValue);
+        ArrayList<Float> results = new ArrayList<>();
+
+        ProtoLayoutDynamicDataPipeline pipeline =
+                initPipelineWithAllAnimations(
+                        Arrays.asList(dynamicFloat),
+                        /* boundProgress= */ null,
+                        /* seekableDrawable= */ null,
+                        /* drawableAvd= */ null,
+                        new FixedQuotaManagerImpl(/* quotaCap= */ 0),
+                        results);
+
+        expect.that(pipeline.getRunningAnimationsCount()).isEqualTo(0);
+        expect.that(results).hasSize(1);
+        expect.that(results).containsExactly(endValue);
+    }
+
+    @Test
+    public void whenOutOfQuota_animationsNotPlayed() {
+        List<DynamicFloat> dynamicFloats = new ArrayList<>();
+        int allowedAnimations = 3;
+        for (int i = 0; i < allowedAnimations + 2; i++) {
+            dynamicFloats.add(animatableFixedFloat(5.0f, 10.0f));
+        }
+        FixedQuotaManagerImpl quotaManager = new FixedQuotaManagerImpl(allowedAnimations);
+
+        ProtoLayoutDynamicDataPipeline pipeline =
+                initPipelineWithAllAnimations(
+                        dynamicFloats,
+                        /* boundProgress= */ null,
+                        /* seekableDrawable= */ null,
+                        /* drawableAvd= */ null,
+                        quotaManager);
+
+        expect.that(pipeline.getRunningAnimationsCount()).isEqualTo(allowedAnimations);
+        shadowOf(getMainLooper()).idle();
+        expect.that(pipeline.getRunningAnimationsCount()).isEqualTo(0);
+        expect.that(quotaManager.isAllQuotaReleased()).isTrue();
+    }
+
+    @Test
+    public void whenInvisible_quotaIsReleased() {
+        List<DynamicFloat> dynamicFloats = new ArrayList<>();
+        int allowedAnimations = 3;
+        for (int i = 0; i < allowedAnimations; i++) {
+            dynamicFloats.add(animatableFixedFloat(5.0f, 10.0f));
+        }
+
+        FixedQuotaManagerImpl quotaManager = new FixedQuotaManagerImpl(allowedAnimations);
+        ProtoLayoutDynamicDataPipeline pipeline =
+                initPipelineWithAllAnimations(
+                        dynamicFloats,
+                        /* boundProgress= */ null,
+                        /* seekableDrawable= */ null,
+                        /* drawableAvd= */ null,
+                        quotaManager);
+
+        expect.that(pipeline.getRunningAnimationsCount()).isEqualTo(allowedAnimations);
+
+        pipeline.setFullyVisible(false);
+        expect.that(pipeline.getRunningAnimationsCount()).isEqualTo(0);
+        expect.that(quotaManager.isAllQuotaReleased()).isTrue();
+    }
+
+    @Test
+    public void whenOutOfQuotaIsReleased_newAnimationsArePlayed() {
+        List<DynamicFloat> dynamicFloats = new ArrayList<>();
+        int allowedAnimations = 3;
+        for (int i = 0; i < allowedAnimations; i++) {
+            dynamicFloats.add(animatableFixedFloat(i, 10.0f));
+        }
+
+        FixedQuotaManagerImpl quotaManager = new FixedQuotaManagerImpl(allowedAnimations);
+
+        ProtoLayoutDynamicDataPipeline pipeline =
+                initPipelineWithAllAnimations(
+                        dynamicFloats,
+                        /* boundProgress= */ null,
+                        /* seekableDrawable= */ null,
+                        /* drawableAvd= */ null,
+                        quotaManager);
+
+        expect.that(pipeline.getRunningAnimationsCount()).isEqualTo(allowedAnimations);
+        shadowOf(getMainLooper()).idle();
+        expect.that(pipeline.getRunningAnimationsCount()).isEqualTo(0);
+        expect.that(quotaManager.isAllQuotaReleased()).isTrue();
+
+        AddToListCallback<Float> receiver =
+                new AddToListCallback<>(new ArrayList<>(), /* invalidList= */ null);
+        pipeline.setFullyVisible(true);
+        ProtoLayoutDynamicDataPipeline.PipelineMaker pipelineMaker = pipeline.newPipelineMaker();
+        pipelineMaker.addPipelineFor(animatableFixedFloat(5.0f, 10.f), TEST_POS_ID, receiver);
+        pipelineMaker.commit(mRootContainer, /* isReattaching= */ false);
+        shadowOf(getMainLooper()).runOneTask();
+
+        expect.that(pipeline.getRunningAnimationsCount()).isEqualTo(1);
+        shadowOf(getMainLooper()).idle();
+        expect.that(pipeline.getRunningAnimationsCount()).isEqualTo(0);
+        expect.that(quotaManager.isAllQuotaReleased()).isTrue();
+    }
+
+    @NonNull
+    private DynamicFloat animatableFixedFloat(float from, float to) {
+        return DynamicFloat.newBuilder()
+                .setAnimatableFixed(
+                        AnimatableFixedFloat.newBuilder().setFromValue(from).setToValue(to))
+                .build();
+    }
+
+    @NonNull
+    private DynamicFloat animatableFixedFloat(float from, float to, int duration, int delay) {
+        return DynamicFloat.newBuilder()
+                .setAnimatableFixed(
+                        AnimatableFixedFloat.newBuilder()
+                                .setFromValue(from)
+                                .setToValue(to)
+                                .setAnimationSpec(
+                                        AnimationSpec.newBuilder()
+                                                .setDurationMillis(duration)
+                                                .setStartDelayMillis(delay)
+                                                .build()))
+                .build();
+    }
+
+    @NonNull
+    private DynamicFloat animatableFixedFloat(
+            float from, float to, int duration, int delay, int repeatDelay) {
+        return DynamicFloat.newBuilder()
+                .setAnimatableFixed(
+                        AnimatableFixedFloat.newBuilder()
+                                .setFromValue(from)
+                                .setToValue(to)
+                                .setAnimationSpec(
+                                        AnimationSpec.newBuilder()
+                                                .setDurationMillis(duration)
+                                                .setStartDelayMillis(delay)
+                                                .setRepeatable(
+                                                        Repeatable.newBuilder()
+                                                                .setRepeatMode(
+                                                                        RepeatMode
+                                                                            .REPEAT_MODE_REVERSE)
+                                                                .setIterations(2)
+                                                                .setForwardRepeatDelayMillis(
+                                                                        repeatDelay)
+                                                                .setReverseRepeatDelayMillis(
+                                                                        repeatDelay)
+                                                                .build())
+                                                .build()))
+                .build();
+    }
+
+    @NonNull
+    private DynamicColor animatableFixedColor(int from, int to) {
+        return DynamicColor.newBuilder()
+                .setAnimatableFixed(
+                        AnimatableFixedColor.newBuilder().setFromArgb(from).setToArgb(to).build())
+                .build();
+    }
+
+    @NonNull
+    private ProtoLayoutDynamicDataPipeline initPipelineWithAllAnimations(
+            List<DynamicFloat> dynamicFloats,
+            @Nullable DynamicFloat boundProgress,
+            @Nullable SeekableAnimatedVectorDrawable seekableDrawable,
+            @Nullable AnimatedVectorDrawable drawableAvd,
+            QuotaManager quotaManager) {
+        return initPipelineWithAllAnimations(
+                dynamicFloats,
+                boundProgress,
+                seekableDrawable,
+                drawableAvd,
+                quotaManager,
+                /* results= */ new ArrayList<>());
+    }
+
+    @NonNull
+    private ProtoLayoutDynamicDataPipeline initPipelineWithAllAnimations(
+            @NonNull List<DynamicFloat> dynamicFloats,
+            @Nullable DynamicFloat boundProgress,
+            @Nullable SeekableAnimatedVectorDrawable seekableDrawable,
+            @Nullable AnimatedVectorDrawable drawableAvd,
+            @NonNull QuotaManager quotaManager,
+            @NonNull List<Float> results) {
+        AddToListCallback<Float> receiver =
+                new AddToListCallback<>(results, /* invalidList= */ null);
+        Trigger trigger =
+                Trigger.newBuilder().setOnLoadTrigger(OnLoadTrigger.getDefaultInstance()).build();
+        ProtoLayoutDynamicDataPipeline pipeline =
+                new ProtoLayoutDynamicDataPipeline(
+                        /* canUpdateGateways= */ true,
+                        /* sensorGateway= */ null,
+                        mStateStore,
+                        quotaManager);
+        shadowOf(getMainLooper()).idle();
+
+        pipeline.setFullyVisible(true);
+        PipelineMaker pipelineMaker = pipeline.newPipelineMaker();
+
+        for (DynamicFloat proto : dynamicFloats) {
+            pipelineMaker.addPipelineFor(proto, TEST_POS_ID, receiver);
+        }
+        if (seekableDrawable != null && boundProgress != null) {
+            pipelineMaker.addResolvedSeekableAnimatedImage(
+                    seekableDrawable, boundProgress, TEST_POS_ID);
+        }
+        if (drawableAvd != null) {
+            pipelineMaker.addResolvedAnimatedImage(drawableAvd, trigger, TEST_POS_ID);
+        }
+        pipelineMaker.commit(mRootContainer, /* isReattaching= */ false);
+        shadowOf(getMainLooper()).runOneTask();
+        return pipeline;
+    }
+
+    private DynamicInt32 fixedDynamicInt32(int value) {
+        return DynamicInt32.newBuilder().setFixed(FixedInt32.newBuilder().setValue(value)).build();
+    }
+
+    private ProtoLayoutDynamicDataPipeline initPipeline(
+            List<Float> results, boolean enableAnimations, DynamicFloat proto, int animationsNum) {
+        return initPipeline(
+                results, /* invalidResults= */ null, enableAnimations, proto, animationsNum);
+    }
+
+    private ProtoLayoutDynamicDataPipeline initPipeline(
+            List<Float> results,
+            @Nullable List<Boolean> invalidResults,
+            boolean enableAnimations,
+            DynamicFloat proto,
+            int animationsNum) {
+        AddToListCallback<Float> receiver = new AddToListCallback<>(results, invalidResults);
+        ProtoLayoutDynamicDataPipeline pipeline =
+                enableAnimations
+                        ? new ProtoLayoutDynamicDataPipeline(
+                                /* canUpdateGateways= */ true,
+                                /* sensorGateway= */ null,
+                        mStateStore,
+                                new FixedQuotaManagerImpl(MAX_VALUE))
+                        : new ProtoLayoutDynamicDataPipeline(
+                                /* canUpdateGateways= */ true,
+                                /* sensorGateway= */ null,
+                                mStateStore);
+        shadowOf(getMainLooper()).idle();
+
+        pipeline.setFullyVisible(true);
+        pipeline.newPipelineMaker()
+                .addPipelineFor(proto, TEST_POS_ID, receiver)
+                .commit(mRootContainer, /* isReattaching= */ false);
+        shadowOf(getMainLooper()).runOneTask();
+        if (enableAnimations) {
+            assertThat(pipeline.getRunningAnimationsCount()).isEqualTo(animationsNum);
+        }
+        shadowOf(getMainLooper()).idle();
+        assertThat(pipeline.getRunningAnimationsCount()).isEqualTo(0);
+
+        return pipeline;
+    }
+
+    /** Runs one task */
+    private ProtoLayoutDynamicDataPipeline initPipelineAnimationsDisabled(
+            List<Float> results, DynamicFloat proto) {
+        AddToListCallback<Float> receiver =
+                new AddToListCallback<>(results, /* invalidList= */ null);
+        ProtoLayoutDynamicDataPipeline pipeline =
+                new ProtoLayoutDynamicDataPipeline(
+                        /* canUpdateGateways= */ true, /* sensorGateway= */ null, mStateStore);
+        shadowOf(getMainLooper()).idle();
+
+        pipeline.setFullyVisible(true);
+        pipeline.newPipelineMaker()
+                .addPipelineFor(proto, TEST_POS_ID, receiver)
+                .commit(mRootContainer, /* isReattaching= */ false);
+        shadowOf(getMainLooper()).runOneTask();
+
+        return pipeline;
+    }
+
+    /** Runs one task */
+    private ProtoLayoutDynamicDataPipeline initPipelineAnimationsDisabled(
+            List<Integer> results, DynamicColor proto) {
+        AddToListCallback<Integer> receiver =
+                new AddToListCallback<>(results, /* invalidList= */ null);
+        ProtoLayoutDynamicDataPipeline pipeline =
+                new ProtoLayoutDynamicDataPipeline(
+                        /* canUpdateGateways= */ true, /* sensorGateway= */ null, mStateStore);
+        shadowOf(getMainLooper()).idle();
+
+        pipeline.setFullyVisible(true);
+        pipeline.newPipelineMaker()
+                .addPipelineFor(proto, TEST_POS_ID, receiver)
+                .commit(mRootContainer, /* isReattaching= */ false);
+        shadowOf(getMainLooper()).runOneTask();
+
+        return pipeline;
+    }
+
+    /** Runs one task */
+    private ProtoLayoutDynamicDataPipeline initPipelineAnimationsDisabled(
+            List<Float> results, DpProp proto) {
+        AddToListCallback<Float> receiver =
+                new AddToListCallback<>(results, /* invalidList= */ null);
+        ProtoLayoutDynamicDataPipeline pipeline =
+                new ProtoLayoutDynamicDataPipeline(
+                        /* canUpdateGateways= */ true, /* sensorGateway= */ null, mStateStore);
+        shadowOf(getMainLooper()).idle();
+
+        pipeline.setFullyVisible(true);
+        pipeline.newPipelineMaker()
+                .addPipelineFor(proto, TEST_POS_ID, receiver)
+                .commit(mRootContainer, /* isReattaching= */ false);
+        shadowOf(getMainLooper()).runOneTask();
+
+        return pipeline;
+    }
+
+    /** Runs one task */
+    private ProtoLayoutDynamicDataPipeline initPipelineAnimationsDisabled(
+            List<Float> results, DegreesProp proto) {
+        AddToListCallback<Float> receiver =
+                new AddToListCallback<>(results, /* invalidList= */ null);
+        ProtoLayoutDynamicDataPipeline pipeline =
+                new ProtoLayoutDynamicDataPipeline(
+                        /* canUpdateGateways= */ true, /* sensorGateway= */ null, mStateStore);
+        shadowOf(getMainLooper()).idle();
+
+        pipeline.setFullyVisible(true);
+        pipeline.newPipelineMaker()
+                .addPipelineFor(proto, TEST_POS_ID, receiver)
+                .commit(mRootContainer, /* isReattaching= */ false);
+        shadowOf(getMainLooper()).runOneTask();
+
+        return pipeline;
+    }
+
+    /** Runs one task */
+    private ProtoLayoutDynamicDataPipeline initPipelineAnimationsDisabled(
+            List<Integer> results, ColorProp proto) {
+        AddToListCallback<Integer> receiver =
+                new AddToListCallback<>(results, /* invalidList= */ null);
+        ProtoLayoutDynamicDataPipeline pipeline =
+                new ProtoLayoutDynamicDataPipeline(
+                        /* canUpdateGateways= */ true, /* sensorGateway= */ null, mStateStore);
+        shadowOf(getMainLooper()).idle();
+
+        pipeline.setFullyVisible(true);
+        pipeline.newPipelineMaker()
+                .addPipelineFor(proto, TEST_POS_ID, receiver)
+                .commit(mRootContainer, /* isReattaching= */ false);
+        shadowOf(getMainLooper()).runOneTask();
+
+        return pipeline;
+    }
+
+    private void setFloatStateVal(String key, float val) {
+        mStateStore.setStateEntryValuesProto(
+                ImmutableMap.of(
+                        key,
+                        StateEntryValue.newBuilder()
+                                .setFloatVal(FixedFloat.newBuilder().setValue(val))
+                                .build()));
+        shadowOf(getMainLooper()).idle();
+    }
+
+    private void setBoolStateVal(String key, boolean val) {
+        mStateStore.setStateEntryValuesProto(
+                ImmutableMap.of(
+                        key,
+                        StateEntryValue.newBuilder()
+                                .setBoolVal(FixedBool.newBuilder().setValue(val))
+                                .build()));
+        shadowOf(getMainLooper()).idle();
+    }
+
+    private void assertAnimation(List<Float> results, float startVal, float endVal) {
+        for (Float val : results) {
+            if (startVal < endVal) {
+                assertThat(val).isIn(Range.closed(startVal, endVal));
+            } else {
+                assertThat(val).isIn(Range.closed(endVal, startVal));
+            }
+        }
+
+        assertThat(results.size()).isGreaterThan(1);
+
+        if (startVal < endVal) {
+            assertThat(results).isInOrder();
+        } else {
+            assertThat(results).isInOrder(Comparator.reverseOrder());
+        }
+
+        assertThat(results).contains(endVal);
+    }
+
+    private static class TestAnimatedVectorDrawable extends AnimatedVectorDrawable {
+        public boolean started = false;
+        public boolean reset = false;
+
+        // We need to intercept callbacks and save it in this test class as shadow drawable doesn't
+        // seem to call onEnd listener, meaning that quota won't be freed and we would get failing
+        // test.
+        private final List<AnimationCallback> mAnimationCallbacks = new ArrayList<>();
+
+        @Override
+        public void start() {
+            super.start();
+            started = true;
+            reset = false;
+        }
+
+        @Override
+        public void registerAnimationCallback(@NonNull AnimationCallback callback) {
+            super.registerAnimationCallback(callback);
+            mAnimationCallbacks.add(callback);
+        }
+
+        @Override
+        public boolean unregisterAnimationCallback(@NonNull AnimationCallback callback) {
+            mAnimationCallbacks.remove(callback);
+            return super.unregisterAnimationCallback(callback);
+        }
+
+        @Override
+        public void stop() {
+            super.stop();
+            started = false;
+            mAnimationCallbacks.forEach(c -> c.onAnimationEnd(this));
+        }
+
+        @Override
+        public void reset() {
+            super.reset();
+            started = false;
+            reset = true;
+            mAnimationCallbacks.forEach(c -> c.onAnimationEnd(this));
+        }
+
+        @Override
+        public boolean isRunning() {
+            super.isRunning();
+            return started;
+        }
+    }
+}
diff --git a/wear/protolayout/protolayout-renderer/src/test/java/androidx/wear/protolayout/renderer/helper/TestDsl.java b/wear/protolayout/protolayout-renderer/src/test/java/androidx/wear/protolayout/renderer/helper/TestDsl.java
new file mode 100644
index 0000000..b158c38
--- /dev/null
+++ b/wear/protolayout/protolayout-renderer/src/test/java/androidx/wear/protolayout/renderer/helper/TestDsl.java
@@ -0,0 +1,574 @@
+/*
+ * 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.protolayout.renderer.helper;
+
+import static java.util.Arrays.stream;
+import static java.util.stream.Collectors.toList;
+
+import androidx.annotation.Nullable;
+import androidx.wear.protolayout.expression.proto.DynamicProto.DynamicString;
+import androidx.wear.protolayout.expression.proto.FixedProto.FixedString;
+import androidx.wear.protolayout.proto.AlignmentProto.HorizontalAlignment;
+import androidx.wear.protolayout.proto.AlignmentProto.HorizontalAlignmentProp;
+import androidx.wear.protolayout.proto.AlignmentProto.VerticalAlignment;
+import androidx.wear.protolayout.proto.AlignmentProto.VerticalAlignmentProp;
+import androidx.wear.protolayout.proto.ColorProto.ColorProp;
+import androidx.wear.protolayout.proto.DimensionProto.ContainerDimension;
+import androidx.wear.protolayout.proto.DimensionProto.DegreesProp;
+import androidx.wear.protolayout.proto.DimensionProto.DpProp;
+import androidx.wear.protolayout.proto.DimensionProto.ImageDimension;
+import androidx.wear.protolayout.proto.DimensionProto.SpProp;
+import androidx.wear.protolayout.proto.DimensionProto.SpacerDimension;
+import androidx.wear.protolayout.proto.FingerprintProto.NodeFingerprint;
+import androidx.wear.protolayout.proto.FingerprintProto.TreeFingerprint;
+import androidx.wear.protolayout.proto.LayoutElementProto;
+import androidx.wear.protolayout.proto.LayoutElementProto.Arc;
+import androidx.wear.protolayout.proto.LayoutElementProto.ArcLayoutElement;
+import androidx.wear.protolayout.proto.LayoutElementProto.ArcText;
+import androidx.wear.protolayout.proto.LayoutElementProto.Box;
+import androidx.wear.protolayout.proto.LayoutElementProto.Column;
+import androidx.wear.protolayout.proto.LayoutElementProto.Image;
+import androidx.wear.protolayout.proto.LayoutElementProto.Layout;
+import androidx.wear.protolayout.proto.LayoutElementProto.LayoutElement;
+import androidx.wear.protolayout.proto.LayoutElementProto.Row;
+import androidx.wear.protolayout.proto.LayoutElementProto.Spacer;
+import androidx.wear.protolayout.proto.LayoutElementProto.Span;
+import androidx.wear.protolayout.proto.LayoutElementProto.SpanText;
+import androidx.wear.protolayout.proto.LayoutElementProto.Spannable;
+import androidx.wear.protolayout.proto.LayoutElementProto.Text;
+import androidx.wear.protolayout.proto.ModifiersProto;
+import androidx.wear.protolayout.proto.TypesProto.BoolProp;
+import androidx.wear.protolayout.proto.TypesProto.Int32Prop;
+import androidx.wear.protolayout.proto.TypesProto.StringProp;
+
+import java.util.List;
+import java.util.Objects;
+import java.util.function.Consumer;
+
+/**
+ * A simple DSL for more easily producing layout protos in tests.
+ *
+ * <p>Example usage:
+ *
+ * <pre>{@code
+ * layout(
+ *   column(
+ *     props -> {
+ *       props.heightDp = 20;
+ *       props.modifiers.border.widthDp = 2;
+ *     },
+ *     row(
+ *       text("Foo"),
+ *       text("Bar")
+ *     )
+ *   )
+ * )
+ * }</pre>
+ */
+public class TestDsl {
+
+    private TestDsl() {}
+
+    /** An intermediate opaque layout node produced by the builders in this class. */
+    public static final class LayoutNode {
+        private LayoutElement.Builder mLayoutElement;
+        private ArcLayoutElement.Builder mArcLayoutElement;
+        private Span.Builder mSpanElement;
+        private NodeFingerprint mFingerprint;
+    }
+
+    /** Corresponds to {@link ModifiersProto.Border} */
+    public static final class Border {
+        public int widthDp;
+        public int colorArgb;
+
+        private ModifiersProto.Border toProto() {
+            return ModifiersProto.Border.newBuilder()
+                    .setWidth(DpProp.newBuilder().setValue(widthDp))
+                    .setColor(ColorProp.newBuilder().setArgb(colorArgb))
+                    .build();
+        }
+    }
+
+    /** Corresponds to {@link ModifiersProto.Modifiers} */
+    public static final class Modifiers {
+        public Border border = new Border();
+
+        private ModifiersProto.Modifiers toProto() {
+            ModifiersProto.Modifiers.Builder proto = ModifiersProto.Modifiers.newBuilder();
+            proto.setBorder(border.toProto());
+            return proto.build();
+        }
+    }
+
+    /** Corresponds to {@link LayoutElementProto.FontStyle} */
+    public static final class FontStyle {
+        public float sizeSp;
+        public boolean italic;
+        public int colorArgb;
+
+        private LayoutElementProto.FontStyle toProto() {
+            return LayoutElementProto.FontStyle.newBuilder()
+                    .setSize(sp(sizeSp))
+                    .setItalic(bool(italic))
+                    .setColor(color(colorArgb))
+                    .build();
+        }
+    }
+
+    /** Properties of a Box, with each field directly accessible for ease of use. */
+    public static final class BoxProps {
+        public Modifiers modifiers = new Modifiers();
+        public int widthDp;
+        public int heightDp;
+        public HorizontalAlignment horizontalAlignment;
+        public VerticalAlignment verticalAlignment;
+
+        private void applyTo(Box.Builder box) {
+            box.setModifiers(modifiers.toProto());
+            box.setWidth(dpContainerDim(widthDp));
+            box.setHeight(dpContainerDim(heightDp));
+            box.setHorizontalAlignment(
+                    HorizontalAlignmentProp.newBuilder().setValue(horizontalAlignment).build());
+            box.setVerticalAlignment(
+                    VerticalAlignmentProp.newBuilder().setValue(verticalAlignment).build());
+        }
+
+        private int fingerprint() {
+            return Objects.hash(
+                    modifiers.toProto(), widthDp, heightDp, horizontalAlignment, verticalAlignment);
+        }
+    }
+
+    /** Properties of a Row, with each field directly accessible for ease of use. */
+    public static final class RowProps {
+        public Modifiers modifiers = new Modifiers();
+        public int widthDp;
+        public int heightDp;
+
+        private void applyTo(Row.Builder row) {
+            row.setModifiers(modifiers.toProto());
+            row.setWidth(dpContainerDim(widthDp));
+            row.setHeight(dpContainerDim(heightDp));
+        }
+
+        private int fingerprint() {
+            return Objects.hash(modifiers.toProto(), widthDp, heightDp);
+        }
+    }
+
+    /** Properties of a Column, with each field directly accessible for ease of use. */
+    public static final class ColumnProps {
+        public Modifiers modifiers = new Modifiers();
+        public int widthDp;
+        public int heightDp;
+
+        private void applyTo(Column.Builder column) {
+            column.setModifiers(modifiers.toProto());
+            column.setWidth(dpContainerDim(widthDp));
+            column.setHeight(dpContainerDim(heightDp));
+        }
+
+        private int fingerprint() {
+            return Objects.hash(modifiers.toProto(), widthDp, heightDp);
+        }
+    }
+
+    /** Properties of a Text object, with each field directly accessible for ease of use. */
+    public static final class TextProps {
+        public Modifiers modifiers = new Modifiers();
+        public int maxLines = 1;
+        public float lineHeightSp = 1;
+        public FontStyle fontStyle = new FontStyle();
+
+        private void applyTo(Text.Builder text) {
+            text.setModifiers(modifiers.toProto());
+            text.setMaxLines(int32(maxLines));
+            text.setLineHeight(sp(lineHeightSp));
+            text.setFontStyle(fontStyle.toProto());
+        }
+
+        private int fingerprint() {
+            return Objects.hash(modifiers.toProto(), maxLines, fontStyle.toProto());
+        }
+    }
+
+    /** Properties of a Image object, with each field directly accessible for ease of use. */
+    public static final class ImageProps {
+        public Modifiers modifiers = new Modifiers();
+        public int widthDp;
+        public int heightDp;
+
+        private void applyTo(Image.Builder image) {
+            image.setModifiers(modifiers.toProto());
+            image.setWidth(dpImageDim(widthDp));
+            image.setHeight(dpImageDim(heightDp));
+        }
+
+        private int fingerprint() {
+            return Objects.hash(modifiers.toProto(), widthDp, heightDp);
+        }
+    }
+
+    /** Properties of a Spacer object, with each field directly accessible for ease of use. */
+    public static final class SpacerProps {
+        public Modifiers modifiers = new Modifiers();
+        public int widthDp;
+        public int heightDp;
+
+        private void applyTo(Spacer.Builder spacer) {
+            spacer.setModifiers(modifiers.toProto());
+            spacer.setWidth(dpSpacerDim(widthDp));
+            spacer.setHeight(dpSpacerDim(heightDp));
+        }
+
+        private int fingerprint() {
+            return Objects.hash(modifiers.toProto(), widthDp, heightDp);
+        }
+    }
+
+    /** Properties of an Arc, with each field directly accessible for ease of use. */
+    public static final class ArcProps {
+        public float anchorAngleDegrees;
+
+        private void applyTo(Arc.Builder arc) {
+            arc.setAnchorAngle(degrees(anchorAngleDegrees));
+        }
+
+        private int fingerprint() {
+            return Float.hashCode(anchorAngleDegrees);
+        }
+    }
+
+    /** Properties of an Spannable, with each field directly accessible for ease of use. */
+    public static final class SpannableProps {
+        public int maxLines;
+
+        private void applyTo(Spannable.Builder spannable) {
+            spannable.setMaxLines(int32(maxLines));
+        }
+
+        private int fingerprint() {
+            return Float.hashCode(maxLines);
+        }
+    }
+
+    public static Layout layout(LayoutNode root) {
+        return Layout.newBuilder()
+                .setRoot(root.mLayoutElement)
+                .setFingerprint(TreeFingerprint.newBuilder().setRoot(root.mFingerprint))
+                .build();
+    }
+
+    public static LayoutNode box(Consumer<BoxProps> propsConsumer, LayoutNode... nodes) {
+        return boxInternal(propsConsumer, nodes);
+    }
+
+    public static LayoutNode box(LayoutNode... nodes) {
+        return boxInternal(/* propsConsumer= */ null, nodes);
+    }
+
+    private static LayoutNode boxInternal(
+            @Nullable Consumer<BoxProps> propsConsumer, LayoutNode... nodes) {
+        LayoutNode element = new LayoutNode();
+        Box.Builder builder = Box.newBuilder().addAllContents(linearContents(nodes));
+        int selfPropsFingerprint = 0;
+        if (propsConsumer != null) {
+            BoxProps props = new BoxProps();
+            propsConsumer.accept(props);
+            props.applyTo(builder);
+            selfPropsFingerprint = combine(selfPropsFingerprint, props.fingerprint());
+        }
+        element.mLayoutElement = LayoutElement.newBuilder().setBox(builder.build());
+        element.mFingerprint = fingerprint("box", selfPropsFingerprint, nodes);
+        return element;
+    }
+
+    public static LayoutNode row(Consumer<RowProps> propsConsumer, LayoutNode... nodes) {
+        return rowInternal(propsConsumer, nodes);
+    }
+
+    public static LayoutNode row(LayoutNode... nodes) {
+        return rowInternal(/* propsConsumer= */ null, nodes);
+    }
+
+    private static LayoutNode rowInternal(
+            @Nullable Consumer<RowProps> propsConsumer, LayoutNode... nodes) {
+        LayoutNode element = new LayoutNode();
+        Row.Builder builder = Row.newBuilder().addAllContents(linearContents(nodes));
+        int selfPropsFingerprint = 0;
+        if (propsConsumer != null) {
+            RowProps props = new RowProps();
+            propsConsumer.accept(props);
+            props.applyTo(builder);
+            selfPropsFingerprint = combine(selfPropsFingerprint, props.fingerprint());
+        }
+        element.mLayoutElement = LayoutElement.newBuilder().setRow(builder.build());
+        element.mFingerprint = fingerprint("row", selfPropsFingerprint, nodes);
+        return element;
+    }
+
+    public static LayoutNode column(Consumer<ColumnProps> propsConsumer, LayoutNode... nodes) {
+        return columnInternal(propsConsumer, nodes);
+    }
+
+    public static LayoutNode column(LayoutNode... nodes) {
+        return columnInternal(/* propsConsumer= */ null, nodes);
+    }
+
+    private static LayoutNode columnInternal(
+            @Nullable Consumer<ColumnProps> propsConsumer, LayoutNode... nodes) {
+        LayoutNode element = new LayoutNode();
+        Column.Builder builder = Column.newBuilder().addAllContents(linearContents(nodes));
+        int selfPropsFingerprint = 0;
+        if (propsConsumer != null) {
+            ColumnProps props = new ColumnProps();
+            propsConsumer.accept(props);
+            props.applyTo(builder);
+            selfPropsFingerprint = combine(selfPropsFingerprint, props.fingerprint());
+        }
+        element.mLayoutElement = LayoutElement.newBuilder().setColumn(builder.build());
+        element.mFingerprint = fingerprint("column", selfPropsFingerprint, nodes);
+        return element;
+    }
+
+    public static LayoutNode dynamicFixedText(String fixedText) {
+        return dynamicFixedText(/* propsConsumer= */ null, fixedText);
+    }
+
+    public static LayoutNode dynamicFixedText(Consumer<TextProps> propsConsumer, String fixedText) {
+        return textInternal(propsConsumer, dynamicStr(fixedText));
+    }
+
+    public static LayoutNode text(String text) {
+        return text(/* propsConsumer= */ null, text);
+    }
+
+    public static LayoutNode text(Consumer<TextProps> propsConsumer, String text) {
+        return textInternal(propsConsumer, str(text));
+    }
+
+    private static LayoutNode textInternal(
+            @Nullable Consumer<TextProps> propsConsumer, StringProp text) {
+        LayoutNode element = new LayoutNode();
+        Text.Builder builder = Text.newBuilder().setText(text);
+        int selfPropsFingerprint = text.hashCode();
+        if (propsConsumer != null) {
+            TextProps props = new TextProps();
+            propsConsumer.accept(props);
+            props.applyTo(builder);
+            selfPropsFingerprint = combine(selfPropsFingerprint, props.fingerprint());
+        }
+        element.mLayoutElement = LayoutElement.newBuilder().setText(builder.build());
+        element.mFingerprint = fingerprint("text", selfPropsFingerprint);
+        return element;
+    }
+
+    public static LayoutNode image(Consumer<ImageProps> propsConsumer, String resourceId) {
+        return imageInternal(propsConsumer, resourceId);
+    }
+
+    public static LayoutNode image(String resourceId) {
+        return imageInternal(/* propsConsumer= */ null, resourceId);
+    }
+
+    private static LayoutNode imageInternal(
+            @Nullable Consumer<ImageProps> propsConsumer, String resourceId) {
+        LayoutNode element = new LayoutNode();
+        Image.Builder builder = Image.newBuilder().setResourceId(str(resourceId));
+        int selfPropsFingerprint = resourceId.hashCode();
+        if (propsConsumer != null) {
+            ImageProps props = new ImageProps();
+            propsConsumer.accept(props);
+            props.applyTo(builder);
+            selfPropsFingerprint = combine(selfPropsFingerprint, props.fingerprint());
+        }
+        element.mLayoutElement = LayoutElement.newBuilder().setImage(builder.build());
+        element.mFingerprint = fingerprint("image", selfPropsFingerprint);
+        return element;
+    }
+
+    public static LayoutNode spacer(Consumer<SpacerProps> propsConsumer) {
+        return spacerInternal(propsConsumer);
+    }
+
+    public static LayoutNode spacer() {
+        return spacerInternal(/* propsConsumer= */ null);
+    }
+
+    private static LayoutNode spacerInternal(@Nullable Consumer<SpacerProps> propsConsumer) {
+        LayoutNode element = new LayoutNode();
+        Spacer.Builder builder = Spacer.newBuilder();
+        int selfPropsFingerprint = 0;
+        if (propsConsumer != null) {
+            SpacerProps props = new SpacerProps();
+            propsConsumer.accept(props);
+            props.applyTo(builder);
+            selfPropsFingerprint = props.fingerprint();
+        }
+        element.mLayoutElement = LayoutElement.newBuilder().setSpacer(builder.build());
+        element.mFingerprint = fingerprint("spacer", selfPropsFingerprint);
+        return element;
+    }
+
+    public static LayoutNode arc(Consumer<ArcProps> propsConsumer, LayoutNode... nodes) {
+        return arcInternal(propsConsumer, nodes);
+    }
+
+    public static LayoutNode arc(LayoutNode... nodes) {
+        return arcInternal(/* propsConsumer= */ null, nodes);
+    }
+
+    private static LayoutNode arcInternal(
+            @Nullable Consumer<ArcProps> propsConsumer, LayoutNode... nodes) {
+        LayoutNode element = new LayoutNode();
+        Arc.Builder builder = Arc.newBuilder().addAllContents(radialContents(nodes));
+        int selfPropsFingerprint = 0;
+        if (propsConsumer != null) {
+            ArcProps props = new ArcProps();
+            propsConsumer.accept(props);
+            props.applyTo(builder);
+            selfPropsFingerprint = combine(selfPropsFingerprint, props.fingerprint());
+        }
+        element.mLayoutElement = LayoutElement.newBuilder().setArc(builder.build());
+        element.mFingerprint = fingerprint("arc", selfPropsFingerprint, nodes);
+        return element;
+    }
+
+    public static LayoutNode arcText(String text) {
+        LayoutNode element = new LayoutNode();
+        element.mArcLayoutElement =
+                ArcLayoutElement.newBuilder().setText(ArcText.newBuilder().setText(str(text)));
+        element.mFingerprint = fingerprint("arcText", text.hashCode());
+        return element;
+    }
+
+    public static LayoutNode spannable(
+            Consumer<SpannableProps> propsConsumer, LayoutNode... nodes) {
+        return spannableInternal(propsConsumer, nodes);
+    }
+
+    public static LayoutNode spannable(LayoutNode... nodes) {
+        return spannableInternal(/* propsConsumer= */ null, nodes);
+    }
+
+    private static LayoutNode spannableInternal(
+            @Nullable Consumer<SpannableProps> propsConsumer, LayoutNode... nodes) {
+        LayoutNode element = new LayoutNode();
+        Spannable.Builder builder = Spannable.newBuilder().addAllSpans(spanContents(nodes));
+        int selfPropsFingerprint = 0;
+        if (propsConsumer != null) {
+            SpannableProps props = new SpannableProps();
+            propsConsumer.accept(props);
+            props.applyTo(builder);
+            selfPropsFingerprint = combine(selfPropsFingerprint, props.fingerprint());
+        }
+        // A Spannable is *not* considered a container for diffing purposes (i.e. any updated
+        // children will cause the entire Spannable to be updated). So we include the fingerprint of
+        // the nodes in the Spannable's self fingerprint. This mirrors behaviour from
+        // Spannable::Builder::addSpan at http://shortn/_cUyrG0M1N2
+        selfPropsFingerprint = combine(selfPropsFingerprint, fingerprints(nodes).hashCode());
+        element.mLayoutElement = LayoutElement.newBuilder().setSpannable(builder.build());
+        element.mFingerprint = fingerprint("spannable", selfPropsFingerprint);
+        return element;
+    }
+
+    public static LayoutNode spanText(String text) {
+        LayoutNode element = new LayoutNode();
+        element.mSpanElement = Span.newBuilder().setText(SpanText.newBuilder().setText(str(text)));
+        element.mFingerprint = fingerprint("spanText", text.hashCode());
+        return element;
+    }
+
+    private static NodeFingerprint fingerprint(
+            String selfTypeName, int selfPropsValue, LayoutNode... nodes) {
+        return NodeFingerprint.newBuilder()
+                .setSelfTypeValue(selfTypeName.hashCode())
+                .setSelfPropsValue(selfPropsValue)
+                .setChildNodesValue(fingerprints(nodes).hashCode())
+                .addAllChildNodes(fingerprints(nodes))
+                .build();
+    }
+
+    private static int combine(int fingerprint1, int fingerprint2) {
+        return 31 * fingerprint1 + fingerprint2;
+    }
+
+    private static List<LayoutElement> linearContents(LayoutNode[] nodes) {
+        return stream(nodes).map(n -> n.mLayoutElement.build()).collect(toList());
+    }
+
+    private static List<NodeFingerprint> fingerprints(LayoutNode[] nodes) {
+        return stream(nodes).map(n -> n.mFingerprint).collect(toList());
+    }
+
+    private static List<ArcLayoutElement> radialContents(LayoutNode[] nodes) {
+        return stream(nodes).map(n -> n.mArcLayoutElement.build()).collect(toList());
+    }
+
+    private static List<Span> spanContents(LayoutNode[] nodes) {
+        return stream(nodes).map(n -> n.mSpanElement.build()).collect(toList());
+    }
+
+    private static ContainerDimension dpContainerDim(float dp) {
+        return ContainerDimension.newBuilder().setLinearDimension(dp(dp)).build();
+    }
+
+    private static ImageDimension dpImageDim(float dp) {
+        return ImageDimension.newBuilder().setLinearDimension(dp(dp)).build();
+    }
+
+    private static SpacerDimension dpSpacerDim(float dp) {
+        return SpacerDimension.newBuilder().setLinearDimension(dp(dp)).build();
+    }
+
+    private static ColorProp color(int value) {
+        return ColorProp.newBuilder().setArgb(value).build();
+    }
+
+    private static BoolProp bool(boolean value) {
+        return BoolProp.newBuilder().setValue(value).build();
+    }
+
+    private static DpProp dp(float value) {
+        return DpProp.newBuilder().setValue(value).build();
+    }
+
+    private static SpProp sp(float value) {
+        return SpProp.newBuilder().setValue(value).build();
+    }
+
+    private static DegreesProp degrees(float degrees) {
+        return DegreesProp.newBuilder().setValue(degrees).build();
+    }
+
+    private static Int32Prop int32(int value) {
+        return Int32Prop.newBuilder().setValue(value).build();
+    }
+
+    private static StringProp str(String value) {
+        return StringProp.newBuilder().setValue(value).build();
+    }
+
+    private static StringProp dynamicStr(String fixedValue) {
+        return StringProp.newBuilder()
+                .setDynamicValue(
+                        DynamicString.newBuilder()
+                                .setFixed(FixedString.newBuilder().setValue(fixedValue)))
+                .build();
+    }
+}
diff --git a/wear/protolayout/protolayout-renderer/src/test/java/androidx/wear/protolayout/renderer/inflater/ResourceResolversTest.java b/wear/protolayout/protolayout-renderer/src/test/java/androidx/wear/protolayout/renderer/inflater/ResourceResolversTest.java
new file mode 100644
index 0000000..1a27090
--- /dev/null
+++ b/wear/protolayout/protolayout-renderer/src/test/java/androidx/wear/protolayout/renderer/inflater/ResourceResolversTest.java
@@ -0,0 +1,374 @@
+/*
+ * 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.protolayout.renderer.inflater;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.junit.Assert.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.graphics.drawable.Drawable;
+import android.graphics.drawable.VectorDrawable;
+
+import androidx.annotation.Nullable;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.wear.protolayout.expression.proto.DynamicProto.DynamicFloat;
+import androidx.wear.protolayout.expression.proto.FixedProto.FixedFloat;
+import androidx.wear.protolayout.proto.ResourceProto.AndroidAnimatedImageResourceByResId;
+import androidx.wear.protolayout.proto.ResourceProto.AndroidImageResourceByContentUri;
+import androidx.wear.protolayout.proto.ResourceProto.AndroidImageResourceByResId;
+import androidx.wear.protolayout.proto.ResourceProto.AndroidSeekableAnimatedImageResourceByResId;
+import androidx.wear.protolayout.proto.ResourceProto.AnimatedImageFormat;
+import androidx.wear.protolayout.proto.ResourceProto.ImageResource;
+import androidx.wear.protolayout.proto.ResourceProto.InlineImageResource;
+import androidx.wear.protolayout.proto.ResourceProto.Resources;
+import androidx.wear.protolayout.proto.TriggerProto.OnVisibleTrigger;
+import androidx.wear.protolayout.proto.TriggerProto.Trigger;
+import androidx.wear.protolayout.renderer.inflater.ResourceResolvers.AndroidAnimatedImageResourceByResIdResolver;
+import androidx.wear.protolayout.renderer.inflater.ResourceResolvers.AndroidImageResourceByResIdResolver;
+import androidx.wear.protolayout.renderer.inflater.ResourceResolvers.AndroidSeekableAnimatedImageResourceByResIdResolver;
+import androidx.wear.protolayout.renderer.inflater.ResourceResolvers.InlineImageResourceResolver;
+import androidx.wear.protolayout.renderer.inflater.ResourceResolvers.ResourceAccessException;
+import androidx.wear.protolayout.renderer.test.R;
+
+import com.google.common.util.concurrent.ListenableFuture;
+
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+
+import java.util.concurrent.ExecutionException;
+
+@RunWith(AndroidJUnit4.class)
+public class ResourceResolversTest {
+    @Rule public final MockitoRule mockito = MockitoJUnit.rule();
+
+    @Mock private InlineImageResourceResolver mInlineImageResolver;
+    @Mock private AndroidImageResourceByResIdResolver mImageByResIdResolver;
+    @Mock private AndroidAnimatedImageResourceByResIdResolver mAnimatedImageByResIdResolver;
+
+    @Mock
+    private AndroidSeekableAnimatedImageResourceByResIdResolver
+            mSeekableAnimatedImageByResIdResolver;
+
+    private final Drawable mTestDrawable = new VectorDrawable();
+    private final ListenableFuture<Drawable> mFutureDrawable =
+            ResourceResolvers.createImmediateFuture(mTestDrawable);
+
+    private static final InlineImageResource INLINE_IMAGE =
+            InlineImageResource.newBuilder().setHeightPx(123).setWidthPx(456).build();
+    private static final AndroidImageResourceByResId ANDROID_IMAGE_BY_RES_ID =
+            AndroidImageResourceByResId.newBuilder().setResourceId(R.drawable.android_24dp).build();
+    private static final AndroidAnimatedImageResourceByResId ANDROID_AVD_BY_RES_ID =
+            AndroidAnimatedImageResourceByResId.newBuilder()
+                    .setAnimatedImageFormat(AnimatedImageFormat.ANIMATED_IMAGE_FORMAT_AVD)
+                    .setResourceId(R.drawable.android_animated_24dp)
+                    .setStartTrigger(
+                            Trigger.newBuilder()
+                                    .setOnVisibleTrigger(OnVisibleTrigger.getDefaultInstance()))
+                    .build();
+    private static final AndroidSeekableAnimatedImageResourceByResId
+            ANDROID_SEEKABLE_AVD_BY_RES_ID =
+                    AndroidSeekableAnimatedImageResourceByResId.newBuilder()
+                            .setAnimatedImageFormat(AnimatedImageFormat.ANIMATED_IMAGE_FORMAT_AVD)
+                            .setResourceId(R.drawable.android_animated_24dp)
+                            .setProgress(
+                                    DynamicFloat.newBuilder()
+                                            .setFixed(
+                                                    FixedFloat.newBuilder().setValue(0.5f).build())
+                                            .build())
+                            .build();
+    private static final AndroidImageResourceByContentUri CONTENT_URI_IMAGE =
+            AndroidImageResourceByContentUri.newBuilder()
+                    .setContentUri("content://foo/bar")
+                    .build();
+
+    private static final String INLINE_IMAGE_RESOURCE_ID = "inline";
+    private static final String ANDROID_IMAGE_BY_RES_ID_RESOURCE_ID = "androidImageById";
+    private static final String ANDROID_AVD_BY_RES_ID_RESOURCE_ID = "androidAVDById";
+    private static final String ANDROID_SEEKABLE_AVD_BY_RES_ID_RESOURCE_ID =
+            "androidSeekableAVDById";
+
+    @Test
+    public void inlineImageRequestRoutedToCorrectAccessor() throws Exception {
+        ResourceResolvers resolvers =
+                ResourceResolvers.builder(buildResources())
+                        .setInlineImageResourceResolver(mInlineImageResolver)
+                        .setAndroidImageResourceByResIdResolver(mImageByResIdResolver)
+                        .setAndroidAnimatedImageResourceByResIdResolver(
+                                mAnimatedImageByResIdResolver)
+                        .setAndroidSeekableAnimatedImageResourceByResIdResolver(
+                                mSeekableAnimatedImageByResIdResolver)
+                        .build();
+
+        when(mInlineImageResolver.getDrawableOrThrow(any())).thenReturn(mTestDrawable);
+
+        // Check future's value to keep compiler happy.
+        assertThat(resolvers.getDrawable(INLINE_IMAGE_RESOURCE_ID).get())
+                .isSameInstanceAs(mTestDrawable);
+
+        verify(mInlineImageResolver).getDrawableOrThrow(INLINE_IMAGE);
+        verify(mImageByResIdResolver, never()).getDrawableOrThrow(any());
+        verify(mAnimatedImageByResIdResolver, never()).getDrawableOrThrow(any());
+        verify(mSeekableAnimatedImageByResIdResolver, never()).getDrawableOrThrow(any());
+    }
+
+    @Test
+    public void imageByResIdRequestRoutedToCorrectAccessor() throws Exception {
+        ResourceResolvers resolvers =
+                ResourceResolvers.builder(buildResources())
+                        .setInlineImageResourceResolver(mInlineImageResolver)
+                        .setAndroidImageResourceByResIdResolver(mImageByResIdResolver)
+                        .setAndroidAnimatedImageResourceByResIdResolver(
+                                mAnimatedImageByResIdResolver)
+                        .setAndroidSeekableAnimatedImageResourceByResIdResolver(
+                                mSeekableAnimatedImageByResIdResolver)
+                        .build();
+
+        when(mImageByResIdResolver.getDrawableOrThrow(any())).thenReturn(mTestDrawable);
+
+        // Check future's value to keep compiler happy.
+        assertThat(resolvers.getDrawable(ANDROID_IMAGE_BY_RES_ID_RESOURCE_ID).get())
+                .isSameInstanceAs(mTestDrawable);
+
+        verify(mInlineImageResolver, never()).getDrawableOrThrow(any());
+        verify(mImageByResIdResolver).getDrawableOrThrow(ANDROID_IMAGE_BY_RES_ID);
+        verify(mAnimatedImageByResIdResolver, never()).getDrawableOrThrow(any());
+        verify(mSeekableAnimatedImageByResIdResolver, never()).getDrawableOrThrow(any());
+    }
+
+    @Test
+    public void animatedImageByResIdRequestRoutedToCorrectAccessor() throws Exception {
+        ResourceResolvers resolvers =
+                ResourceResolvers.builder(buildResources())
+                        .setInlineImageResourceResolver(mInlineImageResolver)
+                        .setAndroidImageResourceByResIdResolver(mImageByResIdResolver)
+                        .setAndroidAnimatedImageResourceByResIdResolver(
+                                mAnimatedImageByResIdResolver)
+                        .setAndroidSeekableAnimatedImageResourceByResIdResolver(
+                                mSeekableAnimatedImageByResIdResolver)
+                        .build();
+
+        when(mAnimatedImageByResIdResolver.getDrawableOrThrow(any())).thenReturn(mTestDrawable);
+
+        // Check future's value to keep compiler happy.
+        assertThat(resolvers.getDrawable(ANDROID_AVD_BY_RES_ID_RESOURCE_ID).get())
+                .isSameInstanceAs(mTestDrawable);
+
+        verify(mInlineImageResolver, never()).getDrawableOrThrow(any());
+        verify(mImageByResIdResolver, never()).getDrawableOrThrow(any());
+        verify(mAnimatedImageByResIdResolver).getDrawableOrThrow(ANDROID_AVD_BY_RES_ID);
+        verify(mSeekableAnimatedImageByResIdResolver, never()).getDrawableOrThrow(any());
+    }
+
+    @Test
+    public void seekableAnimatedImageByResIdRequestRoutedToCorrectAccessor() throws Exception {
+        ResourceResolvers resolvers =
+                ResourceResolvers.builder(buildResources())
+                        .setInlineImageResourceResolver(mInlineImageResolver)
+                        .setAndroidImageResourceByResIdResolver(mImageByResIdResolver)
+                        .setAndroidAnimatedImageResourceByResIdResolver(
+                                mAnimatedImageByResIdResolver)
+                        .setAndroidSeekableAnimatedImageResourceByResIdResolver(
+                                mSeekableAnimatedImageByResIdResolver)
+                        .build();
+
+        when(mSeekableAnimatedImageByResIdResolver.getDrawableOrThrow(any()))
+                .thenReturn(mTestDrawable);
+
+        // Check future's value to keep compiler happy.
+        assertThat(resolvers.getDrawable(ANDROID_SEEKABLE_AVD_BY_RES_ID_RESOURCE_ID).get())
+                .isSameInstanceAs(mTestDrawable);
+
+        verify(mInlineImageResolver, never()).getDrawableOrThrow(any());
+        verify(mImageByResIdResolver, never()).getDrawableOrThrow(any());
+        verify(mAnimatedImageByResIdResolver, never()).getDrawableOrThrow(any());
+        verify(mSeekableAnimatedImageByResIdResolver)
+                .getDrawableOrThrow(ANDROID_SEEKABLE_AVD_BY_RES_ID);
+    }
+
+    @Test
+    public void throwsExceptionWithNonExistentResourceId() throws Exception {
+        ResourceResolvers resolvers =
+                ResourceResolvers.builder(buildResources())
+                        .setInlineImageResourceResolver(mInlineImageResolver)
+                        .setAndroidImageResourceByResIdResolver(mImageByResIdResolver)
+                        .setAndroidAnimatedImageResourceByResIdResolver(
+                                mAnimatedImageByResIdResolver)
+                        .setAndroidSeekableAnimatedImageResourceByResIdResolver(
+                                mSeekableAnimatedImageByResIdResolver)
+                        .build();
+
+        ListenableFuture<Drawable> future = resolvers.getDrawable("does_not_exist");
+
+        verify(mInlineImageResolver, never()).getDrawableOrThrow(any());
+        verify(mImageByResIdResolver, never()).getDrawableOrThrow(any());
+        verify(mAnimatedImageByResIdResolver, never()).getDrawableOrThrow(any());
+        verify(mSeekableAnimatedImageByResIdResolver, never()).getDrawableOrThrow(any());
+
+        assertThrows(ExecutionException.class, future::get);
+    }
+
+    @Test
+    public void throwsIfAccessorIsMissing() throws Exception {
+        ResourceResolvers resolvers =
+                ResourceResolvers.builder(buildResources())
+                        // No inline image resolver.
+                        .setAndroidImageResourceByResIdResolver(mImageByResIdResolver)
+                        .setAndroidAnimatedImageResourceByResIdResolver(
+                                mAnimatedImageByResIdResolver)
+                        .setAndroidSeekableAnimatedImageResourceByResIdResolver(
+                                mSeekableAnimatedImageByResIdResolver)
+                        .build();
+
+        ListenableFuture<Drawable> future = resolvers.getDrawable(INLINE_IMAGE_RESOURCE_ID);
+
+        verify(mInlineImageResolver, never()).getDrawableOrThrow(any());
+        verify(mImageByResIdResolver, never()).getDrawableOrThrow(any());
+        verify(mAnimatedImageByResIdResolver, never()).getDrawableOrThrow(any());
+        verify(mSeekableAnimatedImageByResIdResolver, never()).getDrawableOrThrow(any());
+
+        assertThrows(ExecutionException.class, future::get);
+    }
+
+    @Test
+    public void inlineImageCantHavePlaceholder() {
+        ResourceResolvers resolvers =
+                ResourceResolvers.builder(
+                                buildResources(
+                                        /* httpPlaceholderResourceId= */ INLINE_IMAGE_RESOURCE_ID))
+                        .setInlineImageResourceResolver(mInlineImageResolver)
+                        .setAndroidImageResourceByResIdResolver(mImageByResIdResolver)
+                        .setAndroidAnimatedImageResourceByResIdResolver(
+                                mAnimatedImageByResIdResolver)
+                        .setAndroidSeekableAnimatedImageResourceByResIdResolver(
+                                mSeekableAnimatedImageByResIdResolver)
+                        .build();
+
+        assertThat(resolvers.hasPlaceholderDrawable(INLINE_IMAGE_RESOURCE_ID)).isFalse();
+    }
+
+    @Test
+    public void imageByResCantHavePlaceholder() {
+        ResourceResolvers resolvers =
+                ResourceResolvers.builder(
+                                buildResources(
+                                        /* httpPlaceholderResourceId= */ INLINE_IMAGE_RESOURCE_ID))
+                        .setInlineImageResourceResolver(mInlineImageResolver)
+                        .setAndroidImageResourceByResIdResolver(mImageByResIdResolver)
+                        .setAndroidAnimatedImageResourceByResIdResolver(
+                                mAnimatedImageByResIdResolver)
+                        .setAndroidSeekableAnimatedImageResourceByResIdResolver(
+                                mSeekableAnimatedImageByResIdResolver)
+                        .build();
+
+        assertThat(resolvers.hasPlaceholderDrawable(ANDROID_IMAGE_BY_RES_ID_RESOURCE_ID)).isFalse();
+    }
+
+    @Test
+    public void animatedImageByResCantHavePlaceholder() {
+        ResourceResolvers resolvers =
+                ResourceResolvers.builder(
+                                buildResources(
+                                        /* httpPlaceholderResourceId= */ INLINE_IMAGE_RESOURCE_ID))
+                        .setInlineImageResourceResolver(mInlineImageResolver)
+                        .setAndroidImageResourceByResIdResolver(mImageByResIdResolver)
+                        .setAndroidAnimatedImageResourceByResIdResolver(
+                                mAnimatedImageByResIdResolver)
+                        .setAndroidSeekableAnimatedImageResourceByResIdResolver(
+                                mSeekableAnimatedImageByResIdResolver)
+                        .build();
+
+        assertThat(resolvers.hasPlaceholderDrawable(ANDROID_AVD_BY_RES_ID_RESOURCE_ID)).isFalse();
+    }
+
+    @Test
+    public void getPlaceholderDrawableThrowsIfResourceCannotHavePlaceholder() throws Exception {
+        ResourceResolvers resolvers =
+                ResourceResolvers.builder(buildResources())
+                        // No inline image resolver.
+                        .setAndroidImageResourceByResIdResolver(mImageByResIdResolver)
+                        .setAndroidAnimatedImageResourceByResIdResolver(
+                                mAnimatedImageByResIdResolver)
+                        .setAndroidSeekableAnimatedImageResourceByResIdResolver(
+                                mSeekableAnimatedImageByResIdResolver)
+                        .build();
+
+        assertThrows(
+                ResourceAccessException.class,
+                () -> resolvers.getPlaceholderDrawableOrThrow(INLINE_IMAGE_RESOURCE_ID));
+
+        verify(mInlineImageResolver, never()).getDrawableOrThrow(any());
+        verify(mImageByResIdResolver, never()).getDrawableOrThrow(any());
+        verify(mAnimatedImageByResIdResolver, never()).getDrawableOrThrow(any());
+        verify(mSeekableAnimatedImageByResIdResolver, never()).getDrawableOrThrow(any());
+    }
+
+    @Test
+    public void canImageBeTinted_onlyReturnsTrueForAndroidResources() {
+        ResourceResolvers resolvers =
+                ResourceResolvers.builder(buildResources())
+                        .setInlineImageResourceResolver(mInlineImageResolver)
+                        .setAndroidImageResourceByResIdResolver(mImageByResIdResolver)
+                        .setAndroidAnimatedImageResourceByResIdResolver(
+                                mAnimatedImageByResIdResolver)
+                        .setAndroidSeekableAnimatedImageResourceByResIdResolver(
+                                mSeekableAnimatedImageByResIdResolver)
+                        .build();
+
+        assertThat(resolvers.canImageBeTinted(ANDROID_IMAGE_BY_RES_ID_RESOURCE_ID)).isTrue();
+        assertThat(resolvers.canImageBeTinted(ANDROID_AVD_BY_RES_ID_RESOURCE_ID)).isTrue();
+        assertThat(resolvers.canImageBeTinted(ANDROID_SEEKABLE_AVD_BY_RES_ID_RESOURCE_ID)).isTrue();
+        assertThat(resolvers.canImageBeTinted(INLINE_IMAGE_RESOURCE_ID)).isFalse();
+    }
+
+    private static Resources buildResources() {
+        return buildResources(null);
+    }
+
+    private static Resources buildResources(@Nullable String httpPlaceholderResourceId) {
+        Resources.Builder builder =
+                Resources.newBuilder()
+                        .putIdToImage(
+                                INLINE_IMAGE_RESOURCE_ID,
+                                ImageResource.newBuilder().setInlineResource(INLINE_IMAGE).build())
+                        .putIdToImage(
+                                ANDROID_IMAGE_BY_RES_ID_RESOURCE_ID,
+                                ImageResource.newBuilder()
+                                        .setAndroidResourceByResId(ANDROID_IMAGE_BY_RES_ID)
+                                        .build())
+                        .putIdToImage(
+                                ANDROID_AVD_BY_RES_ID_RESOURCE_ID,
+                                ImageResource.newBuilder()
+                                        .setAndroidAnimatedResourceByResId(ANDROID_AVD_BY_RES_ID)
+                                        .build())
+                        .putIdToImage(
+                                ANDROID_SEEKABLE_AVD_BY_RES_ID_RESOURCE_ID,
+                                ImageResource.newBuilder()
+                                        .setAndroidSeekableAnimatedResourceByResId(
+                                                ANDROID_SEEKABLE_AVD_BY_RES_ID)
+                                        .build());
+
+        return builder.build();
+    }
+}
diff --git a/wear/protolayout/protolayout-renderer/src/test/res/anim/rotation.xml b/wear/protolayout/protolayout-renderer/src/test/res/anim/rotation.xml
new file mode 100644
index 0000000..5570f46
--- /dev/null
+++ b/wear/protolayout/protolayout-renderer/src/test/res/anim/rotation.xml
@@ -0,0 +1,26 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  Copyright 2023 The Android Open Source Project
+
+  Licensed under the Apache License, Version 2.0 (the "License");
+  you may not use this file except in compliance with the License.
+  You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+  -->
+
+<set xmlns:android="http://schemas.android.com/apk/res/android">
+  <objectAnimator
+      android:duration="1000"
+      android:interpolator="@android:anim/linear_interpolator"
+      android:propertyName="rotation"
+      android:valueFrom="0"
+      android:valueTo="360"
+      android:valueType="floatType" />
+</set>
diff --git a/wear/protolayout/protolayout-renderer/src/test/res/drawable/android_24dp.xml b/wear/protolayout/protolayout-renderer/src/test/res/drawable/android_24dp.xml
new file mode 100644
index 0000000..0eabbdd
--- /dev/null
+++ b/wear/protolayout/protolayout-renderer/src/test/res/drawable/android_24dp.xml
@@ -0,0 +1,21 @@
+<!--
+  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 android:height="24dp" android:tint="#FFFFFF"
+    android:viewportHeight="24" android:viewportWidth="24"
+    android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
+  <path android:fillColor="#FF000000" android:pathData="M17.6,11.48 L19.44,8.3a0.63,0.63 0,0 0,-1.09 -0.63l-1.88,3.24a11.43,11.43 0,0 0,-8.94 0L5.65,7.67a0.63,0.63 0,0 0,-1.09 0.63L6.4,11.48A10.81,10.81 0,0 0,1 20L23,20A10.81,10.81 0,0 0,17.6 11.48ZM7,17.25A1.25,1.25 0,1 1,8.25 16,1.25 1.25,0 0,1 7,17.25ZM17,17.25A1.25,1.25 0,1 1,18.25 16,1.25 1.25,0 0,1 17,17.25Z"/>
+</vector>
diff --git a/wear/protolayout/protolayout-renderer/src/test/res/drawable/android_animated_24dp.xml b/wear/protolayout/protolayout-renderer/src/test/res/drawable/android_animated_24dp.xml
new file mode 100644
index 0000000..bf8dadc
--- /dev/null
+++ b/wear/protolayout/protolayout-renderer/src/test/res/drawable/android_animated_24dp.xml
@@ -0,0 +1,25 @@
+<?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.
+  -->
+
+<animated-vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:drawable="@drawable/android_head_24dp">
+
+  <target
+      android:name="android_head"
+      android:animation="@anim/rotation" />
+
+</animated-vector>
diff --git a/wear/protolayout/protolayout-renderer/src/test/res/drawable/android_head_24dp.xml b/wear/protolayout/protolayout-renderer/src/test/res/drawable/android_head_24dp.xml
new file mode 100644
index 0000000..0214194
--- /dev/null
+++ b/wear/protolayout/protolayout-renderer/src/test/res/drawable/android_head_24dp.xml
@@ -0,0 +1,23 @@
+<!--
+  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 android:height="24dp" android:tint="#FFFFFF"
+    android:viewportHeight="24" android:viewportWidth="24"
+    android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
+  <group android:name="android_head">
+    <path android:fillColor="#FF000000" android:pathData="M17.6,11.48 L19.44,8.3a0.63,0.63 0,0 0,-1.09 -0.63l-1.88,3.24a11.43,11.43 0,0 0,-8.94 0L5.65,7.67a0.63,0.63 0,0 0,-1.09 0.63L6.4,11.48A10.81,10.81 0,0 0,1 20L23,20A10.81,10.81 0,0 0,17.6 11.48ZM7,17.25A1.25,1.25 0,1 1,8.25 16,1.25 1.25,0 0,1 7,17.25ZM17,17.25A1.25,1.25 0,1 1,18.25 16,1.25 1.25,0 0,1 17,17.25Z"/>
+  </group>
+</vector>
diff --git a/wear/protolayout/protolayout-renderer/src/test/res/drawable/broken_drawable.xml b/wear/protolayout/protolayout-renderer/src/test/res/drawable/broken_drawable.xml
new file mode 100644
index 0000000..81c73e3
--- /dev/null
+++ b/wear/protolayout/protolayout-renderer/src/test/res/drawable/broken_drawable.xml
@@ -0,0 +1,17 @@
+<!--
+  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="21dp" android:height="29.7dp" android:viewportWidth="210" android:viewportHeight="297"></vector>
diff --git a/wear/protolayout/protolayout-renderer/src/test/res/drawable/filled_image.png b/wear/protolayout/protolayout-renderer/src/test/res/drawable/filled_image.png
new file mode 100644
index 0000000..a6da988
--- /dev/null
+++ b/wear/protolayout/protolayout-renderer/src/test/res/drawable/filled_image.png
Binary files differ
diff --git a/wear/protolayout/protolayout-renderer/src/test/res/drawable/ic_channel_foreground.xml b/wear/protolayout/protolayout-renderer/src/test/res/drawable/ic_channel_foreground.xml
new file mode 100644
index 0000000..98a99d9
--- /dev/null
+++ b/wear/protolayout/protolayout-renderer/src/test/res/drawable/ic_channel_foreground.xml
@@ -0,0 +1,31 @@
+<!--
+  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="120dp"
+    android:height="120dp"
+    android:viewportWidth="120"
+    android:viewportHeight="120"
+    android:tint="#FFFFFF">
+  <group android:scaleX="2.9"
+      android:scaleY="2.9"
+      android:translateX="25.2"
+      android:translateY="25.2">
+      <path
+          android:pathData="M17.6,11.48 L19.44,8.3a0.63,0.63 0,0 0,-1.09 -0.63l-1.88,3.24a11.43,11.43 0,0 0,-8.94 0L5.65,7.67a0.63,0.63 0,0 0,-1.09 0.63L6.4,11.48A10.81,10.81 0,0 0,1 20L23,20A10.81,10.81 0,0 0,17.6 11.48ZM7,17.25A1.25,1.25 0,1 1,8.25 16,1.25 1.25,0 0,1 7,17.25ZM17,17.25A1.25,1.25 0,1 1,18.25 16,1.25 1.25,0 0,1 17,17.25Z"
+          android:fillColor="#FF000000"/>
+  </group>
+</vector>
diff --git a/wear/protolayout/protolayout-renderer/src/test/res/mipmap-anydpi-v26/android_withbg_120dp.xml b/wear/protolayout/protolayout-renderer/src/test/res/mipmap-anydpi-v26/android_withbg_120dp.xml
new file mode 100644
index 0000000..743474e
--- /dev/null
+++ b/wear/protolayout/protolayout-renderer/src/test/res/mipmap-anydpi-v26/android_withbg_120dp.xml
@@ -0,0 +1,21 @@
+<?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.
+  -->
+
+<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
+    <background android:drawable="@color/ic_channel_background"/>
+    <foreground android:drawable="@drawable/ic_channel_foreground"/>
+</adaptive-icon>
diff --git a/wear/protolayout/protolayout-renderer/src/test/res/values/ic_channel_background.xml b/wear/protolayout/protolayout-renderer/src/test/res/values/ic_channel_background.xml
new file mode 100644
index 0000000..9fe15bc
--- /dev/null
+++ b/wear/protolayout/protolayout-renderer/src/test/res/values/ic_channel_background.xml
@@ -0,0 +1,20 @@
+<?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.
+  -->
+
+<resources>
+    <color name="ic_channel_background">#3DDC84</color>
+</resources>
diff --git a/wear/protolayout/protolayout-renderer/src/test/res/values/styles.xml b/wear/protolayout/protolayout-renderer/src/test/res/values/styles.xml
new file mode 100644
index 0000000..2050e47
--- /dev/null
+++ b/wear/protolayout/protolayout-renderer/src/test/res/values/styles.xml
@@ -0,0 +1,33 @@
+<?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.
+  -->
+<resources>
+  <style name="MyProtoLayoutFallbackSansSerifTextAppearance" parent="ProtoLayoutFallbackTextAppearance">
+    <item name="android:fontFamily">sans-serif</item>
+  </style>
+
+  <style name="MyProtoLayoutSansSerifFont">
+    <item name="protoLayoutNormalFont">sans-serif</item>
+    <item name="protoLayoutMediumFont">sans-serif-medium</item>
+    <item name="protoLayoutBoldFont">sans-serif</item>
+  </style>
+
+  <style name="MyProtoLayoutSansSerifTheme">
+    <item name="protoLayoutFallbackTextAppearance">@style/MyProtoLayoutFallbackSansSerifTextAppearance</item>
+    <item name="protoLayoutTitleFont">@style/MyProtoLayoutSansSerifFont</item>
+    <item name="protoLayoutBodyFont">@style/MyProtoLayoutSansSerifFont</item>
+  </style>
+</resources>
diff --git a/wear/protolayout/protolayout/api/public_plus_experimental_current.txt b/wear/protolayout/protolayout/api/public_plus_experimental_current.txt
index 0d15cce..a46315a 100644
--- a/wear/protolayout/protolayout/api/public_plus_experimental_current.txt
+++ b/wear/protolayout/protolayout/api/public_plus_experimental_current.txt
@@ -736,6 +736,26 @@
   }
 
   public final class ModifiersBuilders {
+    field @androidx.wear.protolayout.expression.ProtoLayoutExperimental public static final int SLIDE_DIRECTION_BOTTOM_TO_TOP = 4; // 0x4
+    field @androidx.wear.protolayout.expression.ProtoLayoutExperimental public static final int SLIDE_DIRECTION_LEFT_TO_RIGHT = 1; // 0x1
+    field @androidx.wear.protolayout.expression.ProtoLayoutExperimental public static final int SLIDE_DIRECTION_RIGHT_TO_LEFT = 2; // 0x2
+    field @androidx.wear.protolayout.expression.ProtoLayoutExperimental public static final int SLIDE_DIRECTION_TOP_TO_BOTTOM = 3; // 0x3
+    field @androidx.wear.protolayout.expression.ProtoLayoutExperimental public static final int SLIDE_DIRECTION_UNDEFINED = 0; // 0x0
+    field @androidx.wear.protolayout.expression.ProtoLayoutExperimental public static final int SLIDE_PARENT_SNAP_TO_INSIDE = 1; // 0x1
+    field @androidx.wear.protolayout.expression.ProtoLayoutExperimental public static final int SLIDE_PARENT_SNAP_TO_OUTSIDE = 2; // 0x2
+    field @androidx.wear.protolayout.expression.ProtoLayoutExperimental public static final int SLIDE_PARENT_SNAP_UNDEFINED = 0; // 0x0
+  }
+
+  @androidx.wear.protolayout.expression.ProtoLayoutExperimental public static final class ModifiersBuilders.AnimatedVisibility {
+    method public androidx.wear.protolayout.ModifiersBuilders.EnterTransition? getEnterTransition();
+    method public androidx.wear.protolayout.ModifiersBuilders.ExitTransition? getExitTransition();
+  }
+
+  public static final class ModifiersBuilders.AnimatedVisibility.Builder {
+    ctor public ModifiersBuilders.AnimatedVisibility.Builder();
+    method public androidx.wear.protolayout.ModifiersBuilders.AnimatedVisibility build();
+    method public androidx.wear.protolayout.ModifiersBuilders.AnimatedVisibility.Builder setEnterTransition(androidx.wear.protolayout.ModifiersBuilders.EnterTransition);
+    method public androidx.wear.protolayout.ModifiersBuilders.AnimatedVisibility.Builder setExitTransition(androidx.wear.protolayout.ModifiersBuilders.ExitTransition);
   }
 
   public static final class ModifiersBuilders.ArcModifiers {
@@ -796,6 +816,15 @@
     method public androidx.wear.protolayout.ModifiersBuilders.Corner.Builder setRadius(androidx.wear.protolayout.DimensionBuilders.DpProp);
   }
 
+  @androidx.wear.protolayout.expression.ProtoLayoutExperimental public static final class ModifiersBuilders.DefaultContentTransitions {
+    method public static androidx.wear.protolayout.ModifiersBuilders.EnterTransition fadeIn();
+    method public static androidx.wear.protolayout.ModifiersBuilders.EnterTransition fadeInSlideIn(int);
+    method public static androidx.wear.protolayout.ModifiersBuilders.ExitTransition fadeOut();
+    method public static androidx.wear.protolayout.ModifiersBuilders.ExitTransition fadeOutSlideOut(int);
+    method public static androidx.wear.protolayout.ModifiersBuilders.EnterTransition slideIn(int);
+    method public static androidx.wear.protolayout.ModifiersBuilders.ExitTransition slideOut(int);
+  }
+
   public static final class ModifiersBuilders.ElementMetadata {
     method public byte[] getTagData();
   }
@@ -806,10 +835,59 @@
     method public androidx.wear.protolayout.ModifiersBuilders.ElementMetadata.Builder setTagData(byte[]);
   }
 
+  @androidx.wear.protolayout.expression.ProtoLayoutExperimental public static final class ModifiersBuilders.EnterTransition {
+    method public androidx.wear.protolayout.ModifiersBuilders.FadeInTransition? getFadeIn();
+    method public androidx.wear.protolayout.ModifiersBuilders.SlideInTransition? getSlideIn();
+  }
+
+  public static final class ModifiersBuilders.EnterTransition.Builder {
+    ctor public ModifiersBuilders.EnterTransition.Builder();
+    method public androidx.wear.protolayout.ModifiersBuilders.EnterTransition build();
+    method public androidx.wear.protolayout.ModifiersBuilders.EnterTransition.Builder setFadeIn(androidx.wear.protolayout.ModifiersBuilders.FadeInTransition);
+    method public androidx.wear.protolayout.ModifiersBuilders.EnterTransition.Builder setSlideIn(androidx.wear.protolayout.ModifiersBuilders.SlideInTransition);
+  }
+
+  @androidx.wear.protolayout.expression.ProtoLayoutExperimental public static final class ModifiersBuilders.ExitTransition {
+    method public androidx.wear.protolayout.ModifiersBuilders.FadeOutTransition? getFadeOut();
+    method public androidx.wear.protolayout.ModifiersBuilders.SlideOutTransition? getSlideOut();
+  }
+
+  public static final class ModifiersBuilders.ExitTransition.Builder {
+    ctor public ModifiersBuilders.ExitTransition.Builder();
+    method public androidx.wear.protolayout.ModifiersBuilders.ExitTransition build();
+    method public androidx.wear.protolayout.ModifiersBuilders.ExitTransition.Builder setFadeOut(androidx.wear.protolayout.ModifiersBuilders.FadeOutTransition);
+    method public androidx.wear.protolayout.ModifiersBuilders.ExitTransition.Builder setSlideOut(androidx.wear.protolayout.ModifiersBuilders.SlideOutTransition);
+  }
+
+  @androidx.wear.protolayout.expression.ProtoLayoutExperimental public static final class ModifiersBuilders.FadeInTransition {
+    method public androidx.wear.protolayout.expression.AnimationParameterBuilders.AnimationSpec? getAnimationSpec();
+    method @FloatRange(from=0.0, to=1.0) public float getInitialAlpha();
+  }
+
+  public static final class ModifiersBuilders.FadeInTransition.Builder {
+    ctor public ModifiersBuilders.FadeInTransition.Builder();
+    method public androidx.wear.protolayout.ModifiersBuilders.FadeInTransition build();
+    method public androidx.wear.protolayout.ModifiersBuilders.FadeInTransition.Builder setAnimationSpec(androidx.wear.protolayout.expression.AnimationParameterBuilders.AnimationSpec);
+    method public androidx.wear.protolayout.ModifiersBuilders.FadeInTransition.Builder setInitialAlpha(@FloatRange(from=0.0, to=1.0) float);
+  }
+
+  @androidx.wear.protolayout.expression.ProtoLayoutExperimental public static final class ModifiersBuilders.FadeOutTransition {
+    method public androidx.wear.protolayout.expression.AnimationParameterBuilders.AnimationSpec? getAnimationSpec();
+    method @FloatRange(from=0.0, to=1.0) public float getTargetAlpha();
+  }
+
+  public static final class ModifiersBuilders.FadeOutTransition.Builder {
+    ctor public ModifiersBuilders.FadeOutTransition.Builder();
+    method public androidx.wear.protolayout.ModifiersBuilders.FadeOutTransition build();
+    method public androidx.wear.protolayout.ModifiersBuilders.FadeOutTransition.Builder setAnimationSpec(androidx.wear.protolayout.expression.AnimationParameterBuilders.AnimationSpec);
+    method public androidx.wear.protolayout.ModifiersBuilders.FadeOutTransition.Builder setTargetAlpha(@FloatRange(from=0.0, to=1.0) float);
+  }
+
   public static final class ModifiersBuilders.Modifiers {
     method public androidx.wear.protolayout.ModifiersBuilders.Background? getBackground();
     method public androidx.wear.protolayout.ModifiersBuilders.Border? getBorder();
     method public androidx.wear.protolayout.ModifiersBuilders.Clickable? getClickable();
+    method @androidx.wear.protolayout.expression.ProtoLayoutExperimental public androidx.wear.protolayout.ModifiersBuilders.AnimatedVisibility? getContentUpdateAnimation();
     method public androidx.wear.protolayout.ModifiersBuilders.ElementMetadata? getMetadata();
     method public androidx.wear.protolayout.ModifiersBuilders.Padding? getPadding();
     method public androidx.wear.protolayout.ModifiersBuilders.Semantics? getSemantics();
@@ -821,6 +899,7 @@
     method public androidx.wear.protolayout.ModifiersBuilders.Modifiers.Builder setBackground(androidx.wear.protolayout.ModifiersBuilders.Background);
     method public androidx.wear.protolayout.ModifiersBuilders.Modifiers.Builder setBorder(androidx.wear.protolayout.ModifiersBuilders.Border);
     method public androidx.wear.protolayout.ModifiersBuilders.Modifiers.Builder setClickable(androidx.wear.protolayout.ModifiersBuilders.Clickable);
+    method @androidx.wear.protolayout.expression.ProtoLayoutExperimental public androidx.wear.protolayout.ModifiersBuilders.Modifiers.Builder setContentUpdateAnimation(androidx.wear.protolayout.ModifiersBuilders.AnimatedVisibility);
     method public androidx.wear.protolayout.ModifiersBuilders.Modifiers.Builder setMetadata(androidx.wear.protolayout.ModifiersBuilders.ElementMetadata);
     method public androidx.wear.protolayout.ModifiersBuilders.Modifiers.Builder setPadding(androidx.wear.protolayout.ModifiersBuilders.Padding);
     method public androidx.wear.protolayout.ModifiersBuilders.Modifiers.Builder setSemantics(androidx.wear.protolayout.ModifiersBuilders.Semantics);
@@ -856,6 +935,47 @@
     method public androidx.wear.protolayout.ModifiersBuilders.Semantics.Builder setContentDescription(String);
   }
 
+  @androidx.wear.protolayout.expression.ProtoLayoutExperimental public static interface ModifiersBuilders.SlideBound {
+  }
+
+  @androidx.wear.protolayout.expression.ProtoLayoutExperimental public static final class ModifiersBuilders.SlideInTransition {
+    method public androidx.wear.protolayout.expression.AnimationParameterBuilders.AnimationSpec? getAnimationSpec();
+    method public int getDirection();
+    method public androidx.wear.protolayout.ModifiersBuilders.SlideBound? getInitialSlideBound();
+  }
+
+  public static final class ModifiersBuilders.SlideInTransition.Builder {
+    ctor public ModifiersBuilders.SlideInTransition.Builder();
+    method public androidx.wear.protolayout.ModifiersBuilders.SlideInTransition build();
+    method public androidx.wear.protolayout.ModifiersBuilders.SlideInTransition.Builder setAnimationSpec(androidx.wear.protolayout.expression.AnimationParameterBuilders.AnimationSpec);
+    method public androidx.wear.protolayout.ModifiersBuilders.SlideInTransition.Builder setDirection(int);
+    method public androidx.wear.protolayout.ModifiersBuilders.SlideInTransition.Builder setInitialSlideBound(androidx.wear.protolayout.ModifiersBuilders.SlideBound);
+  }
+
+  @androidx.wear.protolayout.expression.ProtoLayoutExperimental public static final class ModifiersBuilders.SlideOutTransition {
+    method public androidx.wear.protolayout.expression.AnimationParameterBuilders.AnimationSpec? getAnimationSpec();
+    method public int getDirection();
+    method public androidx.wear.protolayout.ModifiersBuilders.SlideBound? getTargetSlideBound();
+  }
+
+  public static final class ModifiersBuilders.SlideOutTransition.Builder {
+    ctor public ModifiersBuilders.SlideOutTransition.Builder();
+    method public androidx.wear.protolayout.ModifiersBuilders.SlideOutTransition build();
+    method public androidx.wear.protolayout.ModifiersBuilders.SlideOutTransition.Builder setAnimationSpec(androidx.wear.protolayout.expression.AnimationParameterBuilders.AnimationSpec);
+    method public androidx.wear.protolayout.ModifiersBuilders.SlideOutTransition.Builder setDirection(int);
+    method public androidx.wear.protolayout.ModifiersBuilders.SlideOutTransition.Builder setTargetSlideBound(androidx.wear.protolayout.ModifiersBuilders.SlideBound);
+  }
+
+  @androidx.wear.protolayout.expression.ProtoLayoutExperimental public static final class ModifiersBuilders.SlideParentBound implements androidx.wear.protolayout.ModifiersBuilders.SlideBound {
+    method public int getSnapTo();
+  }
+
+  public static final class ModifiersBuilders.SlideParentBound.Builder {
+    ctor public ModifiersBuilders.SlideParentBound.Builder();
+    method public androidx.wear.protolayout.ModifiersBuilders.SlideParentBound build();
+    method public androidx.wear.protolayout.ModifiersBuilders.SlideParentBound.Builder setSnapTo(int);
+  }
+
   public static final class ModifiersBuilders.SpanModifiers {
     method public androidx.wear.protolayout.ModifiersBuilders.Clickable? getClickable();
   }
diff --git a/wear/protolayout/protolayout/src/main/java/androidx/wear/protolayout/ModifiersBuilders.java b/wear/protolayout/protolayout/src/main/java/androidx/wear/protolayout/ModifiersBuilders.java
index cc326a4..b8f1803 100644
--- a/wear/protolayout/protolayout/src/main/java/androidx/wear/protolayout/ModifiersBuilders.java
+++ b/wear/protolayout/protolayout/src/main/java/androidx/wear/protolayout/ModifiersBuilders.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2021-2022 The Android Open Source Project
+ * Copyright 2022 The Android Open Source Project
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -22,13 +22,19 @@
 import android.annotation.SuppressLint;
 import androidx.annotation.ColorInt;
 import androidx.annotation.Dimension;
+import androidx.annotation.FloatRange;
+import androidx.annotation.IntDef;
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import androidx.annotation.RestrictTo;
 import androidx.annotation.RestrictTo.Scope;
+import androidx.wear.protolayout.ActionBuilders.Action;
+import androidx.wear.protolayout.expression.AnimationParameterBuilders.AnimationSpec;
 import androidx.wear.protolayout.expression.DynamicBuilders;
+import androidx.wear.protolayout.expression.DynamicBuilders.DynamicColor;
 import androidx.wear.protolayout.expression.DynamicBuilders.DynamicString;
 import androidx.wear.protolayout.expression.Fingerprint;
+import androidx.wear.protolayout.expression.ProtoLayoutExperimental;
 import androidx.wear.protolayout.proto.ColorProto;
 import androidx.wear.protolayout.proto.DimensionProto;
 import androidx.wear.protolayout.proto.ModifiersProto;
@@ -38,12 +44,221 @@
 import androidx.wear.protolayout.DimensionBuilders.DpProp;
 import androidx.wear.protolayout.TypeBuilders.BoolProp;
 import androidx.wear.protolayout.protobuf.ByteString;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
 import java.util.Arrays;
 
 /** Builders for modifiers for composable layout elements. */
 public final class ModifiersBuilders {
   private ModifiersBuilders() {}
 
+  /** Prebuilt default objects for animated visibility transition animations. */
+  @ProtoLayoutExperimental
+  public static final class DefaultContentTransitions {
+    /**
+     * Fade in transition animation that fades in element when entering the layout, from fully
+     * invisible to fully visible.
+     */
+    private static final FadeInTransition FADE_IN_TRANSITION =
+        new FadeInTransition.Builder().build();
+
+    /**
+     * Fade in enter animation that fades in element when entering the layout, from fully invisible
+     * to fully visible.
+     */
+    private static final EnterTransition FADE_IN_ENTER_TRANSITION =
+        new EnterTransition.Builder().setFadeIn(FADE_IN_TRANSITION).build();
+
+    /**
+     * Slide in transition animation that slides in element when entering the layout into its
+     * position from the parent edge in the given direction.
+     *
+     * @param direction The direction for sliding in transition.
+     */
+    private static SlideInTransition slideInTransition(@SlideDirection int direction) {
+      return new SlideInTransition.Builder().setDirection(direction).build();
+    }
+
+    /**
+     * Enter content transition animation that fades in element when entering the layout, from fully
+     * invisible to fully visible.
+     */
+    @NonNull
+    public static EnterTransition fadeIn() {
+      return FADE_IN_ENTER_TRANSITION;
+    }
+
+    /**
+     * Enter content transition animation that slides in element when entering the layout into its
+     * position from the parent edge in the given direction.
+     */
+    @NonNull
+    public static EnterTransition slideIn(@SlideDirection int slideDirection) {
+      return new EnterTransition.Builder().setSlideIn(slideInTransition(slideDirection)).build();
+    }
+
+    /**
+     * Enter content transition animation that fades in element when entering the layout, from fully
+     * invisible to fully visible and slides it in into its position from the parent edge in the
+     * given direction.
+     *
+     * @param slideDirection The direction for sliding in part of transition.
+     */
+    @NonNull
+    public static EnterTransition fadeInSlideIn(@SlideDirection int slideDirection) {
+      return new EnterTransition.Builder()
+          .setFadeIn(FADE_IN_TRANSITION)
+          .setSlideIn(slideInTransition(slideDirection))
+          .build();
+    }
+
+    /**
+     * Fade out transition animation that fades out element when exiting the layout, from fully
+     * visible to fully invisible.
+     */
+    private static final FadeOutTransition FADE_OUT_TRANSITION =
+        new FadeOutTransition.Builder().build();
+
+    /**
+     * Fade out exit animation that fades out element when exiting the layout, from fully visible to
+     * fully invisible.
+     */
+    private static final ExitTransition FADE_OUT_EXIT_TRANSITION =
+        new ExitTransition.Builder().setFadeOut(FADE_OUT_TRANSITION).build();
+
+    /**
+     * Slide out transition animation that slides out element when exiting the layout from its
+     * position to the parent edge in the given direction.
+     *
+     * @param direction The direction for sliding out transition.
+     */
+    private static SlideOutTransition slideOutTransition(@SlideDirection int direction) {
+      return new SlideOutTransition.Builder().setDirection(direction).build();
+    }
+
+    /**
+     * Exit content transition animation that fades out element when exiting the layout, from fully
+     * visible to fully invisible.
+     */
+    @NonNull
+    public static ExitTransition fadeOut() {
+      return FADE_OUT_EXIT_TRANSITION;
+    }
+
+    /**
+     * Exit content transition animation that slides out element when exiting the layout from its
+     * position to the parent edge in the given direction.
+     */
+    @NonNull
+    public static ExitTransition slideOut(@SlideDirection int slideDirection) {
+      return new ExitTransition.Builder().setSlideOut(slideOutTransition(slideDirection)).build();
+    }
+
+    /**
+     * Exit content transition animation that fades out element when exiting the layout, from fully
+     * visible to fully invisible and slides it out from its position to the parent edge in the
+     * given direction.
+     *
+     * @param slideDirection The direction for sliding in part of transition.
+     */
+    @NonNull
+    public static ExitTransition fadeOutSlideOut(@SlideDirection int slideDirection) {
+      return new ExitTransition.Builder()
+          .setFadeOut(FADE_OUT_TRANSITION)
+          .setSlideOut(slideOutTransition(slideDirection))
+          .build();
+    }
+
+    private DefaultContentTransitions() {}
+  }
+
+  /**
+   * The snap options to use when sliding using parent boundaries.
+   *
+   * @since 1.2
+   * @hide
+   */
+  @RestrictTo(RestrictTo.Scope.LIBRARY)
+  @IntDef({SLIDE_PARENT_SNAP_UNDEFINED, SLIDE_PARENT_SNAP_TO_INSIDE, SLIDE_PARENT_SNAP_TO_OUTSIDE})
+  @Retention(RetentionPolicy.SOURCE)
+  @ProtoLayoutExperimental
+  public @interface SlideParentSnapOption {}
+
+  /**
+   * The undefined snapping option.
+   *
+   * @since 1.2
+   */
+  @ProtoLayoutExperimental public static final int SLIDE_PARENT_SNAP_UNDEFINED = 0;
+
+  /**
+   * The option that snaps insides of the element and its parent at start/end.
+   *
+   * @since 1.2
+   */
+  @ProtoLayoutExperimental public static final int SLIDE_PARENT_SNAP_TO_INSIDE = 1;
+
+  /**
+   * The option that snaps outsides of the element and its parent at start/end.
+   *
+   * @since 1.2
+   */
+  @ProtoLayoutExperimental public static final int SLIDE_PARENT_SNAP_TO_OUTSIDE = 2;
+
+  /**
+   * The slide direction used for slide animations on any element, from the specified point to its
+   * destination in the layout for in animation or reverse for out animation.
+   *
+   * @since 1.2
+   * @hide
+   */
+  @RestrictTo(RestrictTo.Scope.LIBRARY)
+  @IntDef({
+    SLIDE_DIRECTION_UNDEFINED,
+    SLIDE_DIRECTION_LEFT_TO_RIGHT,
+    SLIDE_DIRECTION_RIGHT_TO_LEFT,
+    SLIDE_DIRECTION_TOP_TO_BOTTOM,
+    SLIDE_DIRECTION_BOTTOM_TO_TOP
+  })
+  @Retention(RetentionPolicy.SOURCE)
+  @ProtoLayoutExperimental
+  public @interface SlideDirection {}
+
+  /**
+   * The undefined sliding orientation.
+   *
+   * @since 1.2
+   */
+  @ProtoLayoutExperimental public static final int SLIDE_DIRECTION_UNDEFINED = 0;
+
+  /**
+   * The sliding orientation that moves an element horizontally from left to the right.
+   *
+   * @since 1.2
+   */
+  @ProtoLayoutExperimental public static final int SLIDE_DIRECTION_LEFT_TO_RIGHT = 1;
+
+  /**
+   * The sliding orientation that moves an element horizontally from right to the left.
+   *
+   * @since 1.2
+   */
+  @ProtoLayoutExperimental public static final int SLIDE_DIRECTION_RIGHT_TO_LEFT = 2;
+
+  /**
+   * The sliding orientation that moves an element vertically from top to the bottom.
+   *
+   * @since 1.2
+   */
+  @ProtoLayoutExperimental public static final int SLIDE_DIRECTION_TOP_TO_BOTTOM = 3;
+
+  /**
+   * The sliding orientation that moves an element vertically from bottom to the top.
+   *
+   * @since 1.2
+   */
+  @ProtoLayoutExperimental public static final int SLIDE_DIRECTION_BOTTOM_TO_TOP = 4;
+
   /**
    * A modifier for an element which can have associated Actions for click events. When an element
    * with a ClickableModifier is clicked it will fire the associated action.
@@ -817,6 +1032,22 @@
     }
 
     /**
+     * Gets the content transition of an element. Any update to the element or its children will
+     * trigger this animation for this element and everything underneath it.
+     *
+     * @since 1.2
+     */
+    @ProtoLayoutExperimental
+    @Nullable
+    public AnimatedVisibility getContentUpdateAnimation() {
+      if (mImpl.hasContentUpdateAnimation()) {
+        return AnimatedVisibility.fromProto(mImpl.getContentUpdateAnimation());
+      } else {
+        return null;
+      }
+    }
+
+    /**
      * Get the fingerprint for this object, or null if unknown.
      *
      * @hide
@@ -920,6 +1151,21 @@
         return this;
       }
 
+      /**
+       * Sets the content transition of an element. Any update to the element or its children will
+       * trigger this animation for this element and everything underneath it.
+       *
+       * @since 1.2
+       */
+      @ProtoLayoutExperimental
+      @NonNull
+      public Builder setContentUpdateAnimation(@NonNull AnimatedVisibility contentUpdateAnimation) {
+        mImpl.setContentUpdateAnimation(contentUpdateAnimation.toProto());
+        mFingerprint.recordPropertyUpdate(
+            7, checkNotNull(contentUpdateAnimation.getFingerprint()).aggregateValueAsInt());
+        return this;
+      }
+
       /** Builds an instance from accumulated values. */
       @NonNull
       public Modifiers build() {
@@ -929,6 +1175,1173 @@
   }
 
   /**
+   * The content transition of an element. Any update to the element or its children will trigger
+   * this animation for this element and everything underneath it.
+   *
+   * @since 1.2
+   */
+  @ProtoLayoutExperimental
+  public static final class AnimatedVisibility {
+    private final ModifiersProto.AnimatedVisibility mImpl;
+    @Nullable private final Fingerprint mFingerprint;
+
+    AnimatedVisibility(ModifiersProto.AnimatedVisibility impl, @Nullable Fingerprint fingerprint) {
+      this.mImpl = impl;
+      this.mFingerprint = fingerprint;
+    }
+
+    /**
+     * Gets the content transition that is triggered when element enters the layout.
+     *
+     * @since 1.2
+     */
+    @Nullable
+    public EnterTransition getEnterTransition() {
+      if (mImpl.hasEnterTransition()) {
+        return EnterTransition.fromProto(mImpl.getEnterTransition());
+      } else {
+        return null;
+      }
+    }
+
+    /**
+     * Gets the content transition that is triggered when element exits the layout. Note that
+     * indefinite exit animations are ignored.
+     *
+     * @since 1.2
+     */
+    @Nullable
+    public ExitTransition getExitTransition() {
+      if (mImpl.hasExitTransition()) {
+        return ExitTransition.fromProto(mImpl.getExitTransition());
+      } else {
+        return null;
+      }
+    }
+
+    /**
+     * Get the fingerprint for this object, or null if unknown.
+     *
+     * @hide
+     */
+    @RestrictTo(Scope.LIBRARY_GROUP)
+    @Nullable
+    public Fingerprint getFingerprint() {
+      return mFingerprint;
+    }
+    /**
+     * Creates a new wrapper instance from the proto.
+     *
+     * @hide
+     */
+    @RestrictTo(Scope.LIBRARY_GROUP)
+    @NonNull
+    public static AnimatedVisibility fromProto(
+        @NonNull ModifiersProto.AnimatedVisibility proto, @Nullable Fingerprint fingerprint) {
+      return new AnimatedVisibility(proto, fingerprint);
+    }
+
+    @NonNull
+    static AnimatedVisibility fromProto(@NonNull ModifiersProto.AnimatedVisibility proto) {
+      return fromProto(proto, null);
+    }
+
+    /**
+     * Returns the internal proto instance.
+     *
+     * @hide
+     */
+    @RestrictTo(Scope.LIBRARY_GROUP)
+    @NonNull
+    public ModifiersProto.AnimatedVisibility toProto() {
+      return mImpl;
+    }
+
+    @Override
+    @NonNull
+    public String toString() {
+      return "AnimatedVisibility{"
+              + "enterTransition="
+              + getEnterTransition()
+              + ", exitTransition="
+              + getExitTransition()
+              + "}";
+    }
+
+    /** Builder for {@link AnimatedVisibility} */
+    public static final class Builder {
+      private final ModifiersProto.AnimatedVisibility.Builder mImpl =
+          ModifiersProto.AnimatedVisibility.newBuilder();
+      private final Fingerprint mFingerprint = new Fingerprint(1372451979);
+
+      public Builder() {}
+
+      /**
+       * Sets the content transition that is triggered when element enters the layout.
+       *
+       * @since 1.2
+       */
+      @NonNull
+      public Builder setEnterTransition(@NonNull EnterTransition enterTransition) {
+        mImpl.setEnterTransition(enterTransition.toProto());
+        mFingerprint.recordPropertyUpdate(
+            1, checkNotNull(enterTransition.getFingerprint()).aggregateValueAsInt());
+        return this;
+      }
+
+      /**
+       * Sets the content transition that is triggered when element exits the layout. Note that
+       * indefinite exit animations are ignored.
+       *
+       * @since 1.2
+       */
+      @NonNull
+      public Builder setExitTransition(@NonNull ExitTransition exitTransition) {
+        mImpl.setExitTransition(exitTransition.toProto());
+        mFingerprint.recordPropertyUpdate(
+            2, checkNotNull(exitTransition.getFingerprint()).aggregateValueAsInt());
+        return this;
+      }
+
+      /** Builds an instance from accumulated values. */
+      @NonNull
+      public AnimatedVisibility build() {
+        return new AnimatedVisibility(mImpl.build(), mFingerprint);
+      }
+    }
+  }
+
+  /**
+   * The content transition that is triggered when element enters the layout.
+   *
+   * @since 1.2
+   */
+  @ProtoLayoutExperimental
+  public static final class EnterTransition {
+    private final ModifiersProto.EnterTransition mImpl;
+    @Nullable private final Fingerprint mFingerprint;
+
+    EnterTransition(ModifiersProto.EnterTransition impl, @Nullable Fingerprint fingerprint) {
+      this.mImpl = impl;
+      this.mFingerprint = fingerprint;
+    }
+
+    /**
+     * Gets the fading in animation for content transition of an element and its children happening
+     * when entering the layout.
+     *
+     * @since 1.2
+     */
+    @Nullable
+    public FadeInTransition getFadeIn() {
+      if (mImpl.hasFadeIn()) {
+        return FadeInTransition.fromProto(mImpl.getFadeIn());
+      } else {
+        return null;
+      }
+    }
+
+    /**
+     * Gets the sliding in animation for content transition of an element and its children happening
+     * when entering the layout.
+     *
+     * @since 1.2
+     */
+    @Nullable
+    public SlideInTransition getSlideIn() {
+      if (mImpl.hasSlideIn()) {
+        return SlideInTransition.fromProto(mImpl.getSlideIn());
+      } else {
+        return null;
+      }
+    }
+
+    /**
+     * Get the fingerprint for this object, or null if unknown.
+     *
+     * @hide
+     */
+    @RestrictTo(Scope.LIBRARY_GROUP)
+    @Nullable
+    public Fingerprint getFingerprint() {
+      return mFingerprint;
+    }
+    /**
+     * Creates a new wrapper instance from the proto.
+     *
+     * @hide
+     */
+    @RestrictTo(Scope.LIBRARY_GROUP)
+    @NonNull
+    public static EnterTransition fromProto(
+        @NonNull ModifiersProto.EnterTransition proto, @Nullable Fingerprint fingerprint) {
+      return new EnterTransition(proto, fingerprint);
+    }
+
+    @NonNull
+    static EnterTransition fromProto(@NonNull ModifiersProto.EnterTransition proto) {
+      return fromProto(proto, null);
+    }
+
+    /**
+     * Returns the internal proto instance.
+     *
+     * @hide
+     */
+    @RestrictTo(Scope.LIBRARY_GROUP)
+    @NonNull
+    public ModifiersProto.EnterTransition toProto() {
+      return mImpl;
+    }
+
+    @Override
+    @NonNull
+    public String toString() {
+      return "EnterTransition{" + "fadeIn=" + getFadeIn() + ", slideIn=" + getSlideIn() + "}";
+    }
+
+    /** Builder for {@link EnterTransition} */
+    public static final class Builder {
+      private final ModifiersProto.EnterTransition.Builder mImpl =
+          ModifiersProto.EnterTransition.newBuilder();
+      private final Fingerprint mFingerprint = new Fingerprint(-1732205279);
+
+      public Builder() {}
+
+      /**
+       * Sets the fading in animation for content transition of an element and its children
+       * happening when entering the layout.
+       *
+       * @since 1.2
+       */
+      @NonNull
+      public Builder setFadeIn(@NonNull FadeInTransition fadeIn) {
+        mImpl.setFadeIn(fadeIn.toProto());
+        mFingerprint.recordPropertyUpdate(
+            1, checkNotNull(fadeIn.getFingerprint()).aggregateValueAsInt());
+        return this;
+      }
+
+      /**
+       * Sets the sliding in animation for content transition of an element and its children
+       * happening when entering the layout.
+       *
+       * @since 1.2
+       */
+      @NonNull
+      public Builder setSlideIn(@NonNull SlideInTransition slideIn) {
+        mImpl.setSlideIn(slideIn.toProto());
+        mFingerprint.recordPropertyUpdate(
+            2, checkNotNull(slideIn.getFingerprint()).aggregateValueAsInt());
+        return this;
+      }
+
+      /** Builds an instance from accumulated values. */
+      @NonNull
+      public EnterTransition build() {
+        return new EnterTransition(mImpl.build(), mFingerprint);
+      }
+    }
+  }
+
+  /**
+   * The fading animation for content transition of an element and its children, from the specified
+   * starting alpha to fully visible.
+   *
+   * @since 1.2
+   */
+  @ProtoLayoutExperimental
+  public static final class FadeInTransition {
+    private final ModifiersProto.FadeInTransition mImpl;
+    @Nullable private final Fingerprint mFingerprint;
+
+    FadeInTransition(ModifiersProto.FadeInTransition impl, @Nullable Fingerprint fingerprint) {
+      this.mImpl = impl;
+      this.mFingerprint = fingerprint;
+    }
+
+    /**
+     * Gets the starting alpha of the fade in transition. It should be between 0 and 1. If not set,
+     * defaults to fully transparent, i.e. 0.
+     *
+     * @since 1.2
+     */
+    @FloatRange(from = 0.0, to = 1.0)
+    public float getInitialAlpha() {
+      return mImpl.getInitialAlpha();
+    }
+
+    /**
+     * Gets the animation parameters for duration, delay, etc.
+     *
+     * @since 1.2
+     */
+    @Nullable
+    public AnimationSpec getAnimationSpec() {
+      if (mImpl.hasAnimationSpec()) {
+        return AnimationSpec.fromProto(mImpl.getAnimationSpec());
+      } else {
+        return null;
+      }
+    }
+
+    /**
+     * Get the fingerprint for this object, or null if unknown.
+     *
+     * @hide
+     */
+    @RestrictTo(Scope.LIBRARY_GROUP)
+    @Nullable
+    public Fingerprint getFingerprint() {
+      return mFingerprint;
+    }
+    /**
+     * Creates a new wrapper instance from the proto.
+     *
+     * @hide
+     */
+    @RestrictTo(Scope.LIBRARY_GROUP)
+    @NonNull
+    public static FadeInTransition fromProto(
+        @NonNull ModifiersProto.FadeInTransition proto, @Nullable Fingerprint fingerprint) {
+      return new FadeInTransition(proto, fingerprint);
+    }
+
+    @NonNull
+    static FadeInTransition fromProto(@NonNull ModifiersProto.FadeInTransition proto) {
+      return fromProto(proto, null);
+    }
+
+    /**
+     * Returns the internal proto instance.
+     *
+     * @hide
+     */
+    @RestrictTo(Scope.LIBRARY_GROUP)
+    @NonNull
+    public ModifiersProto.FadeInTransition toProto() {
+      return mImpl;
+    }
+
+    @Override
+    @NonNull
+    public String toString() {
+      return "FadeInTransition{"
+          + "initialAlpha="
+          + getInitialAlpha()
+          + ", animationSpec="
+          + getAnimationSpec()
+          + "}";
+    }
+
+    /** Builder for {@link FadeInTransition} */
+    public static final class Builder {
+      private final ModifiersProto.FadeInTransition.Builder mImpl =
+          ModifiersProto.FadeInTransition.newBuilder();
+      private final Fingerprint mFingerprint = new Fingerprint(1430024488);
+
+      public Builder() {}
+
+      /**
+       * Sets the starting alpha of the fade in transition. It should be between 0 and 1. If not
+       * set, defaults to fully transparent, i.e. 0.
+       *
+       * @since 1.2
+       */
+      @NonNull
+      public Builder setInitialAlpha(@FloatRange(from = 0.0, to = 1.0) float initialAlpha) {
+        mImpl.setInitialAlpha(initialAlpha);
+        mFingerprint.recordPropertyUpdate(1, Float.floatToIntBits(initialAlpha));
+        return this;
+      }
+
+      /**
+       * Sets the animation parameters for duration, delay, etc.
+       *
+       * @since 1.2
+       */
+      @NonNull
+      public Builder setAnimationSpec(@NonNull AnimationSpec animationSpec) {
+        mImpl.setAnimationSpec(animationSpec.toProto());
+        mFingerprint.recordPropertyUpdate(
+            2, checkNotNull(animationSpec.getFingerprint()).aggregateValueAsInt());
+        return this;
+      }
+
+      /** Builds an instance from accumulated values. */
+      @NonNull
+      public FadeInTransition build() {
+        return new FadeInTransition(mImpl.build(), mFingerprint);
+      }
+    }
+  }
+
+  /**
+   * The sliding in animation for content transition of an element and its children.
+   *
+   * @since 1.2
+   */
+  @ProtoLayoutExperimental
+  public static final class SlideInTransition {
+    private final ModifiersProto.SlideInTransition mImpl;
+    @Nullable private final Fingerprint mFingerprint;
+
+    SlideInTransition(ModifiersProto.SlideInTransition impl, @Nullable Fingerprint fingerprint) {
+      this.mImpl = impl;
+      this.mFingerprint = fingerprint;
+    }
+
+    /**
+     * Gets the slide direction used for slide animations on any element, from the specified point
+     * to its destination in the layout. If not set, defaults to horizontal from left to the right.
+     *
+     * @since 1.2
+     */
+    @SlideDirection
+    public int getDirection() {
+      return mImpl.getDirection().getNumber();
+    }
+
+    /**
+     * Gets the initial offset for animation. By default the transition starts from the left parent
+     * boundary for horizontal orientation and from the top for vertical orientation. Note that
+     * sliding from the screen boundaries can only be achieved if all parent's sizes are big enough
+     * to accommodate it.
+     *
+     * @since 1.2
+     */
+    @Nullable
+    public SlideBound getInitialSlideBound() {
+      if (mImpl.hasInitialSlideBound()) {
+        return ModifiersBuilders.slideBoundFromProto(mImpl.getInitialSlideBound());
+      } else {
+        return null;
+      }
+    }
+
+    /**
+     * Gets the animation parameters for duration, delay, etc.
+     *
+     * @since 1.2
+     */
+    @Nullable
+    public AnimationSpec getAnimationSpec() {
+      if (mImpl.hasAnimationSpec()) {
+        return AnimationSpec.fromProto(mImpl.getAnimationSpec());
+      } else {
+        return null;
+      }
+    }
+
+    /**
+     * Get the fingerprint for this object, or null if unknown.
+     *
+     * @hide
+     */
+    @RestrictTo(Scope.LIBRARY_GROUP)
+    @Nullable
+    public Fingerprint getFingerprint() {
+      return mFingerprint;
+    }
+    /**
+     * Creates a new wrapper instance from the proto.
+     *
+     * @hide
+     */
+    @RestrictTo(Scope.LIBRARY_GROUP)
+    @NonNull
+    public static SlideInTransition fromProto(
+        @NonNull ModifiersProto.SlideInTransition proto, @Nullable Fingerprint fingerprint) {
+      return new SlideInTransition(proto, fingerprint);
+    }
+
+    @NonNull
+    static SlideInTransition fromProto(@NonNull ModifiersProto.SlideInTransition proto) {
+      return fromProto(proto, null);
+    }
+
+    /**
+     * Returns the internal proto instance.
+     *
+     * @hide
+     */
+    @RestrictTo(Scope.LIBRARY_GROUP)
+    @NonNull
+    public ModifiersProto.SlideInTransition toProto() {
+      return mImpl;
+    }
+
+    @Override
+    @NonNull
+    public String toString() {
+      return "SlideInTransition{"
+          + "direction="
+          + getDirection()
+          + ", initialSlideBound="
+          + getInitialSlideBound()
+          + ", animationSpec="
+          + getAnimationSpec()
+          + "}";
+    }
+
+    /** Builder for {@link SlideInTransition} */
+    public static final class Builder {
+      private final ModifiersProto.SlideInTransition.Builder mImpl =
+          ModifiersProto.SlideInTransition.newBuilder();
+      private final Fingerprint mFingerprint = new Fingerprint(-991346238);
+
+      public Builder() {}
+
+      /**
+       * Sets the slide direction used for slide animations on any element, from the specified point
+       * to its destination in the layout. If not set, defaults to horizontal from left to the
+       * right.
+       *
+       * @since 1.2
+       */
+      @NonNull
+      public Builder setDirection(@SlideDirection int direction) {
+        mImpl.setDirection(ModifiersProto.SlideDirection.forNumber(direction));
+        mFingerprint.recordPropertyUpdate(1, direction);
+        return this;
+      }
+
+      /**
+       * Sets the initial offset for animation. By default the transition starts from the left
+       * parent boundary for horizontal orientation and from the top for vertical orientation. Note
+       * that sliding from the screen boundaries can only be achieved if all parent's sizes are big
+       * enough to accommodate it.
+       *
+       * @since 1.2
+       */
+      @NonNull
+      public Builder setInitialSlideBound(@NonNull SlideBound initialSlideBound) {
+        mImpl.setInitialSlideBound(initialSlideBound.toSlideBoundProto());
+        mFingerprint.recordPropertyUpdate(
+            2, checkNotNull(initialSlideBound.getFingerprint()).aggregateValueAsInt());
+        return this;
+      }
+
+      /**
+       * Sets the animation parameters for duration, delay, etc.
+       *
+       * @since 1.2
+       */
+      @NonNull
+      public Builder setAnimationSpec(@NonNull AnimationSpec animationSpec) {
+        mImpl.setAnimationSpec(animationSpec.toProto());
+        mFingerprint.recordPropertyUpdate(
+            3, checkNotNull(animationSpec.getFingerprint()).aggregateValueAsInt());
+        return this;
+      }
+
+      /** Builds an instance from accumulated values. */
+      @NonNull
+      public SlideInTransition build() {
+        return new SlideInTransition(mImpl.build(), mFingerprint);
+      }
+    }
+  }
+
+  /**
+   * The content transition that is triggered when element exits the layout.
+   *
+   * @since 1.2
+   */
+  @ProtoLayoutExperimental
+  public static final class ExitTransition {
+    private final ModifiersProto.ExitTransition mImpl;
+    @Nullable private final Fingerprint mFingerprint;
+
+    ExitTransition(ModifiersProto.ExitTransition impl, @Nullable Fingerprint fingerprint) {
+      this.mImpl = impl;
+      this.mFingerprint = fingerprint;
+    }
+
+    /**
+     * Gets the fading out animation for content transition of an element and its children happening
+     * when exiting the layout.
+     *
+     * @since 1.2
+     */
+    @Nullable
+    public FadeOutTransition getFadeOut() {
+      if (mImpl.hasFadeOut()) {
+        return FadeOutTransition.fromProto(mImpl.getFadeOut());
+      } else {
+        return null;
+      }
+    }
+
+    /**
+     * Gets the sliding out animation for content transition of an element and its children
+     * happening when exiting the layout.
+     *
+     * @since 1.2
+     */
+    @Nullable
+    public SlideOutTransition getSlideOut() {
+      if (mImpl.hasSlideOut()) {
+        return SlideOutTransition.fromProto(mImpl.getSlideOut());
+      } else {
+        return null;
+      }
+    }
+
+    /**
+     * Get the fingerprint for this object, or null if unknown.
+     *
+     * @hide
+     */
+    @RestrictTo(Scope.LIBRARY_GROUP)
+    @Nullable
+    public Fingerprint getFingerprint() {
+      return mFingerprint;
+    }
+    /**
+     * Creates a new wrapper instance from the proto.
+     *
+     * @hide
+     */
+    @RestrictTo(Scope.LIBRARY_GROUP)
+    @NonNull
+    public static ExitTransition fromProto(
+        @NonNull ModifiersProto.ExitTransition proto, @Nullable Fingerprint fingerprint) {
+      return new ExitTransition(proto, fingerprint);
+    }
+
+    @NonNull
+    static ExitTransition fromProto(@NonNull ModifiersProto.ExitTransition proto) {
+      return fromProto(proto, null);
+    }
+
+    /**
+     * Returns the internal proto instance.
+     *
+     * @hide
+     */
+    @RestrictTo(Scope.LIBRARY_GROUP)
+    @NonNull
+    public ModifiersProto.ExitTransition toProto() {
+      return mImpl;
+    }
+
+    @Override
+    @NonNull
+    public String toString() {
+      return "ExitTransition{" + "fadeOut=" + getFadeOut() + ", slideOut=" + getSlideOut() + "}";
+    }
+
+    /** Builder for {@link ExitTransition} */
+    public static final class Builder {
+      private final ModifiersProto.ExitTransition.Builder mImpl =
+          ModifiersProto.ExitTransition.newBuilder();
+      private final Fingerprint mFingerprint = new Fingerprint(-99296494);
+
+      public Builder() {}
+
+      /**
+       * Sets the fading out animation for content transition of an element and its children
+       * happening when exiting the layout.
+       *
+       * @since 1.2
+       */
+      @NonNull
+      public Builder setFadeOut(@NonNull FadeOutTransition fadeOut) {
+        mImpl.setFadeOut(fadeOut.toProto());
+        mFingerprint.recordPropertyUpdate(
+            1, checkNotNull(fadeOut.getFingerprint()).aggregateValueAsInt());
+        return this;
+      }
+
+      /**
+       * Sets the sliding out animation for content transition of an element and its children
+       * happening when exiting the layout.
+       *
+       * @since 1.2
+       */
+      @NonNull
+      public Builder setSlideOut(@NonNull SlideOutTransition slideOut) {
+        mImpl.setSlideOut(slideOut.toProto());
+        mFingerprint.recordPropertyUpdate(
+            2, checkNotNull(slideOut.getFingerprint()).aggregateValueAsInt());
+        return this;
+      }
+
+      /** Builds an instance from accumulated values. */
+      @NonNull
+      public ExitTransition build() {
+        return new ExitTransition(mImpl.build(), mFingerprint);
+      }
+    }
+  }
+
+  /**
+   * The fading animation for content transition of an element and its children, from fully visible
+   * to the specified target alpha.
+   *
+   * @since 1.2
+   */
+  @ProtoLayoutExperimental
+  public static final class FadeOutTransition {
+    private final ModifiersProto.FadeOutTransition mImpl;
+    @Nullable private final Fingerprint mFingerprint;
+
+    FadeOutTransition(ModifiersProto.FadeOutTransition impl, @Nullable Fingerprint fingerprint) {
+      this.mImpl = impl;
+      this.mFingerprint = fingerprint;
+    }
+
+    /**
+     * Gets the target alpha of the fade out transition. It should be between 0 and 1. If not set,
+     * defaults to fully invisible, i.e. 0.
+     *
+     * @since 1.2
+     */
+    @FloatRange(from = 0.0, to = 1.0)
+    public float getTargetAlpha() {
+      return mImpl.getTargetAlpha();
+    }
+
+    /**
+     * Gets the animation parameters for duration, delay, etc.
+     *
+     * @since 1.2
+     */
+    @Nullable
+    public AnimationSpec getAnimationSpec() {
+      if (mImpl.hasAnimationSpec()) {
+        return AnimationSpec.fromProto(mImpl.getAnimationSpec());
+      } else {
+        return null;
+      }
+    }
+
+    /**
+     * Get the fingerprint for this object, or null if unknown.
+     *
+     * @hide
+     */
+    @RestrictTo(Scope.LIBRARY_GROUP)
+    @Nullable
+    public Fingerprint getFingerprint() {
+      return mFingerprint;
+    }
+    /**
+     * Creates a new wrapper instance from the proto.
+     *
+     * @hide
+     */
+    @RestrictTo(Scope.LIBRARY_GROUP)
+    @NonNull
+    public static FadeOutTransition fromProto(
+        @NonNull ModifiersProto.FadeOutTransition proto, @Nullable Fingerprint fingerprint) {
+      return new FadeOutTransition(proto, fingerprint);
+    }
+
+    @NonNull
+    static FadeOutTransition fromProto(@NonNull ModifiersProto.FadeOutTransition proto) {
+      return fromProto(proto, null);
+    }
+
+    /**
+     * Returns the internal proto instance.
+     *
+     * @hide
+     */
+    @RestrictTo(Scope.LIBRARY_GROUP)
+    @NonNull
+    public ModifiersProto.FadeOutTransition toProto() {
+      return mImpl;
+    }
+
+    @Override
+    @NonNull
+    public String toString() {
+      return "FadeOutTransition{"
+          + "targetAlpha="
+          + getTargetAlpha()
+          + ", animationSpec="
+          + getAnimationSpec()
+          + "}";
+    }
+
+    /** Builder for {@link FadeOutTransition} */
+    public static final class Builder {
+      private final ModifiersProto.FadeOutTransition.Builder mImpl =
+          ModifiersProto.FadeOutTransition.newBuilder();
+      private final Fingerprint mFingerprint = new Fingerprint(-545572295);
+
+      public Builder() {}
+
+      /**
+       * Sets the target alpha of the fade out transition. It should be between 0 and 1. If not set,
+       * defaults to fully invisible, i.e. 0.
+       *
+       * @since 1.2
+       */
+      @NonNull
+      public Builder setTargetAlpha(@FloatRange(from = 0.0, to = 1.0) float targetAlpha) {
+        mImpl.setTargetAlpha(targetAlpha);
+        mFingerprint.recordPropertyUpdate(1, Float.floatToIntBits(targetAlpha));
+        return this;
+      }
+
+      /**
+       * Sets the animation parameters for duration, delay, etc.
+       *
+       * @since 1.2
+       */
+      @NonNull
+      public Builder setAnimationSpec(@NonNull AnimationSpec animationSpec) {
+        mImpl.setAnimationSpec(animationSpec.toProto());
+        mFingerprint.recordPropertyUpdate(
+            2, checkNotNull(animationSpec.getFingerprint()).aggregateValueAsInt());
+        return this;
+      }
+
+      /** Builds an instance from accumulated values. */
+      @NonNull
+      public FadeOutTransition build() {
+        return new FadeOutTransition(mImpl.build(), mFingerprint);
+      }
+    }
+  }
+
+  /**
+   * The sliding out animation for content transition of an element and its children.
+   *
+   * @since 1.2
+   */
+  @ProtoLayoutExperimental
+  public static final class SlideOutTransition {
+    private final ModifiersProto.SlideOutTransition mImpl;
+    @Nullable private final Fingerprint mFingerprint;
+
+    SlideOutTransition(ModifiersProto.SlideOutTransition impl, @Nullable Fingerprint fingerprint) {
+      this.mImpl = impl;
+      this.mFingerprint = fingerprint;
+    }
+
+    /**
+     * Gets the slide direction used for slide animations on any element, from its destination in
+     * the layout to the specified point. If not set, defaults to horizontal from right to the left.
+     *
+     * @since 1.2
+     */
+    @SlideDirection
+    public int getDirection() {
+      return mImpl.getDirection().getNumber();
+    }
+
+    /**
+     * Gets the target offset for animation. By default the transition will end at the left parent
+     * boundary for horizontal orientation and at the top for vertical orientation. Note that
+     * sliding from the screen boundaries can only be achieved if all parent's sizes are big enough
+     * to accommodate it.
+     *
+     * @since 1.2
+     */
+    @Nullable
+    public SlideBound getTargetSlideBound() {
+      if (mImpl.hasTargetSlideBound()) {
+        return ModifiersBuilders.slideBoundFromProto(mImpl.getTargetSlideBound());
+      } else {
+        return null;
+      }
+    }
+
+    /**
+     * Gets the animation parameters for duration, delay, etc.
+     *
+     * @since 1.2
+     */
+    @Nullable
+    public AnimationSpec getAnimationSpec() {
+      if (mImpl.hasAnimationSpec()) {
+        return AnimationSpec.fromProto(mImpl.getAnimationSpec());
+      } else {
+        return null;
+      }
+    }
+
+    /**
+     * Get the fingerprint for this object, or null if unknown.
+     *
+     * @hide
+     */
+    @RestrictTo(Scope.LIBRARY_GROUP)
+    @Nullable
+    public Fingerprint getFingerprint() {
+      return mFingerprint;
+    }
+    /**
+     * Creates a new wrapper instance from the proto.
+     *
+     * @hide
+     */
+    @RestrictTo(Scope.LIBRARY_GROUP)
+    @NonNull
+    public static SlideOutTransition fromProto(
+        @NonNull ModifiersProto.SlideOutTransition proto, @Nullable Fingerprint fingerprint) {
+      return new SlideOutTransition(proto, fingerprint);
+    }
+
+    @NonNull
+    static SlideOutTransition fromProto(@NonNull ModifiersProto.SlideOutTransition proto) {
+      return fromProto(proto, null);
+    }
+
+    /**
+     * Returns the internal proto instance.
+     *
+     * @hide
+     */
+    @RestrictTo(Scope.LIBRARY_GROUP)
+    @NonNull
+    public ModifiersProto.SlideOutTransition toProto() {
+      return mImpl;
+    }
+
+    @Override
+    @NonNull
+    public String toString() {
+      return "SlideOutTransition{"
+          + "direction="
+          + getDirection()
+          + ", targetSlideBound="
+          + getTargetSlideBound()
+          + ", animationSpec="
+          + getAnimationSpec()
+          + "}";
+    }
+
+    /** Builder for {@link SlideOutTransition} */
+    public static final class Builder {
+      private final ModifiersProto.SlideOutTransition.Builder mImpl =
+          ModifiersProto.SlideOutTransition.newBuilder();
+      private final Fingerprint mFingerprint = new Fingerprint(3732844);
+
+      public Builder() {}
+
+      /**
+       * Sets the slide direction used for slide animations on any element, from its destination in
+       * the layout to the specified point. If not set, defaults to horizontal from right to the
+       * left.
+       *
+       * @since 1.2
+       */
+      @NonNull
+      public Builder setDirection(@SlideDirection int direction) {
+        mImpl.setDirection(ModifiersProto.SlideDirection.forNumber(direction));
+        mFingerprint.recordPropertyUpdate(1, direction);
+        return this;
+      }
+
+      /**
+       * Sets the target offset for animation. By default the transition will end at the left parent
+       * boundary for horizontal orientation and at the top for vertical orientation. Note that
+       * sliding from the screen boundaries can only be achieved if all parent's sizes are big
+       * enough to accommodate it.
+       *
+       * @since 1.2
+       */
+      @NonNull
+      public Builder setTargetSlideBound(@NonNull SlideBound targetSlideBound) {
+        mImpl.setTargetSlideBound(targetSlideBound.toSlideBoundProto());
+        mFingerprint.recordPropertyUpdate(
+            2, checkNotNull(targetSlideBound.getFingerprint()).aggregateValueAsInt());
+        return this;
+      }
+
+      /**
+       * Sets the animation parameters for duration, delay, etc.
+       *
+       * @since 1.2
+       */
+      @NonNull
+      public Builder setAnimationSpec(@NonNull AnimationSpec animationSpec) {
+        mImpl.setAnimationSpec(animationSpec.toProto());
+        mFingerprint.recordPropertyUpdate(
+            3, checkNotNull(animationSpec.getFingerprint()).aggregateValueAsInt());
+        return this;
+      }
+
+      /** Builds an instance from accumulated values. */
+      @NonNull
+      public SlideOutTransition build() {
+        return new SlideOutTransition(mImpl.build(), mFingerprint);
+      }
+    }
+  }
+
+  /**
+   * Interface defining the boundary that a Slide animation will use for start/end.
+   *
+   * @since 1.2
+   */
+  @ProtoLayoutExperimental
+  public interface SlideBound {
+    /**
+     * Get the protocol buffer representation of this object.
+     *
+     * @hide
+     */
+    @RestrictTo(Scope.LIBRARY_GROUP)
+    @NonNull
+    ModifiersProto.SlideBound toSlideBoundProto();
+
+    /**
+     * Get the fingerprint for this object or null if unknown.
+     *
+     * @hide
+     */
+    @RestrictTo(Scope.LIBRARY_GROUP)
+    @Nullable
+    Fingerprint getFingerprint();
+
+    /**
+     * Builder to create {@link SlideBound} objects.
+     *
+     * @hide
+     */
+    @RestrictTo(Scope.LIBRARY_GROUP)
+    interface Builder {
+
+      /** Builds an instance with values accumulated in this Builder. */
+      @NonNull
+      SlideBound build();
+    }
+  }
+
+  /**
+   * Creates a new wrapper instance from the proto.
+   *
+   * @hide
+   */
+  @RestrictTo(Scope.LIBRARY_GROUP)
+  @NonNull
+  @ProtoLayoutExperimental
+  public static SlideBound slideBoundFromProto(
+      @NonNull ModifiersProto.SlideBound proto, @Nullable Fingerprint fingerprint) {
+    if (proto.hasParentBound()) {
+      return SlideParentBound.fromProto(proto.getParentBound(), fingerprint);
+    }
+    throw new IllegalStateException("Proto was not a recognised instance of SlideBound");
+  }
+
+  @NonNull
+  @ProtoLayoutExperimental
+  static SlideBound slideBoundFromProto(@NonNull ModifiersProto.SlideBound proto) {
+    return slideBoundFromProto(proto, null);
+  }
+
+  /**
+   * The slide animation will animate from/to the parent elements boundaries.
+   *
+   * @since 1.2
+   */
+  @ProtoLayoutExperimental
+  public static final class SlideParentBound implements SlideBound {
+    private final ModifiersProto.SlideParentBound mImpl;
+    @Nullable private final Fingerprint mFingerprint;
+
+    SlideParentBound(ModifiersProto.SlideParentBound impl, @Nullable Fingerprint fingerprint) {
+      this.mImpl = impl;
+      this.mFingerprint = fingerprint;
+    }
+
+    /**
+     * Gets the snap options to use when sliding using parent boundaries. Defaults to
+     * SLIDE_PARENT_SNAP_TO_INSIDE if not specified.
+     *
+     * @since 1.2
+     */
+    @SlideParentSnapOption
+    public int getSnapTo() {
+      return mImpl.getSnapTo().getNumber();
+    }
+
+    /** @hide */
+    @Override
+    @RestrictTo(Scope.LIBRARY_GROUP)
+    @Nullable
+    public Fingerprint getFingerprint() {
+      return mFingerprint;
+    }
+    /**
+     * Creates a new wrapper instance from the proto.
+     *
+     * @hide
+     */
+    @RestrictTo(Scope.LIBRARY_GROUP)
+    @NonNull
+    public static SlideParentBound fromProto(
+        @NonNull ModifiersProto.SlideParentBound proto, @Nullable Fingerprint fingerprint) {
+      return new SlideParentBound(proto, fingerprint);
+    }
+
+    @NonNull
+    static SlideParentBound fromProto(@NonNull ModifiersProto.SlideParentBound proto) {
+      return fromProto(proto, null);
+    }
+
+    /**
+     * Returns the internal proto instance.
+     *
+     * @hide
+     */
+    @RestrictTo(Scope.LIBRARY_GROUP)
+    @NonNull
+    ModifiersProto.SlideParentBound toProto() {
+      return mImpl;
+    }
+
+    /** @hide */
+    @Override
+    @RestrictTo(Scope.LIBRARY_GROUP)
+    @NonNull
+    @ProtoLayoutExperimental
+    public ModifiersProto.SlideBound toSlideBoundProto() {
+      return ModifiersProto.SlideBound.newBuilder().setParentBound(mImpl).build();
+    }
+
+    @Override
+    @NonNull
+    public String toString() {
+      return "SlideParentBound{" + "snapTo=" + getSnapTo() + "}";
+    }
+
+    /** Builder for {@link SlideParentBound}. */
+    public static final class Builder implements SlideBound.Builder {
+      private final ModifiersProto.SlideParentBound.Builder mImpl =
+          ModifiersProto.SlideParentBound.newBuilder();
+      private final Fingerprint mFingerprint = new Fingerprint(-516388675);
+
+      public Builder() {}
+
+      /**
+       * Sets the snap options to use when sliding using parent boundaries. Defaults to
+       * SLIDE_PARENT_SNAP_TO_INSIDE if not specified.
+       *
+       * @since 1.2
+       */
+      @NonNull
+      public Builder setSnapTo(@SlideParentSnapOption int snapTo) {
+        mImpl.setSnapTo(ModifiersProto.SlideParentSnapOption.forNumber(snapTo));
+        mFingerprint.recordPropertyUpdate(1, snapTo);
+        return this;
+      }
+
+      @Override
+      @NonNull
+      public SlideParentBound build() {
+        return new SlideParentBound(mImpl.build(), mFingerprint);
+      }
+    }
+  }
+
+  /**
    * {@link Modifiers} that can be used with ArcLayoutElements. These may change the way they are
    * drawn, or change their behaviour.
    */
diff --git a/wear/protolayout/protolayout/src/main/java/androidx/wear/protolayout/ResourceBuilders.java b/wear/protolayout/protolayout/src/main/java/androidx/wear/protolayout/ResourceBuilders.java
index 76dbb60..071b698 100644
--- a/wear/protolayout/protolayout/src/main/java/androidx/wear/protolayout/ResourceBuilders.java
+++ b/wear/protolayout/protolayout/src/main/java/androidx/wear/protolayout/ResourceBuilders.java
@@ -35,6 +35,7 @@
 
 import java.lang.annotation.Retention;
 import java.lang.annotation.RetentionPolicy;
+import java.util.Arrays;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.Map;
@@ -117,18 +118,35 @@
         public int getResourceId() {
             return mImpl.getResourceId();
         }
-
+        /**
+         * Creates a new wrapper instance from the proto.
+         *
+         * @hide
+         */
+        @RestrictTo(Scope.LIBRARY_GROUP)
         @NonNull
-        static AndroidImageResourceByResId fromProto(
+        public static AndroidImageResourceByResId fromProto(
                 @NonNull ResourceProto.AndroidImageResourceByResId proto) {
             return new AndroidImageResourceByResId(proto);
         }
 
+        /**
+         * Returns the internal proto instance.
+         *
+         * @hide
+         */
+        @RestrictTo(Scope.LIBRARY_GROUP)
         @NonNull
-        ResourceProto.AndroidImageResourceByResId toProto() {
+        public ResourceProto.AndroidImageResourceByResId toProto() {
             return mImpl;
         }
 
+        @Override
+        @NonNull
+        public String toString() {
+            return "AndroidImageResourceByResId{" + "resourceId=" + getResourceId() + "}";
+        }
+
         /** Builder for {@link AndroidImageResourceByResId} */
         public static final class Builder {
             private final ResourceProto.AndroidImageResourceByResId.Builder mImpl =
@@ -213,17 +231,44 @@
         public int getFormat() {
             return mImpl.getFormat().getNumber();
         }
-
+        /**
+         * Creates a new wrapper instance from the proto.
+         *
+         * @hide
+         */
+        @RestrictTo(Scope.LIBRARY_GROUP)
         @NonNull
-        static InlineImageResource fromProto(@NonNull ResourceProto.InlineImageResource proto) {
+        public static InlineImageResource fromProto(
+                @NonNull ResourceProto.InlineImageResource proto) {
             return new InlineImageResource(proto);
         }
 
+        /**
+         * Returns the internal proto instance.
+         *
+         * @hide
+         */
+        @RestrictTo(Scope.LIBRARY_GROUP)
         @NonNull
-        ResourceProto.InlineImageResource toProto() {
+        public ResourceProto.InlineImageResource toProto() {
             return mImpl;
         }
 
+        @Override
+        @NonNull
+        public String toString() {
+            return "InlineImageResource{"
+                    + "data="
+                    + Arrays.toString(getData())
+                    + ", widthPx="
+                    + getWidthPx()
+                    + ", heightPx="
+                    + getHeightPx()
+                    + ", format="
+                    + getFormat()
+                    + "}";
+        }
+
         /** Builder for {@link InlineImageResource} */
         public static final class Builder {
             private final ResourceProto.InlineImageResource.Builder mImpl =
@@ -309,7 +354,7 @@
          */
         @AnimatedImageFormat
         public int getAnimatedImageFormat() {
-            return mImpl.getFormat().getNumber();
+            return mImpl.getAnimatedImageFormat().getNumber();
         }
 
         /**
@@ -329,24 +374,48 @@
          */
         @Nullable
         public Trigger getStartTrigger() {
-            if (mImpl.hasTrigger()) {
-                return TriggerBuilders.triggerFromProto(mImpl.getTrigger());
+            if (mImpl.hasStartTrigger()) {
+                return TriggerBuilders.triggerFromProto(mImpl.getStartTrigger());
             } else {
                 return null;
             }
         }
-
+        /**
+         * Creates a new wrapper instance from the proto.
+         *
+         * @hide
+         */
+        @RestrictTo(Scope.LIBRARY_GROUP)
         @NonNull
-        static AndroidAnimatedImageResourceByResId fromProto(
+        public static AndroidAnimatedImageResourceByResId fromProto(
                 @NonNull ResourceProto.AndroidAnimatedImageResourceByResId proto) {
             return new AndroidAnimatedImageResourceByResId(proto);
         }
 
+        /**
+         * Returns the internal proto instance.
+         *
+         * @hide
+         */
+        @RestrictTo(Scope.LIBRARY_GROUP)
         @NonNull
-        ResourceProto.AndroidAnimatedImageResourceByResId toProto() {
+        public ResourceProto.AndroidAnimatedImageResourceByResId toProto() {
             return mImpl;
         }
 
+        @Override
+        @NonNull
+        public String toString() {
+            return "AndroidAnimatedImageResourceByResId{"
+                    + "animatedImageFormat="
+                    + getAnimatedImageFormat()
+                    + ", resourceId="
+                    + getResourceId()
+                    + ", startTrigger="
+                    + getStartTrigger()
+                    + "}";
+        }
+
         /** Builder for {@link AndroidAnimatedImageResourceByResId} */
         public static final class Builder {
             private final ResourceProto.AndroidAnimatedImageResourceByResId.Builder mImpl =
@@ -360,8 +429,9 @@
              * @since 1.2
              */
             @NonNull
-            public Builder setAnimatedImageFormat(@AnimatedImageFormat int format) {
-                mImpl.setFormat(ResourceProto.AnimatedImageFormat.forNumber(format));
+            public Builder setAnimatedImageFormat(@AnimatedImageFormat int animatedImageFormat) {
+                mImpl.setAnimatedImageFormat(
+                        ResourceProto.AnimatedImageFormat.forNumber(animatedImageFormat));
                 return this;
             }
 
@@ -382,8 +452,8 @@
              * @since 1.2
              */
             @NonNull
-            public Builder setStartTrigger(@NonNull Trigger trigger) {
-                mImpl.setTrigger(trigger.toTriggerProto());
+            public Builder setStartTrigger(@NonNull Trigger startTrigger) {
+                mImpl.setStartTrigger(startTrigger.toTriggerProto());
                 return this;
             }
 
@@ -416,7 +486,7 @@
          */
         @AnimatedImageFormat
         public int getAnimatedImageFormat() {
-            return mImpl.getFormat().getNumber();
+            return mImpl.getAnimatedImageFormat().getNumber();
         }
 
         /**
@@ -430,13 +500,14 @@
         }
 
         /**
-         * Gets a {@link DynamicFloat}, normally transformed from certain states with the data
-         * binding pipeline to control the progress of the animation. Its value is required to fall
-         * in the range of [0.0, 1.0]. Any values outside this range would be clamped. When the
-         * first value of the {@link DynamicFloat} arrives, the animation starts from progress 0 to
-         * that value. After that it plays from current progress to the new value on subsequent
-         * updates.
-         * If not set, the animation will play on load (similar to a non-seekable animated).
+         * Gets a {@link androidx.wear.protolayout.expression.DynamicBuilders.DynamicFloat},
+         * normally transformed from certain states with the data binding pipeline to control the
+         * progress of the animation. Its value is required to fall in the range of [0.0, 1.0]. Any
+         * values outside this range would be clamped. When the first value of the {@link
+         * androidx.wear.protolayout.expression.DynamicBuilders.DynamicFloat} arrives, the animation
+         * starts from progress 0 to that value. After that it plays from current progress to the
+         * new value on subsequent updates. If not set, the animation will play on load (similar to
+         * a non-seekable animated).
          *
          * @since 1.2
          */
@@ -448,18 +519,42 @@
                 return null;
             }
         }
-
+        /**
+         * Creates a new wrapper instance from the proto.
+         *
+         * @hide
+         */
+        @RestrictTo(Scope.LIBRARY_GROUP)
         @NonNull
-        static AndroidSeekableAnimatedImageResourceByResId fromProto(
+        public static AndroidSeekableAnimatedImageResourceByResId fromProto(
                 @NonNull ResourceProto.AndroidSeekableAnimatedImageResourceByResId proto) {
             return new AndroidSeekableAnimatedImageResourceByResId(proto);
         }
 
+        /**
+         * Returns the internal proto instance.
+         *
+         * @hide
+         */
+        @RestrictTo(Scope.LIBRARY_GROUP)
         @NonNull
-        ResourceProto.AndroidSeekableAnimatedImageResourceByResId toProto() {
+        public ResourceProto.AndroidSeekableAnimatedImageResourceByResId toProto() {
             return mImpl;
         }
 
+        @Override
+        @NonNull
+        public String toString() {
+            return "AndroidSeekableAnimatedImageResourceByResId{"
+                    + "animatedImageFormat="
+                    + getAnimatedImageFormat()
+                    + ", resourceId="
+                    + getResourceId()
+                    + ", progress="
+                    + getProgress()
+                    + "}";
+        }
+
         /** Builder for {@link AndroidSeekableAnimatedImageResourceByResId} */
         public static final class Builder {
             private final ResourceProto.AndroidSeekableAnimatedImageResourceByResId.Builder mImpl =
@@ -473,8 +568,9 @@
              * @since 1.2
              */
             @NonNull
-            public Builder setAnimatedImageFormat(@AnimatedImageFormat int format) {
-                mImpl.setFormat(ResourceProto.AnimatedImageFormat.forNumber(format));
+            public Builder setAnimatedImageFormat(@AnimatedImageFormat int animatedImageFormat) {
+                mImpl.setAnimatedImageFormat(
+                        ResourceProto.AnimatedImageFormat.forNumber(animatedImageFormat));
                 return this;
             }
 
@@ -490,13 +586,14 @@
             }
 
             /**
-             * Sets a {@link DynamicFloat}, normally transformed from certain states with the data
-             * binding pipeline to control the progress of the animation. Its value is required to
-             * fall in the range of [0.0, 1.0]. Any values outside this range would be clamped. When
-             * the first value of the {@link DynamicFloat} arrives, the animation starts from
-             * progress 0 to that value. After that it plays from current progress to the new value
-             * on subsequent updates.
-             * If not set, the animation will play on load (similar to a non-seekable animated).
+             * Sets a {@link androidx.wear.protolayout.expression.DynamicBuilders.DynamicFloat},
+             * normally transformed from certain states with the data binding pipeline to control
+             * the progress of the animation. Its value is required to fall in the range of [0.0,
+             * 1.0]. Any values outside this range would be clamped. When the first value of the
+             * {@link androidx.wear.protolayout.expression.DynamicBuilders.DynamicFloat} arrives,
+             * the animation starts from progress 0 to that value. After that it plays from current
+             * progress to the new value on subsequent updates. If not set, the animation will play
+             * on load (similar to a non-seekable animated).
              *
              * @since 1.2
              */
@@ -587,17 +684,43 @@
                 return null;
             }
         }
-
+        /**
+         * Creates a new wrapper instance from the proto.
+         *
+         * @hide
+         */
+        @RestrictTo(Scope.LIBRARY_GROUP)
         @NonNull
-        static ImageResource fromProto(@NonNull ResourceProto.ImageResource proto) {
+        public static ImageResource fromProto(@NonNull ResourceProto.ImageResource proto) {
             return new ImageResource(proto);
         }
 
+        /**
+         * Returns the internal proto instance.
+         *
+         * @hide
+         */
+        @RestrictTo(Scope.LIBRARY_GROUP)
         @NonNull
-        ResourceProto.ImageResource toProto() {
+        public ResourceProto.ImageResource toProto() {
             return mImpl;
         }
 
+        @Override
+        @NonNull
+        public String toString() {
+            return "ImageResource{"
+                    + "androidResourceByResId="
+                    + getAndroidResourceByResId()
+                    + ", inlineResource="
+                    + getInlineResource()
+                    + ", androidAnimatedResourceByResId="
+                    + getAndroidAnimatedResourceByResId()
+                    + ", androidSeekableAnimatedResourceByResId="
+                    + getAndroidSeekableAnimatedResourceByResId()
+                    + "}";
+        }
+
         /** Builder for {@link ImageResource} */
         public static final class Builder {
             private final ResourceProto.ImageResource.Builder mImpl =
@@ -710,8 +833,14 @@
             return Collections.unmodifiableMap(map);
         }
 
+        /**
+         * Creates a new wrapper instance from the proto.
+         *
+         * @hide
+         */
+        @RestrictTo(Scope.LIBRARY_GROUP)
         @NonNull
-        static Resources fromProto(@NonNull ResourceProto.Resources proto) {
+        public static Resources fromProto(@NonNull ResourceProto.Resources proto) {
             return new Resources(proto);
         }
 
@@ -726,6 +855,17 @@
             return mImpl;
         }
 
+        @Override
+        @NonNull
+        public String toString() {
+            return "Resources{"
+                    + "version="
+                    + getVersion()
+                    + ", idToImageMapping="
+                    + getIdToImageMapping()
+                    + "}";
+        }
+
         /** Builder for {@link Resources} */
         public static final class Builder {
             private final ResourceProto.Resources.Builder mImpl =
diff --git a/wear/protolayout/protolayout/src/main/java/androidx/wear/protolayout/TriggerBuilders.java b/wear/protolayout/protolayout/src/main/java/androidx/wear/protolayout/TriggerBuilders.java
index 675364c..959a62e 100644
--- a/wear/protolayout/protolayout/src/main/java/androidx/wear/protolayout/TriggerBuilders.java
+++ b/wear/protolayout/protolayout/src/main/java/androidx/wear/protolayout/TriggerBuilders.java
@@ -18,8 +18,6 @@
 
 import static androidx.wear.protolayout.expression.Preconditions.checkNotNull;
 
-import android.annotation.SuppressLint;
-
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import androidx.annotation.RestrictTo;
@@ -109,10 +107,10 @@
    * @since 1.2
    */
   public static final class OnConditionMetTrigger implements Trigger {
-    private final TriggerProto.OnConditionTrigger mImpl;
+    private final TriggerProto.OnConditionMetTrigger mImpl;
     @Nullable private final Fingerprint mFingerprint;
 
-    OnConditionMetTrigger(TriggerProto.OnConditionTrigger impl, @Nullable Fingerprint fingerprint) {
+    OnConditionMetTrigger(TriggerProto.OnConditionMetTrigger impl, @Nullable Fingerprint fingerprint) {
       this.mImpl = impl;
       this.mFingerprint = fingerprint;
     }
@@ -124,8 +122,8 @@
      */
     @Nullable
     public DynamicBool getTrigger() {
-      if (mImpl.hasDynamicBool()) {
-        return DynamicBuilders.dynamicBoolFromProto(mImpl.getDynamicBool());
+      if (mImpl.hasTrigger()) {
+        return DynamicBuilders.dynamicBoolFromProto(mImpl.getTrigger());
       } else {
         return null;
       }
@@ -140,12 +138,12 @@
     }
 
     @NonNull
-    static OnConditionMetTrigger fromProto(@NonNull TriggerProto.OnConditionTrigger proto) {
+    static OnConditionMetTrigger fromProto(@NonNull TriggerProto.OnConditionMetTrigger proto) {
       return new OnConditionMetTrigger(proto, null);
     }
 
     @NonNull
-    TriggerProto.OnConditionTrigger toProto() {
+    TriggerProto.OnConditionMetTrigger toProto() {
       return mImpl;
     }
 
@@ -154,13 +152,13 @@
     @RestrictTo(Scope.LIBRARY_GROUP)
     @NonNull
       public TriggerProto.Trigger toTriggerProto() {
-      return TriggerProto.Trigger.newBuilder().setOnConditionTrigger(mImpl).build();
+      return TriggerProto.Trigger.newBuilder().setOnConditionMetTrigger(mImpl).build();
     }
 
     /** Builder for {@link OnConditionMetTrigger}. */
     public static final class Builder implements Trigger.Builder {
-      private final TriggerProto.OnConditionTrigger.Builder mImpl =
-          TriggerProto.OnConditionTrigger.newBuilder();
+      private final TriggerProto.OnConditionMetTrigger.Builder mImpl =
+          TriggerProto.OnConditionMetTrigger.newBuilder();
       private final Fingerprint mFingerprint = new Fingerprint(1952746052);
 
       public Builder() {}
@@ -172,7 +170,7 @@
        */
       @NonNull
       public Builder setTrigger(@NonNull DynamicBool dynamicBool) {
-        mImpl.setDynamicBool(dynamicBool.toDynamicBoolProto());
+        mImpl.setTrigger(dynamicBool.toDynamicBoolProto());
         mFingerprint.recordPropertyUpdate(
             1, checkNotNull(dynamicBool.getFingerprint()).aggregateValueAsInt());
         return this;
@@ -229,8 +227,8 @@
     if (proto.hasOnLoadTrigger()) {
       return OnLoadTrigger.fromProto(proto.getOnLoadTrigger());
     }
-    if (proto.hasOnConditionTrigger()) {
-      return OnConditionMetTrigger.fromProto(proto.getOnConditionTrigger());
+    if (proto.hasOnConditionMetTrigger()) {
+      return OnConditionMetTrigger.fromProto(proto.getOnConditionMetTrigger());
     }
     throw new IllegalStateException("Proto was not a recognised instance of Trigger");
   }
diff --git a/wear/protolayout/protolayout/src/test/java/androidx/wear/protolayout/ResourceBuildersTest.java b/wear/protolayout/protolayout/src/test/java/androidx/wear/protolayout/ResourceBuildersTest.java
index 59f2df8..2b6348d 100644
--- a/wear/protolayout/protolayout/src/test/java/androidx/wear/protolayout/ResourceBuildersTest.java
+++ b/wear/protolayout/protolayout/src/test/java/androidx/wear/protolayout/ResourceBuildersTest.java
@@ -44,8 +44,8 @@
         ResourceProto.AndroidAnimatedImageResourceByResId avdProto = avd.toProto();
 
         assertThat(avdProto.getResourceId()).isEqualTo(RESOURCE_ID);
-        assertThat(avdProto.getFormat().getNumber()).isEqualTo(FORMAT);
-        assertThat(avdProto.getTrigger().hasOnLoadTrigger()).isTrue();
+        assertThat(avdProto.getAnimatedImageFormat().getNumber()).isEqualTo(FORMAT);
+        assertThat(avdProto.getStartTrigger().hasOnLoadTrigger()).isTrue();
     }
 
     @Test
@@ -61,7 +61,7 @@
         ResourceProto.AndroidSeekableAnimatedImageResourceByResId avdProto = avd.toProto();
 
         assertThat(avdProto.getResourceId()).isEqualTo(RESOURCE_ID);
-        assertThat(avdProto.getFormat().getNumber()).isEqualTo(FORMAT);
+        assertThat(avdProto.getAnimatedImageFormat().getNumber()).isEqualTo(FORMAT);
         assertThat(avdProto.getProgress().getStateSource().getSourceKey()).isEqualTo(stateKey);
     }
 }
diff --git a/wear/protolayout/protolayout/src/test/java/androidx/wear/protolayout/TriggerBuildersTest.java b/wear/protolayout/protolayout/src/test/java/androidx/wear/protolayout/TriggerBuildersTest.java
index 5b3260e..cf36e4f 100644
--- a/wear/protolayout/protolayout/src/test/java/androidx/wear/protolayout/TriggerBuildersTest.java
+++ b/wear/protolayout/protolayout/src/test/java/androidx/wear/protolayout/TriggerBuildersTest.java
@@ -44,7 +44,7 @@
                 condition);
 
         assertThat(
-                onConditionMetTrigger.toTriggerProto().getOnConditionTrigger().getDynamicBool())
+                onConditionMetTrigger.toTriggerProto().getOnConditionMetTrigger().getTrigger())
                 .isEqualTo(condition.toDynamicBoolProto());
     }
 }
diff --git a/wear/tiles/tiles/src/main/java/androidx/wear/tiles/EventBuilders.java b/wear/tiles/tiles/src/main/java/androidx/wear/tiles/EventBuilders.java
index 17e5741..71b74da 100644
--- a/wear/tiles/tiles/src/main/java/androidx/wear/tiles/EventBuilders.java
+++ b/wear/tiles/tiles/src/main/java/androidx/wear/tiles/EventBuilders.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2021 The Android Open Source Project
+ * Copyright 2022 The Android Open Source Project
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -25,28 +25,46 @@
 public final class EventBuilders {
     private EventBuilders() {}
 
-    /** Event fired when a tile has been added to the carousel. */
+    /**
+     * Event fired when a tile has been added to the carousel.
+     *
+     * @since 1.0
+     */
     public static final class TileAddEvent {
         private final EventProto.TileAddEvent mImpl;
 
-        private TileAddEvent(EventProto.TileAddEvent impl) {
+        TileAddEvent(EventProto.TileAddEvent impl) {
             this.mImpl = impl;
         }
 
-        /** @hide */
+        /**
+         * Creates a new wrapper instance from the proto.
+         *
+         * @hide
+         */
         @RestrictTo(Scope.LIBRARY_GROUP)
         @NonNull
         public static TileAddEvent fromProto(@NonNull EventProto.TileAddEvent proto) {
             return new TileAddEvent(proto);
         }
 
-        /** @hide */
+        /**
+         * Returns the internal proto instance.
+         *
+         * @hide
+         */
         @RestrictTo(Scope.LIBRARY_GROUP)
         @NonNull
         public EventProto.TileAddEvent toProto() {
             return mImpl;
         }
 
+        @Override
+        @NonNull
+        public String toString() {
+            return "TileAddEvent{" + "}";
+        }
+
         /** Builder for {@link TileAddEvent} */
         public static final class Builder {
             private final EventProto.TileAddEvent.Builder mImpl =
@@ -62,28 +80,46 @@
         }
     }
 
-    /** Event fired when a tile has been removed from the carousel. */
+    /**
+     * Event fired when a tile has been removed from the carousel.
+     *
+     * @since 1.0
+     */
     public static final class TileRemoveEvent {
         private final EventProto.TileRemoveEvent mImpl;
 
-        private TileRemoveEvent(EventProto.TileRemoveEvent impl) {
+        TileRemoveEvent(EventProto.TileRemoveEvent impl) {
             this.mImpl = impl;
         }
 
-        /** @hide */
+        /**
+         * Creates a new wrapper instance from the proto.
+         *
+         * @hide
+         */
         @RestrictTo(Scope.LIBRARY_GROUP)
         @NonNull
         public static TileRemoveEvent fromProto(@NonNull EventProto.TileRemoveEvent proto) {
             return new TileRemoveEvent(proto);
         }
 
-        /** @hide */
+        /**
+         * Returns the internal proto instance.
+         *
+         * @hide
+         */
         @RestrictTo(Scope.LIBRARY_GROUP)
         @NonNull
         public EventProto.TileRemoveEvent toProto() {
             return mImpl;
         }
 
+        @Override
+        @NonNull
+        public String toString() {
+            return "TileRemoveEvent{" + "}";
+        }
+
         /** Builder for {@link TileRemoveEvent} */
         public static final class Builder {
             private final EventProto.TileRemoveEvent.Builder mImpl =
@@ -99,28 +135,46 @@
         }
     }
 
-    /** Event fired when a tile is swiped to by the user (i.e. it's visible on screen). */
+    /**
+     * Event fired when a tile is swiped to by the user (i.e. it's visible on screen).
+     *
+     * @since 1.0
+     */
     public static final class TileEnterEvent {
         private final EventProto.TileEnterEvent mImpl;
 
-        private TileEnterEvent(EventProto.TileEnterEvent impl) {
+        TileEnterEvent(EventProto.TileEnterEvent impl) {
             this.mImpl = impl;
         }
 
-        /** @hide */
+        /**
+         * Creates a new wrapper instance from the proto.
+         *
+         * @hide
+         */
         @RestrictTo(Scope.LIBRARY_GROUP)
         @NonNull
         public static TileEnterEvent fromProto(@NonNull EventProto.TileEnterEvent proto) {
             return new TileEnterEvent(proto);
         }
 
-        /** @hide */
+        /**
+         * Returns the internal proto instance.
+         *
+         * @hide
+         */
         @RestrictTo(Scope.LIBRARY_GROUP)
         @NonNull
         public EventProto.TileEnterEvent toProto() {
             return mImpl;
         }
 
+        @Override
+        @NonNull
+        public String toString() {
+            return "TileEnterEvent{" + "}";
+        }
+
         /** Builder for {@link TileEnterEvent} */
         public static final class Builder {
             private final EventProto.TileEnterEvent.Builder mImpl =
@@ -139,28 +193,44 @@
     /**
      * Event fired when a tile is swiped away from by the user (i.e. it's no longer visible on
      * screen).
+     *
+     * @since 1.0
      */
     public static final class TileLeaveEvent {
         private final EventProto.TileLeaveEvent mImpl;
 
-        private TileLeaveEvent(EventProto.TileLeaveEvent impl) {
+        TileLeaveEvent(EventProto.TileLeaveEvent impl) {
             this.mImpl = impl;
         }
 
-        /** @hide */
+        /**
+         * Creates a new wrapper instance from the proto.
+         *
+         * @hide
+         */
         @RestrictTo(Scope.LIBRARY_GROUP)
         @NonNull
         public static TileLeaveEvent fromProto(@NonNull EventProto.TileLeaveEvent proto) {
             return new TileLeaveEvent(proto);
         }
 
-        /** @hide */
+        /**
+         * Returns the internal proto instance.
+         *
+         * @hide
+         */
         @RestrictTo(Scope.LIBRARY_GROUP)
         @NonNull
         public EventProto.TileLeaveEvent toProto() {
             return mImpl;
         }
 
+        @Override
+        @NonNull
+        public String toString() {
+            return "TileLeaveEvent{" + "}";
+        }
+
         /** Builder for {@link TileLeaveEvent} */
         public static final class Builder {
             private final EventProto.TileLeaveEvent.Builder mImpl =
diff --git a/wear/tiles/tiles/src/main/java/androidx/wear/tiles/ResourceBuilders.java b/wear/tiles/tiles/src/main/java/androidx/wear/tiles/ResourceBuilders.java
index b062133..03390ef 100644
--- a/wear/tiles/tiles/src/main/java/androidx/wear/tiles/ResourceBuilders.java
+++ b/wear/tiles/tiles/src/main/java/androidx/wear/tiles/ResourceBuilders.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2021 The Android Open Source Project
+ * Copyright 2021-2022 The Android Open Source Project
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -18,8 +18,6 @@
 
 import static androidx.annotation.Dimension.PX;
 
-import static java.util.stream.Collectors.toMap;
-
 import android.annotation.SuppressLint;
 
 import androidx.annotation.Dimension;
@@ -36,7 +34,9 @@
 import java.lang.annotation.Retention;
 import java.lang.annotation.RetentionPolicy;
 import java.util.Collections;
+import java.util.HashMap;
 import java.util.Map;
+import java.util.Map.Entry;
 
 /** Builders for the resources for a layout. */
 public final class ResourceBuilders {
@@ -347,12 +347,31 @@
          */
         @NonNull
         public Map<String, ImageResource> getIdToImageMapping() {
-            return Collections.unmodifiableMap(
-                    mImpl.getIdToImageMap().entrySet().stream()
-                            .collect(
-                                    toMap(
-                                            Map.Entry::getKey,
-                                            f -> ImageResource.fromProto(f.getValue()))));
+            Map<String, ImageResource> map = new HashMap<>();
+            for (Entry<String, ResourceProto.ImageResource> entry :
+                    mImpl.getIdToImageMap().entrySet()) {
+                map.put(entry.getKey(), ImageResource.fromProto(entry.getValue()));
+            }
+            return Collections.unmodifiableMap(map);
+        }
+
+        /** Converts to byte array representation. */
+        @NonNull
+        @TilesExperimental
+        public byte[] toByteArray() {
+            return mImpl.toByteArray();
+        }
+
+        /** Converts from byte array representation. */
+        @SuppressWarnings("ProtoParseWithRegistry")
+        @Nullable
+        @TilesExperimental
+        public static Resources fromByteArray(@NonNull byte[] byteArray) {
+            try {
+                return fromProto(ResourceProto.Resources.parseFrom(byteArray));
+            } catch (InvalidProtocolBufferException e) {
+                return null;
+            }
         }
 
         /** @hide */
@@ -369,24 +388,6 @@
             return mImpl;
         }
 
-        /** Converts to byte array representation. */
-        @TilesExperimental
-        @NonNull
-        public byte[] toByteArray() {
-            return mImpl.toByteArray();
-        }
-
-        /** Converts from byte array representation. */
-        @TilesExperimental
-        @Nullable
-        public static Resources fromByteArray(@NonNull byte[] byteArray) {
-            try {
-                return fromProto(ResourceProto.Resources.parseFrom(byteArray));
-            } catch (InvalidProtocolBufferException e) {
-                return null;
-            }
-        }
-
         /** Builder for {@link Resources} */
         public static final class Builder {
             private final ResourceProto.Resources.Builder mImpl =
diff --git a/wear/tiles/tiles/src/main/java/androidx/wear/tiles/TileAddEventData.java b/wear/tiles/tiles/src/main/java/androidx/wear/tiles/TileAddEventData.java
index feef838..f296d5b 100644
--- a/wear/tiles/tiles/src/main/java/androidx/wear/tiles/TileAddEventData.java
+++ b/wear/tiles/tiles/src/main/java/androidx/wear/tiles/TileAddEventData.java
@@ -20,7 +20,7 @@
 import androidx.annotation.RestrictTo;
 
 /**
- * Holder for Tiles' TileAddEvent class, to be parceled and transferred to a tile service.
+ * Holder for Tiles' TileAddEvent class, to be parceled and transferred to a Tile Service.
  *
  * <p>All this does is to serialize TileAddEvent as a protobuf and transmit it.
  *
diff --git a/wear/tiles/tiles/src/main/java/androidx/wear/tiles/TileEnterEventData.java b/wear/tiles/tiles/src/main/java/androidx/wear/tiles/TileEnterEventData.java
index 4dae195..fda2841 100644
--- a/wear/tiles/tiles/src/main/java/androidx/wear/tiles/TileEnterEventData.java
+++ b/wear/tiles/tiles/src/main/java/androidx/wear/tiles/TileEnterEventData.java
@@ -20,7 +20,7 @@
 import androidx.annotation.RestrictTo;
 
 /**
- * Holder for Tiles' TileEnterEvent class, to be parceled and transferred to a tile service.
+ * Holder for Tiles' TileEnterEvent class, to be parceled and transferred to a Tile Service.
  *
  * <p>All this does is to serialize TileEnterEvent as a protobuf and transmit it.
  *
diff --git a/wear/tiles/tiles/src/main/java/androidx/wear/tiles/TileRemoveEventData.java b/wear/tiles/tiles/src/main/java/androidx/wear/tiles/TileRemoveEventData.java
index 921a74b..9bccf59 100644
--- a/wear/tiles/tiles/src/main/java/androidx/wear/tiles/TileRemoveEventData.java
+++ b/wear/tiles/tiles/src/main/java/androidx/wear/tiles/TileRemoveEventData.java
@@ -20,7 +20,7 @@
 import androidx.annotation.RestrictTo;
 
 /**
- * Holder for Tiles' TileRemoveEvent class, to be parceled and transferred to a tile service.
+ * Holder for Tiles' TileRemoveEvent class, to be parceled and transferred to a Tile Service.
  *
  * <p>All this does is to serialize TileRemoveEvent as a protobuf and transmit it.
  *
diff --git a/wear/watchface/watchface-complications-data/src/main/java/androidx/wear/watchface/complications/data/ComplicationDataExpressionEvaluator.kt b/wear/watchface/watchface-complications-data/src/main/java/androidx/wear/watchface/complications/data/ComplicationDataExpressionEvaluator.kt
index 278ec35..60cf83e 100644
--- a/wear/watchface/watchface-complications-data/src/main/java/androidx/wear/watchface/complications/data/ComplicationDataExpressionEvaluator.kt
+++ b/wear/watchface/watchface-complications-data/src/main/java/androidx/wear/watchface/complications/data/ComplicationDataExpressionEvaluator.kt
@@ -28,9 +28,13 @@
 import androidx.wear.protolayout.expression.pipeline.ObservableStateStore
 import androidx.wear.protolayout.expression.pipeline.sensor.SensorGateway
 import java.util.concurrent.Executor
+import kotlin.coroutines.ContinuationInterceptor
+import kotlin.coroutines.CoroutineContext
+import kotlinx.coroutines.CoroutineDispatcher
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.asCoroutineDispatcher
+import kotlinx.coroutines.asExecutor
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.StateFlow
 import kotlinx.coroutines.flow.asStateFlow
@@ -74,7 +78,7 @@
          *   `coroutineScope`.
          */
         fun init(executor: Executor) {
-            evaluator.init()
+            evaluator.init(CoroutineScope(executor.asCoroutineDispatcher()))
             evaluator.data
                 .filterNotNull()
                 .onEach(listener::accept)
@@ -109,7 +113,7 @@
     fun init(coroutineScope: CoroutineScope = CoroutineScope(Dispatchers.Main)) {
         // Add all the receivers before we start binding them because binding can synchronously
         // trigger the receiver, which would update the data before all the fields are evaluated.
-        initStateReceivers()
+        initStateReceivers(coroutineScope)
         initEvaluator()
         monitorState(coroutineScope)
     }
@@ -125,29 +129,37 @@
     }
 
     /** Adds [ComplicationEvaluationResultReceiver]s to [state]. */
-    private fun initStateReceivers() {
+    private fun initStateReceivers(coroutineScope: CoroutineScope) {
         val receivers = mutableSetOf<ComplicationEvaluationResultReceiver<out Any>>()
 
         if (unevaluatedData.hasRangedValueExpression()) {
             unevaluatedData.rangedValueExpression
-                ?.buildReceiver { setRangedValue(it) }
+                ?.buildReceiver(coroutineScope) { setRangedValue(it) }
                 ?.let { receivers += it }
         }
         if (unevaluatedData.hasLongText()) {
-            unevaluatedData.longText?.buildReceiver { setLongText(it) }?.let { receivers += it }
+            unevaluatedData.longText
+                ?.buildReceiver(coroutineScope) { setLongText(it) }
+                ?.let { receivers += it }
         }
         if (unevaluatedData.hasLongTitle()) {
-            unevaluatedData.longTitle?.buildReceiver { setLongTitle(it) }?.let { receivers += it }
+            unevaluatedData.longTitle
+                ?.buildReceiver(coroutineScope) { setLongTitle(it) }
+                ?.let { receivers += it }
         }
         if (unevaluatedData.hasShortText()) {
-            unevaluatedData.shortText?.buildReceiver { setShortText(it) }?.let { receivers += it }
+            unevaluatedData.shortText
+                ?.buildReceiver(coroutineScope) { setShortText(it) }
+                ?.let { receivers += it }
         }
         if (unevaluatedData.hasShortTitle()) {
-            unevaluatedData.shortTitle?.buildReceiver { setShortTitle(it) }?.let { receivers += it }
+            unevaluatedData.shortTitle
+                ?.buildReceiver(coroutineScope) { setShortTitle(it) }
+                ?.let { receivers += it }
         }
         if (unevaluatedData.hasContentDescription()) {
             unevaluatedData.contentDescription
-                ?.buildReceiver { setContentDescription(it) }
+                ?.buildReceiver(coroutineScope) { setContentDescription(it) }
                 ?.let { receivers += it }
         }
 
@@ -155,21 +167,34 @@
     }
 
     private fun DynamicFloat.buildReceiver(
+        coroutineScope: CoroutineScope,
         setter: WireComplicationData.Builder.(Float) -> WireComplicationData.Builder
     ) =
         ComplicationEvaluationResultReceiver(
             setter,
-            binder = { receiver -> evaluator.bind(this@buildReceiver, receiver) },
+            binder = { receiver ->
+                evaluator.bind(
+                    this@buildReceiver,
+                    coroutineScope.coroutineContext.asExecutor(),
+                    receiver
+                )
+            },
         )
 
     private fun WireComplicationText.buildReceiver(
+        coroutineScope: CoroutineScope,
         setter: WireComplicationData.Builder.(WireComplicationText) -> WireComplicationData.Builder
     ) =
         expression?.let { expression ->
             ComplicationEvaluationResultReceiver<String>(
                 setter = { setter(WireComplicationText(it, expression)) },
                 binder = { receiver ->
-                    evaluator.bind(expression, ULocale.getDefault(), receiver)
+                    evaluator.bind(
+                        expression,
+                        ULocale.getDefault(),
+                        coroutineScope.coroutineContext.asExecutor(),
+                        receiver
+                    )
                 },
             )
         }
@@ -184,6 +209,7 @@
                 stateStore,
             )
         for (receiver in state.value.pending) receiver.bind()
+        for (receiver in state.value.pending) receiver.startEvaluation()
         evaluator.enablePlatformDataSources()
     }
 
@@ -229,6 +255,10 @@
             boundDynamicType = binder(this)
         }
 
+        fun startEvaluation() {
+            boundDynamicType.startEvaluation()
+        }
+
         override fun close() {
             boundDynamicType.close()
         }
@@ -263,3 +293,6 @@
             }
     }
 }
+
+internal fun CoroutineContext.asExecutor(): Executor =
+    (get(ContinuationInterceptor) as CoroutineDispatcher).asExecutor()
diff --git a/wear/watchface/watchface-editor/src/androidTest/java/androidx/wear/watchface/editor/EditorSessionTest.kt b/wear/watchface/watchface-editor/src/androidTest/java/androidx/wear/watchface/editor/EditorSessionTest.kt
index a2ab862..c6c602b 100644
--- a/wear/watchface/watchface-editor/src/androidTest/java/androidx/wear/watchface/editor/EditorSessionTest.kt
+++ b/wear/watchface/watchface-editor/src/androidTest/java/androidx/wear/watchface/editor/EditorSessionTest.kt
@@ -592,8 +592,30 @@
         previewScreenshotParams: PreviewScreenshotParams? = null
     ): ActivityScenario<OnWatchFaceEditingTestActivity> {
         val userStyleRepository = CurrentUserStyleRepository(UserStyleSchema(userStyleSettings))
+        val mockSurfaceHolder = `mock`(SurfaceHolder::class.java)
+        `when`(mockSurfaceHolder.surfaceFrame).thenReturn(screenBounds)
+        @Suppress("Deprecation")
+        val fakeRenderer = object : Renderer.CanvasRenderer(
+            mockSurfaceHolder,
+            userStyleRepository,
+            MutableWatchState().asWatchState(),
+            CanvasType.HARDWARE,
+            interactiveDrawModeUpdateDelayMillis = 16,
+            clearWithBackgroundTintBeforeRenderingHighlightLayer = false
+        ) {
+            override fun render(canvas: Canvas, bounds: Rect, zonedDateTime: ZonedDateTime) {
+            }
+
+            override fun renderHighlightLayer(
+                canvas: Canvas,
+                bounds: Rect,
+                zonedDateTime: ZonedDateTime
+            ) {
+            }
+        }
+
         val complicationSlotsManager =
-            ComplicationSlotsManager(complicationSlots, userStyleRepository)
+            ComplicationSlotsManager(complicationSlots, userStyleRepository, fakeRenderer)
         complicationSlotsManager.watchState = placeholderWatchState
         complicationSlotsManager.listenForStyleChanges(CoroutineScope(Dispatchers.Main.immediate))
 
diff --git a/wear/watchface/watchface-editor/src/main/java/androidx/wear/watchface/editor/EditorSession.kt b/wear/watchface/watchface-editor/src/main/java/androidx/wear/watchface/editor/EditorSession.kt
index 5e52e91..53832df 100644
--- a/wear/watchface/watchface-editor/src/main/java/androidx/wear/watchface/editor/EditorSession.kt
+++ b/wear/watchface/watchface-editor/src/main/java/androidx/wear/watchface/editor/EditorSession.kt
@@ -632,21 +632,6 @@
             ?.key
     }
 
-    override fun getComplicationSlotIdAt(@Px x: Int, @Px y: Int): Int? {
-        requireNotClosed()
-        return complicationSlotsState.value.entries
-            .firstOrNull {
-                it.value.isEnabled &&
-                    when (it.value.boundsType) {
-                        ComplicationSlotBoundsType.ROUND_RECT -> it.value.bounds.contains(x, y)
-                        ComplicationSlotBoundsType.BACKGROUND -> false
-                        ComplicationSlotBoundsType.EDGE -> false
-                        else -> false
-                    }
-            }
-            ?.key
-    }
-
     /**
      * Returns the complication data source's preview [ComplicationData] if possible or fallback
      * preview data based on complication data source icon and name if not. If the slot is
@@ -1041,6 +1026,11 @@
 
     override val showComplicationRationaleDialogIntent
         get() = editorDelegate.complicationRationaleDialogIntent
+
+    override fun getComplicationSlotIdAt(@Px x: Int, @Px y: Int): Int? {
+        requireNotClosed()
+        return editorDelegate.complicationSlotsManager.getComplicationSlotAt(x, y)?.id
+    }
 }
 
 @RequiresApi(27)
@@ -1130,6 +1120,21 @@
     init {
         fetchComplicationsData(coroutineScope)
     }
+
+    override fun getComplicationSlotIdAt(@Px x: Int, @Px y: Int): Int? {
+        requireNotClosed()
+        return complicationSlotsState.value.entries
+            .firstOrNull {
+                it.value.isEnabled &&
+                    when (it.value.boundsType) {
+                        ComplicationSlotBoundsType.ROUND_RECT -> it.value.bounds.contains(x, y)
+                        ComplicationSlotBoundsType.BACKGROUND -> false
+                        ComplicationSlotBoundsType.EDGE -> false
+                        else -> false
+                    }
+            }
+            ?.key
+    }
 }
 
 internal class ComplicationDataSourceChooserRequest(
diff --git a/wear/watchface/watchface/src/main/java/androidx/wear/watchface/ComplicationSlotsManager.kt b/wear/watchface/watchface/src/main/java/androidx/wear/watchface/ComplicationSlotsManager.kt
index ea17261..bd4843b 100644
--- a/wear/watchface/watchface/src/main/java/androidx/wear/watchface/ComplicationSlotsManager.kt
+++ b/wear/watchface/watchface/src/main/java/androidx/wear/watchface/ComplicationSlotsManager.kt
@@ -133,8 +133,10 @@
     public var configExtrasChangeCallback: WatchFace.ComplicationSlotConfigExtrasChangeCallback? =
         null
 
+    /** @hide */
+    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
     @VisibleForTesting
-    internal constructor(
+    public constructor(
         complicationSlotCollection: Collection<ComplicationSlot>,
         currentUserStyleRepository: CurrentUserStyleRepository,
         renderer: Renderer
diff --git a/wear/wear/src/main/java/androidx/wear/widget/SwipeDismissTransitionHelper.java b/wear/wear/src/main/java/androidx/wear/widget/SwipeDismissTransitionHelper.java
index 75ff715..7a82c04 100644
--- a/wear/wear/src/main/java/androidx/wear/widget/SwipeDismissTransitionHelper.java
+++ b/wear/wear/src/main/java/androidx/wear/widget/SwipeDismissTransitionHelper.java
@@ -27,13 +27,16 @@
 import android.graphics.Paint;
 import android.graphics.PorterDuff;
 import android.graphics.PorterDuffColorFilter;
+import android.graphics.drawable.Drawable;
+import android.graphics.drawable.LayerDrawable;
+import android.graphics.drawable.ShapeDrawable;
+import android.graphics.drawable.shapes.RectShape;
 import android.util.SparseArray;
 import android.view.MotionEvent;
 import android.view.VelocityTracker;
 import android.view.View;
 import android.view.ViewGroup;
 import android.view.ViewOutlineProvider;
-import android.view.ViewParent;
 
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
@@ -63,7 +66,7 @@
 
     private final int mScreenWidth;
     private final SparseArray<ColorFilter> mDimmingColorFilterCache = new SparseArray<>();
-    private final View mScrimBackground;
+    private final Drawable mScrimBackground;
     private final boolean mIsScreenRound;
     private final Paint mCompositingPaint = new Paint();
 
@@ -76,18 +79,16 @@
     private float mDimming;
     private SpringAnimation mDismissalSpring;
     private SpringAnimation mRecoverySpring;
+    // Variable to restore the parent's background which is added below mScrimBackground.
+    private Drawable mPrevParentBackground = null;
 
     SwipeDismissTransitionHelper(@NonNull Context context,
             @NonNull DismissibleFrameLayout layout) {
         mLayout = layout;
         mIsScreenRound = layout.getResources().getConfiguration().isScreenRound();
         mScreenWidth = Resources.getSystem().getDisplayMetrics().widthPixels;
-        mScrimBackground = new View(context);
-        clipOutline(mScrimBackground, mIsScreenRound);
-        mScrimBackground.setLayoutParams(
-                new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,
-                        ViewGroup.LayoutParams.MATCH_PARENT));
-        mScrimBackground.setBackgroundColor(Color.BLACK);
+        mScrimBackground = generateScrimBackgroundDrawable(mScreenWidth,
+                Resources.getSystem().getDisplayMetrics().heightPixels);
     }
 
     private static void clipOutline(@NonNull View view, boolean useRoundShape) {
@@ -208,23 +209,33 @@
     }
 
     private void updateScrim() {
-        float alpha =  SCRIM_BACKGROUND_MAX * (1 - mProgress);
-        mScrimBackground.setAlpha(alpha);
+        float alpha = SCRIM_BACKGROUND_MAX * (1 - mProgress);
+        // Scaling alpha between 0 to 255, as Drawable.setAlpha expects it in range [0,255].
+        mScrimBackground.setAlpha((int) (alpha * 255));
     }
 
     private void initializeTransition() {
         mStarted = true;
         ViewGroup originalParentView = getOriginalParentView();
-        ViewParent scrimBackgroundParent = mScrimBackground.getParent();
 
-        if (originalParentView == null) return;
-
-        // Check if scrim background is already attached to the parent view.
-        if (scrimBackgroundParent != originalParentView) {
-            originalParentView.addView(mScrimBackground);
-            mLayout.bringToFront();
+        if (originalParentView == null) {
+            return;
         }
 
+        if (mPrevParentBackground == null) {
+            mPrevParentBackground = originalParentView.getBackground();
+        }
+
+        // Adding scrim over parent background if it exists.
+        Drawable parentBackgroundLayers;
+        if (mPrevParentBackground != null) {
+            parentBackgroundLayers = new LayerDrawable(new Drawable[]{mPrevParentBackground,
+                    mScrimBackground});
+        } else {
+            parentBackgroundLayers = mScrimBackground;
+        }
+        originalParentView.setBackground(parentBackgroundLayers);
+
         mCompositingPaint.setColorFilter(null);
         mLayout.setLayerType(View.LAYER_TYPE_HARDWARE, mCompositingPaint);
         clipOutline(mLayout, mIsScreenRound);
@@ -246,6 +257,15 @@
         mCompositingPaint.setColorFilter(null);
         mLayout.setLayerType(View.LAYER_TYPE_NONE, null);
         mLayout.setClipToOutline(false);
+        getOriginalParentView().setBackground(mPrevParentBackground);
+        mPrevParentBackground = null;
+    }
+
+    private Drawable generateScrimBackgroundDrawable(int width, int height) {
+        ShapeDrawable shape = new ShapeDrawable(new RectShape());
+        shape.setBounds(0, 0, width, height);
+        shape.getPaint().setColor(Color.BLACK);
+        return shape;
     }
 
     /**
diff --git a/window/extensions/extensions/build.gradle b/window/extensions/extensions/build.gradle
index 9db659b..8bdde53 100644
--- a/window/extensions/extensions/build.gradle
+++ b/window/extensions/extensions/build.gradle
@@ -28,6 +28,7 @@
     implementation("androidx.annotation:annotation-experimental:1.1.0")
     implementation("androidx.window.extensions.core:core:1.0.0-alpha01")
 
+    testImplementation(libs.robolectric)
     testImplementation(libs.testExtJunit)
     testImplementation(libs.testRunner)
     testImplementation(libs.testRules)
diff --git a/window/extensions/extensions/src/androidTest/java/androidx/window/extensions/embedding/SplitAttributesTest.java b/window/extensions/extensions/src/test/java/androidx/window/extensions/embedding/SplitAttributesTest.java
similarity index 96%
rename from window/extensions/extensions/src/androidTest/java/androidx/window/extensions/embedding/SplitAttributesTest.java
rename to window/extensions/extensions/src/test/java/androidx/window/extensions/embedding/SplitAttributesTest.java
index 0bd4c63..7886c84 100644
--- a/window/extensions/extensions/src/androidTest/java/androidx/window/extensions/embedding/SplitAttributesTest.java
+++ b/window/extensions/extensions/src/test/java/androidx/window/extensions/embedding/SplitAttributesTest.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2022 The Android Open Source Project
+ * 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.
@@ -27,9 +27,12 @@
 import androidx.window.extensions.embedding.SplitAttributes.LayoutDirection;
 
 import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
 
 /** Test for {@link SplitAttributes} */
 @SmallTest
+@RunWith(RobolectricTestRunner.class)
 public class SplitAttributesTest {
     @Test
     public void testSplitAttributesEquals() {
diff --git a/window/window-demos/demo/src/main/AndroidManifest.xml b/window/window-demos/demo/src/main/AndroidManifest.xml
index 932003e..5d3151e 100644
--- a/window/window-demos/demo/src/main/AndroidManifest.xml
+++ b/window/window-demos/demo/src/main/AndroidManifest.xml
@@ -227,6 +227,27 @@
             android:configChanges="orientation|screenSize|screenLayout|screenSize"
             android:label="@string/ime"/>
 
+        <!-- The demo app to show IME usages in ActivityEmbedding split. -->
+
+        <activity
+            android:name=".embedding.SplitImeActivityMain"
+            android:exported="true"
+            android:label="Split with IME"
+            android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|colorMode|density|touchscreen"
+            android:taskAffinity="androidx.window.demo.split_ime">
+            <intent-filter>
+                <action android:name="android.intent.action.MAIN" />
+                <category android:name="android.intent.category.LAUNCHER" />
+            </intent-filter>
+        </activity>
+        <activity
+            android:name=".embedding.SplitImeActivityPlaceholder"
+            android:exported="false"
+            android:label="Split with IME Placeholder"
+            android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|colorMode|density|touchscreen"
+            android:taskAffinity="androidx.window.demo.split_ime">
+        </activity>
+
         <!-- Activity embedding initializer -->
 
         <provider android:name="androidx.startup.InitializationProvider"
diff --git a/window/window-demos/demo/src/main/java/androidx/window/demo/embedding/ExampleWindowInitializer.kt b/window/window-demos/demo/src/main/java/androidx/window/demo/embedding/ExampleWindowInitializer.kt
index 8c5fbb5a..95a6ea2 100644
--- a/window/window-demos/demo/src/main/java/androidx/window/demo/embedding/ExampleWindowInitializer.kt
+++ b/window/window-demos/demo/src/main/java/androidx/window/demo/embedding/ExampleWindowInitializer.kt
@@ -34,6 +34,8 @@
 import androidx.window.embedding.SplitAttributes.LayoutDirection.Companion.LEFT_TO_RIGHT
 import androidx.window.embedding.SplitAttributes.LayoutDirection.Companion.RIGHT_TO_LEFT
 import androidx.window.embedding.SplitAttributes.LayoutDirection.Companion.TOP_TO_BOTTOM
+import androidx.window.embedding.SplitAttributes.SplitType.Companion.SPLIT_TYPE_HINGE
+import androidx.window.embedding.SplitAttributes.SplitType.Companion.SPLIT_TYPE_EQUAL
 import androidx.window.embedding.SplitAttributesCalculatorParams
 import androidx.window.embedding.SplitController
 import androidx.window.embedding.SplitController.SplitSupportStatus.Companion.SPLIT_AVAILABLE
@@ -44,10 +46,10 @@
 /**
  * Initializes SplitController with a set of statically defined rules.
  */
+@OptIn(ExperimentalWindowApi::class)
 class ExampleWindowInitializer : Initializer<RuleController> {
     private val mDemoActivityEmbeddingController = DemoActivityEmbeddingController.getInstance()
 
-    @OptIn(ExperimentalWindowApi::class)
     override fun create(context: Context): RuleController {
         SplitController.getInstance(context).apply {
             if (isSplitAttributesCalculatorSupported()) {
@@ -76,7 +78,7 @@
         val config = params.parentConfiguration
         // The SplitAttributes to occupy the whole task bounds
         val expandContainersAttrs = SplitAttributes.Builder()
-            .setSplitType(SplitAttributes.SplitType.expandContainers())
+            .setSplitType(SplitAttributes.SplitType.SPLIT_TYPE_EXPAND)
             .build()
         val tag = params.splitRuleTag
         val shouldReversed = tag?.contains(SUFFIX_REVERSED) ?: false
@@ -104,7 +106,7 @@
             TAG_SHOW_FULLSCREEN_IN_PORTRAIT + SUFFIX_AND_HORIZONTAL_LAYOUT_IN_TABLETOP -> {
                 if (isTabletop) {
                     return SplitAttributes.Builder()
-                        .setSplitType(SplitAttributes.SplitType.splitByHinge())
+                        .setSplitType(SPLIT_TYPE_HINGE)
                         .setLayoutDirection(
                             if (shouldReversed) {
                                 BOTTOM_TO_TOP
@@ -121,7 +123,7 @@
             TAG_SHOW_HORIZONTAL_LAYOUT_IN_TABLETOP -> {
                 if (isTabletop) {
                     return SplitAttributes.Builder()
-                        .setSplitType(SplitAttributes.SplitType.splitByHinge())
+                        .setSplitType(SPLIT_TYPE_HINGE)
                         .setLayoutDirection(
                             if (shouldReversed) {
                                 BOTTOM_TO_TOP
@@ -135,7 +137,7 @@
             }
             TAG_SHOW_DIFFERENT_LAYOUT_WITH_SIZE -> {
                 return SplitAttributes.Builder()
-                    .setSplitType(SplitAttributes.SplitType.splitByHinge())
+                    .setSplitType(SPLIT_TYPE_HINGE)
                     .setLayoutDirection(
                         if (shouldReversed) {
                             BOTTOM_TO_TOP
@@ -149,7 +151,7 @@
                     expandContainersAttrs
                 } else if (config.screenWidthDp <= 600) {
                     SplitAttributes.Builder()
-                        .setSplitType(SplitAttributes.SplitType.splitEqually())
+                        .setSplitType(SPLIT_TYPE_EQUAL)
                         .setLayoutDirection(
                             if (shouldReversed) {
                                 BOTTOM_TO_TOP
@@ -161,7 +163,7 @@
                         .build()
                 } else {
                     SplitAttributes.Builder()
-                        .setSplitType(SplitAttributes.SplitType.splitEqually())
+                        .setSplitType(SPLIT_TYPE_EQUAL)
                         .setLayoutDirection(
                             if (shouldReversed) {
                                 RIGHT_TO_LEFT
@@ -179,7 +181,7 @@
                     return SplitAttributes.Builder()
                         .setSplitType(
                             if (foldingState.isSeparating) {
-                                SplitAttributes.SplitType.splitByHinge()
+                                SPLIT_TYPE_HINGE
                             } else {
                                 SplitAttributes.SplitType.ratio(0.3f)
                             }
diff --git a/window/window-demos/demo/src/main/java/androidx/window/demo/embedding/SplitActivityBase.java b/window/window-demos/demo/src/main/java/androidx/window/demo/embedding/SplitActivityBase.java
index 59ca4ac..8e0c564 100644
--- a/window/window-demos/demo/src/main/java/androidx/window/demo/embedding/SplitActivityBase.java
+++ b/window/window-demos/demo/src/main/java/androidx/window/demo/embedding/SplitActivityBase.java
@@ -18,6 +18,7 @@
 
 import static android.app.PendingIntent.FLAG_IMMUTABLE;
 
+import static androidx.window.embedding.SplitController.SplitSupportStatus.SPLIT_AVAILABLE;
 import static androidx.window.embedding.SplitRule.FinishBehavior.ADJACENT;
 import static androidx.window.embedding.SplitRule.FinishBehavior.ALWAYS;
 import static androidx.window.embedding.SplitRule.FinishBehavior.NEVER;
@@ -50,6 +51,7 @@
 import androidx.window.embedding.SplitPairFilter;
 import androidx.window.embedding.SplitPairRule;
 import androidx.window.embedding.SplitPlaceholderRule;
+import androidx.window.java.embedding.SplitControllerCallbackAdapter;
 
 import java.util.HashSet;
 import java.util.List;
@@ -67,7 +69,11 @@
     static final float SPLIT_RATIO = 0.3f;
     static final String EXTRA_LAUNCH_C_TO_SIDE = "launch_c_to_side";
 
-    private SplitController mSplitController;
+    /**
+     * The {@link SplitController} adapter to use callback shaped APIs to get {@link SplitInfo}
+     *  changes
+     */
+    private SplitControllerCallbackAdapter mSplitControllerAdapter;
     private RuleController mRuleController;
     private SplitInfoCallback mCallback;
 
@@ -157,8 +163,9 @@
         mViewBinding.fullscreenECheckBox.setOnCheckedChangeListener(this);
         mViewBinding.splitWithFCheckBox.setOnCheckedChangeListener(this);
 
-        mSplitController = SplitController.getInstance(this);
-        if (!mSplitController.isSplitSupported()) {
+        final SplitController splitController = SplitController.getInstance(this);
+        mSplitControllerAdapter = new SplitControllerCallbackAdapter(splitController);
+        if (splitController.getSplitSupportStatus() != SPLIT_AVAILABLE) {
             Toast.makeText(this, R.string.toast_split_not_support,
                     Toast.LENGTH_SHORT).show();
             finish();
@@ -171,13 +178,13 @@
     protected void onStart() {
         super.onStart();
         mCallback = new SplitInfoCallback();
-        mSplitController.addSplitListener(this, Runnable::run, mCallback);
+        mSplitControllerAdapter.addSplitListener(this, Runnable::run, mCallback);
     }
 
     @Override
     protected void onStop() {
         super.onStop();
-        mSplitController.removeSplitListener(mCallback);
+        mSplitControllerAdapter.removeSplitListener(mCallback);
         mCallback = null;
     }
 
diff --git a/window/window-demos/demo/src/main/java/androidx/window/demo/embedding/SplitActivityList.kt b/window/window-demos/demo/src/main/java/androidx/window/demo/embedding/SplitActivityList.kt
index 49e119a..345b5eb 100644
--- a/window/window-demos/demo/src/main/java/androidx/window/demo/embedding/SplitActivityList.kt
+++ b/window/window-demos/demo/src/main/java/androidx/window/demo/embedding/SplitActivityList.kt
@@ -22,24 +22,38 @@
 import android.view.View
 import android.widget.TextView
 import androidx.appcompat.app.AppCompatActivity
-import androidx.core.content.ContextCompat
-import androidx.core.util.Consumer
-import androidx.window.embedding.SplitController
-import androidx.window.embedding.SplitInfo
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
 import androidx.window.demo.R
 import androidx.window.demo.embedding.SplitActivityDetail.Companion.EXTRA_SELECTED_ITEM
+import androidx.window.embedding.SplitController
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
 
 open class SplitActivityList : AppCompatActivity() {
-    lateinit var splitController: SplitController
-    val splitChangeListener = SplitStateChangeListener()
-
     override fun onCreate(savedInstanceState: Bundle?) {
         super.onCreate(savedInstanceState)
         setContentView(R.layout.activity_split_activity_list_layout)
         findViewById<View>(R.id.root_split_activity_layout)
             .setBackgroundColor(Color.parseColor("#e0f7fa"))
+        val splitController = SplitController.getInstance(this)
 
-        splitController = SplitController.getInstance(this)
+        lifecycleScope.launch {
+            // The block passed to repeatOnLifecycle is executed when the lifecycle
+            // is at least STARTED and is cancelled when the lifecycle is STOPPED.
+            // It automatically restarts the block when the lifecycle is STARTED again.
+            lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
+                splitController.splitInfoList(this@SplitActivityList)
+                    .collect { newSplitInfos ->
+                        withContext(Dispatchers.Main) {
+                            findViewById<View>(R.id.infoButton).visibility =
+                                if (newSplitInfos.isEmpty()) View.VISIBLE else View.GONE
+                        }
+                    }
+            }
+        }
     }
 
     open fun onItemClick(view: View) {
@@ -48,29 +62,4 @@
         startIntent.putExtra(EXTRA_SELECTED_ITEM, text)
         startActivity(startIntent)
     }
-
-    override fun onStart() {
-        super.onStart()
-        splitController.addSplitListener(
-            this,
-            ContextCompat.getMainExecutor(this),
-            splitChangeListener
-        )
-    }
-
-    override fun onStop() {
-        super.onStop()
-        splitController.removeSplitListener(
-            splitChangeListener
-        )
-    }
-
-    inner class SplitStateChangeListener : Consumer<List<SplitInfo>> {
-        override fun accept(newSplitInfos: List<SplitInfo>) {
-            runOnUiThread {
-                findViewById<View>(R.id.infoButton).visibility =
-                    if (newSplitInfos.isEmpty()) View.VISIBLE else View.GONE
-            }
-        }
-    }
 }
\ No newline at end of file
diff --git a/window/window-demos/demo/src/main/java/androidx/window/demo/embedding/SplitDeviceStateActivityBase.kt b/window/window-demos/demo/src/main/java/androidx/window/demo/embedding/SplitDeviceStateActivityBase.kt
index 6a5e127..680d8d2 100644
--- a/window/window-demos/demo/src/main/java/androidx/window/demo/embedding/SplitDeviceStateActivityBase.kt
+++ b/window/window-demos/demo/src/main/java/androidx/window/demo/embedding/SplitDeviceStateActivityBase.kt
@@ -27,20 +27,27 @@
 import android.widget.RadioGroup
 import android.widget.Toast
 import androidx.appcompat.app.AppCompatActivity
-import androidx.core.content.ContextCompat
-import androidx.core.util.Consumer
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
 import androidx.window.core.ExperimentalWindowApi
+import androidx.window.demo.R
+import androidx.window.demo.databinding.ActivitySplitDeviceStateLayoutBinding
 import androidx.window.embedding.EmbeddingRule
+import androidx.window.embedding.RuleController
 import androidx.window.embedding.SplitAttributes
+import androidx.window.embedding.SplitAttributes.SplitType.Companion.SPLIT_TYPE_EXPAND
+import androidx.window.embedding.SplitAttributes.SplitType.Companion.SPLIT_TYPE_EQUAL
 import androidx.window.embedding.SplitController
+import androidx.window.embedding.SplitController.SplitSupportStatus.Companion.SPLIT_AVAILABLE
 import androidx.window.embedding.SplitInfo
 import androidx.window.embedding.SplitPairFilter
 import androidx.window.embedding.SplitPairRule
-import androidx.window.demo.R
-import androidx.window.demo.databinding.ActivitySplitDeviceStateLayoutBinding
-import androidx.window.embedding.RuleController
-import androidx.window.embedding.SplitController.SplitSupportStatus.Companion.SPLIT_AVAILABLE
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
 
+@OptIn(ExperimentalWindowApi::class)
 open class SplitDeviceStateActivityBase : AppCompatActivity(), View.OnClickListener,
     RadioGroup.OnCheckedChangeListener, CompoundButton.OnCheckedChangeListener,
     AdapterView.OnItemSelectedListener {
@@ -48,8 +55,6 @@
     private lateinit var splitController: SplitController
     private lateinit var ruleController: RuleController
 
-    private val splitStateChangeListener = SplitStateChangeListener()
-
     private lateinit var splitPairRule: SplitPairRule
     private var shouldReverseContainerPosition = false
     private var shouldShowHorizontalInTabletop = false
@@ -65,7 +70,6 @@
     /** The last selected split rule id. */
     private var lastCheckedRuleId = 0
 
-    @OptIn(ExperimentalWindowApi::class)
     override fun onCreate(savedInstanceState: Bundle?) {
         super.onCreate(savedInstanceState)
         viewBinding = ActivitySplitDeviceStateLayoutBinding.inflate(layoutInflater)
@@ -127,20 +131,19 @@
             viewBinding.errorMessageTextView.text = "SplitAttributesCalculator is not supported!"
             animationBgColorDropdown.isEnabled = false
         }
-    }
 
-    override fun onStart() {
-        super.onStart()
-        splitController.addSplitListener(
-            this,
-            ContextCompat.getMainExecutor(this),
-            splitStateChangeListener
-        )
-    }
-
-    override fun onStop() {
-        super.onStop()
-        splitController.removeSplitListener(splitStateChangeListener)
+        lifecycleScope.launch {
+            // The block passed to repeatOnLifecycle is executed when the lifecycle
+            // is at least STARTED and is cancelled when the lifecycle is STOPPED.
+            // It automatically restarts the block when the lifecycle is STARTED again.
+            lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
+                splitController.splitInfoList(this@SplitDeviceStateActivityBase)
+                    .collect { newSplitInfos ->
+                        updateSplitAttributesText(newSplitInfos)
+                        updateRadioGroupAndCheckBoxFromRule()
+                    }
+            }
+        }
     }
 
     override fun onClick(button: View) {
@@ -261,7 +264,7 @@
         )
         splitPairFilters.add(splitPairFilter)
         val defaultSplitAttributes = SplitAttributes.Builder()
-            .setSplitType(SplitAttributes.SplitType.splitEqually())
+            .setSplitType(SPLIT_TYPE_EQUAL)
             .setLayoutDirection(SplitAttributes.LayoutDirection.LOCALE)
             .setAnimationBackgroundColor(demoActivityEmbeddingController.animationBackgroundColor)
             .build()
@@ -306,18 +309,9 @@
         ruleController.addRule(splitPairRule)
     }
 
-    /** Updates split attributes when receives callback from the extension. */
-    inner class SplitStateChangeListener : Consumer<List<SplitInfo>> {
-        override fun accept(newSplitInfos: List<SplitInfo>) {
-            updateSplitAttributesText(newSplitInfos)
-            updateRadioGroupAndCheckBoxFromRule()
-        }
-    }
-
-    @OptIn(ExperimentalWindowApi::class)
-    fun updateSplitAttributesText(newSplitInfos: List<SplitInfo>) {
+    private suspend fun updateSplitAttributesText(newSplitInfos: List<SplitInfo>) {
         var splitAttributes: SplitAttributes = SplitAttributes.Builder()
-            .setSplitType(SplitAttributes.SplitType.expandContainers())
+            .setSplitType(SPLIT_TYPE_EXPAND)
             .build()
         var suggestToFinishItself = false
         val isCallbackSupported = splitController.isSplitAttributesCalculatorSupported()
@@ -326,8 +320,7 @@
             if (info.contains(this@SplitDeviceStateActivityBase)) {
                 splitAttributes = info.splitAttributes
                 if (componentName == activityB &&
-                    splitAttributes.splitType
-                        is SplitAttributes.SplitType.ExpandContainersSplitType
+                    splitAttributes.splitType == SPLIT_TYPE_EXPAND
                 ) {
                     // We don't put any functionality on activity B. Suggest users to finish the
                     // activity if it fills the host task.
@@ -336,12 +329,12 @@
                 break
             }
         }
-        runOnUiThread {
+        withContext(Dispatchers.Main) {
             viewBinding.activityPairSplitAttributesTextView.text =
                 resources.getString(R.string.current_split_attributes) + splitAttributes
             if (!isCallbackSupported) {
                 // Don't update the error message if the callback is not supported.
-                return@runOnUiThread
+                return@withContext
             }
             viewBinding.errorMessageTextView.text =
                 if (suggestToFinishItself) {
@@ -352,7 +345,7 @@
         }
     }
 
-    fun updateRadioGroupAndCheckBoxFromRule() {
+    private fun updateRadioGroupAndCheckBoxFromRule() {
         val splitPairRule = ruleController.getRules().firstOrNull { rule ->
             isRuleForSplitActivityA(rule)
         } ?: return
diff --git a/window/window-demos/demo/src/main/java/androidx/window/demo/embedding/SplitImeActivityBase.kt b/window/window-demos/demo/src/main/java/androidx/window/demo/embedding/SplitImeActivityBase.kt
new file mode 100644
index 0000000..fa767bf
--- /dev/null
+++ b/window/window-demos/demo/src/main/java/androidx/window/demo/embedding/SplitImeActivityBase.kt
@@ -0,0 +1,37 @@
+/*
+ * 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.window.demo.embedding
+
+import android.os.Bundle
+import androidx.appcompat.app.AppCompatActivity
+import androidx.window.demo.databinding.ActivitySplitImeLayoutBinding
+
+/**
+ * Sample showcase of split activity with input field. Allows the user to use IME with split
+ * activities.
+ * The split rule is defined in `main_split_config.xml`.
+ */
+abstract class SplitImeActivityBase : AppCompatActivity() {
+
+    lateinit var viewBinding: ActivitySplitImeLayoutBinding
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        viewBinding = ActivitySplitImeLayoutBinding.inflate(layoutInflater)
+        setContentView(viewBinding.root)
+    }
+}
\ No newline at end of file
diff --git a/window/window-demos/demo/src/main/java/androidx/window/demo/embedding/SplitImeActivityMain.kt b/window/window-demos/demo/src/main/java/androidx/window/demo/embedding/SplitImeActivityMain.kt
new file mode 100644
index 0000000..d866e9f
--- /dev/null
+++ b/window/window-demos/demo/src/main/java/androidx/window/demo/embedding/SplitImeActivityMain.kt
@@ -0,0 +1,28 @@
+/*
+ * 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.window.demo.embedding
+
+import android.graphics.Color
+import android.os.Bundle
+
+/** The main activity that will launch [SplitImeActivityPlaceholder] when there is enough space. */
+class SplitImeActivityMain : SplitImeActivityBase() {
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        viewBinding.rootSplitActivityLayout.setBackgroundColor(Color.parseColor("#e0f7fa"))
+    }
+}
\ No newline at end of file
diff --git a/window/window-demos/demo/src/main/java/androidx/window/demo/embedding/SplitImeActivityPlaceholder.kt b/window/window-demos/demo/src/main/java/androidx/window/demo/embedding/SplitImeActivityPlaceholder.kt
new file mode 100644
index 0000000..8edaf79
--- /dev/null
+++ b/window/window-demos/demo/src/main/java/androidx/window/demo/embedding/SplitImeActivityPlaceholder.kt
@@ -0,0 +1,59 @@
+/*
+ * 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.window.demo.embedding
+
+import android.graphics.Color
+import android.graphics.PixelFormat
+import android.os.Build
+import android.os.Bundle
+import android.view.View
+import android.view.WindowManager
+import android.view.WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM
+import android.view.WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
+import android.view.WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE
+
+/** The placeholder activity that will be launched when there is enough space. */
+class SplitImeActivityPlaceholder : SplitImeActivityBase() {
+
+    private var imeLayeringTargetPlaceholder: View? = null
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        viewBinding.rootSplitActivityLayout.setBackgroundColor(Color.parseColor("#fff3e0"))
+
+        if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.TIRAMISU) {
+            // CAVEATS:
+            // Before Android U, IME may have issue to be shown in the primary split.
+            // A workaround is to add a child window to the secondary split, and set its window
+            // attribute as "FLAG_NOT_FOCUSABLE or FLAG_ALT_FOCUSABLE_IM".
+            val attrs = WindowManager.LayoutParams()
+            // Make it transparent and not touchable to not affect the views below.
+            attrs.format = PixelFormat.TRANSPARENT
+            attrs.flags = FLAG_NOT_FOCUSABLE or FLAG_ALT_FOCUSABLE_IM or FLAG_NOT_TOUCHABLE
+            attrs.fitInsetsTypes = 0
+            imeLayeringTargetPlaceholder = View(this)
+            windowManager.addView(imeLayeringTargetPlaceholder, attrs)
+        }
+    }
+
+    override fun onDestroy() {
+        super.onDestroy()
+        if (imeLayeringTargetPlaceholder != null) {
+            windowManager.removeView(imeLayeringTargetPlaceholder)
+        }
+    }
+}
\ No newline at end of file
diff --git a/window/window-demos/demo/src/main/java/androidx/window/demo/embedding/SplitPipActivityBase.kt b/window/window-demos/demo/src/main/java/androidx/window/demo/embedding/SplitPipActivityBase.kt
index 61c056c..4ab221f 100644
--- a/window/window-demos/demo/src/main/java/androidx/window/demo/embedding/SplitPipActivityBase.kt
+++ b/window/window-demos/demo/src/main/java/androidx/window/demo/embedding/SplitPipActivityBase.kt
@@ -25,8 +25,9 @@
 import android.widget.RadioGroup
 import android.widget.Toast
 import androidx.appcompat.app.AppCompatActivity
-import androidx.core.content.ContextCompat
-import androidx.core.util.Consumer
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
 import androidx.window.demo.R
 import androidx.window.demo.common.util.PictureInPictureUtil
 import androidx.window.demo.databinding.ActivitySplitPipActivityLayoutBinding
@@ -34,14 +35,17 @@
 import androidx.window.embedding.EmbeddingRule
 import androidx.window.embedding.RuleController
 import androidx.window.embedding.SplitAttributes
+import androidx.window.embedding.SplitAttributes.SplitType.Companion.SPLIT_TYPE_EXPAND
 import androidx.window.embedding.SplitController
-import androidx.window.embedding.SplitInfo
 import androidx.window.embedding.SplitPairFilter
 import androidx.window.embedding.SplitPairRule
 import androidx.window.embedding.SplitPlaceholderRule
 import androidx.window.embedding.SplitRule.FinishBehavior.Companion.ADJACENT
 import androidx.window.embedding.SplitRule.FinishBehavior.Companion.ALWAYS
 import androidx.window.embedding.SplitRule.FinishBehavior.Companion.NEVER
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
 
 /**
  * Sample showcase of split activity rules with picture-in-picture. Allows the user to select some
@@ -58,7 +62,6 @@
     lateinit var componentNameB: ComponentName
     lateinit var componentNameNotPip: ComponentName
     lateinit var componentNamePlaceholder: ComponentName
-    private val splitChangeListener = SplitStateChangeListener()
     private val splitRatio = 0.5f
     private var enterPipOnUserLeave = false
     private var autoEnterPip = false
@@ -96,6 +99,33 @@
         // Buttons for PiP options.
         viewBinding.enterPipButton.setOnClickListener(this)
         viewBinding.supportPipRadioGroup.setOnCheckedChangeListener(this)
+
+        lifecycleScope.launch {
+            // The block passed to repeatOnLifecycle is executed when the lifecycle
+            // is at least STARTED and is cancelled when the lifecycle is STOPPED.
+            // It automatically restarts the block when the lifecycle is STARTED again.
+            lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
+                splitController.splitInfoList(this@SplitPipActivityBase)
+                    .collect { newSplitInfos ->
+                        var isInSplit = false
+                        for (info in newSplitInfos) {
+                            if (info.contains(this@SplitPipActivityBase) &&
+                                info.splitAttributes.splitType == SPLIT_TYPE_EXPAND
+                            ) {
+                                isInSplit = true
+                                break
+                            }
+                        }
+
+                        withContext(Dispatchers.Main) {
+                            viewBinding.activityEmbeddedStatusTextView.visibility =
+                                if (isInSplit) View.VISIBLE else View.GONE
+
+                            updateCheckboxes()
+                        }
+                    }
+            }
+        }
     }
 
     /** Called on checkbox changed. */
@@ -277,41 +307,4 @@
             ruleController.addRule(rule)
         }
     }
-
-    override fun onStart() {
-        super.onStart()
-        splitController.addSplitListener(
-            this,
-            ContextCompat.getMainExecutor(this),
-            splitChangeListener
-        )
-    }
-
-    override fun onStop() {
-        super.onStop()
-        splitController.removeSplitListener(splitChangeListener)
-    }
-
-    /** Updates the embedding status when receives callback from the extension. */
-    inner class SplitStateChangeListener : Consumer<List<SplitInfo>> {
-        override fun accept(newSplitInfos: List<SplitInfo>) {
-            var isInSplit = false
-            for (info in newSplitInfos) {
-                if (info.contains(this@SplitPipActivityBase) &&
-                    info.splitAttributes.splitType !is
-                        SplitAttributes.SplitType.ExpandContainersSplitType
-                ) {
-                    isInSplit = true
-                    break
-                }
-            }
-
-            runOnUiThread {
-                viewBinding.activityEmbeddedStatusTextView.visibility =
-                    if (isInSplit) View.VISIBLE else View.GONE
-
-                updateCheckboxes()
-            }
-        }
-    }
 }
\ No newline at end of file
diff --git a/window/window-demos/demo/src/main/res/layout/activity_split_ime_layout.xml b/window/window-demos/demo/src/main/res/layout/activity_split_ime_layout.xml
new file mode 100644
index 0000000..0fa99f5
--- /dev/null
+++ b/window/window-demos/demo/src/main/res/layout/activity_split_ime_layout.xml
@@ -0,0 +1,33 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  Copyright 2023 The Android Open Source Project
+
+  Licensed under the Apache License, Version 2.0 (the "License");
+  you may not use this file except in compliance with the License.
+  You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+  -->
+
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    android:id="@+id/root_split_activity_layout"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:orientation="vertical">
+
+    <Space
+        android:layout_width="match_parent"
+        android:layout_height="16dp"/>
+
+    <EditText
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:hint="@string/window_metrics_ime_hint"/>
+
+</LinearLayout>
\ No newline at end of file
diff --git a/window/window-demos/demo/src/main/res/xml/main_split_config.xml b/window/window-demos/demo/src/main/res/xml/main_split_config.xml
index e4b150d..8269c38 100644
--- a/window/window-demos/demo/src/main/res/xml/main_split_config.xml
+++ b/window/window-demos/demo/src/main/res/xml/main_split_config.xml
@@ -16,6 +16,9 @@
   -->
 <resources
     xmlns:window="http://schemas.android.com/apk/res-auto">
+
+    <!-- Rules for SplitActivityList -->
+
     <SplitPairRule
         window:finishPrimaryWithSecondary="always"
         window:finishSecondaryWithPrimary="adjacent">
@@ -30,4 +33,12 @@
         <ActivityFilter
             window:activityName="androidx.window.demo.embedding.SplitActivityList"/>
     </SplitPlaceholderRule>
+
+    <!-- Rules for SplitImeActivityMain -->
+
+    <SplitPlaceholderRule
+        window:placeholderActivityName="androidx.window.demo.embedding.SplitImeActivityPlaceholder">
+        <ActivityFilter
+            window:activityName="androidx.window.demo.embedding.SplitImeActivityMain"/>
+    </SplitPlaceholderRule>
 </resources>
\ No newline at end of file
diff --git a/window/window-java/api/public_plus_experimental_current.txt b/window/window-java/api/public_plus_experimental_current.txt
index ded945be..bcbb1e7 100644
--- a/window/window-java/api/public_plus_experimental_current.txt
+++ b/window/window-java/api/public_plus_experimental_current.txt
@@ -10,6 +10,16 @@
 
 }
 
+package androidx.window.java.embedding {
+
+  @androidx.window.core.ExperimentalWindowApi public final class SplitControllerCallbackAdapter {
+    ctor public SplitControllerCallbackAdapter(androidx.window.embedding.SplitController controller);
+    method public void addSplitListener(android.app.Activity activity, java.util.concurrent.Executor executor, androidx.core.util.Consumer<java.util.List<androidx.window.embedding.SplitInfo>> consumer);
+    method public void removeSplitListener(androidx.core.util.Consumer<java.util.List<androidx.window.embedding.SplitInfo>> consumer);
+  }
+
+}
+
 package androidx.window.java.layout {
 
   public final class WindowInfoTrackerCallbackAdapter implements androidx.window.layout.WindowInfoTracker {
diff --git a/window/window-java/src/main/java/androidx/window/java/embedding/SplitControllerCallbackAdapter.kt b/window/window-java/src/main/java/androidx/window/java/embedding/SplitControllerCallbackAdapter.kt
new file mode 100644
index 0000000..4494162
--- /dev/null
+++ b/window/window-java/src/main/java/androidx/window/java/embedding/SplitControllerCallbackAdapter.kt
@@ -0,0 +1,68 @@
+/*
+ * 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.window.java.embedding
+
+import android.app.Activity
+import androidx.core.util.Consumer
+import androidx.window.core.ExperimentalWindowApi
+import androidx.window.embedding.SplitController
+import androidx.window.embedding.SplitInfo
+import java.util.concurrent.Executor
+
+/**
+ * An adapted interface for [SplitController] that provides callback shaped APIs to report
+ * the latest split information with [SplitInfo] list.
+ *
+ * It should only be used if [SplitController.splitInfoList] is not available. For example, an app
+ * is written in Java and cannot use Flow APIs.
+ *
+ * @param controller A [SplitController] that can be obtained by [SplitController.getInstance]
+ */
+@ExperimentalWindowApi
+@Suppress("Deprecation") // To call legacy SplitController SplitInfo callback APIs
+class SplitControllerCallbackAdapter(private val controller: SplitController) {
+    /**
+     * Registers a listener for updates about the active split state(s) that this
+     * activity is part of. An activity can be in zero, one or more active splits.
+     * More than one active split is possible if an activity created multiple
+     * containers to side, stacked on top of each other. Or it can be in two
+     * different splits at the same time - in a secondary container for one (it was
+     * launched to the side) and in the primary for another (it launched another
+     * activity to the side). The reported splits in the list are ordered from
+     * bottom to top by their z-order, more recent splits appearing later.
+     * Guaranteed to be called at least once to report the most recent state.
+     *
+     * @param activity only split that this [Activity] is part of will be reported.
+     * @param executor when there is an update to the active split state(s), the [consumer] will be
+     * invoked on this [Executor].
+     * @param consumer [Consumer] that will be invoked on the [executor] when there is an update to
+     * the active split state(s).
+     */
+    fun addSplitListener(
+        activity: Activity,
+        executor: Executor,
+        consumer: Consumer<List<SplitInfo>>
+    ) = controller.addSplitListener(activity, executor, consumer)
+
+    /**
+     * Unregisters a listener that was previously registered via [addSplitListener].
+     *
+     * @param consumer the previously registered [Consumer] to unregister.
+     */
+    fun removeSplitListener(consumer: Consumer<List<SplitInfo>>) =
+        controller.removeSplitListener(consumer)
+}
\ No newline at end of file
diff --git a/window/window-testing/api/current.txt b/window/window-testing/api/current.txt
index baf8ef4..12a7d20 100644
--- a/window/window-testing/api/current.txt
+++ b/window/window-testing/api/current.txt
@@ -1,17 +1,4 @@
 // Signature format: 4.0
-package androidx.window.testing.embedding {
-
-  public final class TestSplitAttributesCalculatorParams {
-    method public static androidx.window.embedding.SplitAttributesCalculatorParams createTestSplitAttributesCalculatorParams(androidx.window.layout.WindowMetrics parentWindowMetrics, optional android.content.res.Configuration parentConfiguration, optional androidx.window.layout.WindowLayoutInfo parentWindowLayoutInfo, optional androidx.window.embedding.SplitAttributes defaultSplitAttributes, optional boolean areDefaultConstraintsSatisfied, optional String? splitRuleTag);
-    method public static androidx.window.embedding.SplitAttributesCalculatorParams createTestSplitAttributesCalculatorParams(androidx.window.layout.WindowMetrics parentWindowMetrics, optional android.content.res.Configuration parentConfiguration, optional androidx.window.layout.WindowLayoutInfo parentWindowLayoutInfo, optional androidx.window.embedding.SplitAttributes defaultSplitAttributes, optional boolean areDefaultConstraintsSatisfied);
-    method public static androidx.window.embedding.SplitAttributesCalculatorParams createTestSplitAttributesCalculatorParams(androidx.window.layout.WindowMetrics parentWindowMetrics, optional android.content.res.Configuration parentConfiguration, optional androidx.window.layout.WindowLayoutInfo parentWindowLayoutInfo, optional androidx.window.embedding.SplitAttributes defaultSplitAttributes);
-    method public static androidx.window.embedding.SplitAttributesCalculatorParams createTestSplitAttributesCalculatorParams(androidx.window.layout.WindowMetrics parentWindowMetrics, optional android.content.res.Configuration parentConfiguration, optional androidx.window.layout.WindowLayoutInfo parentWindowLayoutInfo);
-    method public static androidx.window.embedding.SplitAttributesCalculatorParams createTestSplitAttributesCalculatorParams(androidx.window.layout.WindowMetrics parentWindowMetrics, optional android.content.res.Configuration parentConfiguration);
-    method public static androidx.window.embedding.SplitAttributesCalculatorParams createTestSplitAttributesCalculatorParams(androidx.window.layout.WindowMetrics parentWindowMetrics);
-  }
-
-}
-
 package androidx.window.testing.layout {
 
   public final class DisplayFeatureTesting {
diff --git a/window/window-testing/api/public_plus_experimental_current.txt b/window/window-testing/api/public_plus_experimental_current.txt
index a053966..e099b5d 100644
--- a/window/window-testing/api/public_plus_experimental_current.txt
+++ b/window/window-testing/api/public_plus_experimental_current.txt
@@ -8,12 +8,12 @@
   }
 
   public final class TestSplitAttributesCalculatorParams {
-    method public static androidx.window.embedding.SplitAttributesCalculatorParams createTestSplitAttributesCalculatorParams(androidx.window.layout.WindowMetrics parentWindowMetrics, optional android.content.res.Configuration parentConfiguration, optional androidx.window.layout.WindowLayoutInfo parentWindowLayoutInfo, optional androidx.window.embedding.SplitAttributes defaultSplitAttributes, optional boolean areDefaultConstraintsSatisfied, optional String? splitRuleTag);
-    method public static androidx.window.embedding.SplitAttributesCalculatorParams createTestSplitAttributesCalculatorParams(androidx.window.layout.WindowMetrics parentWindowMetrics, optional android.content.res.Configuration parentConfiguration, optional androidx.window.layout.WindowLayoutInfo parentWindowLayoutInfo, optional androidx.window.embedding.SplitAttributes defaultSplitAttributes, optional boolean areDefaultConstraintsSatisfied);
-    method public static androidx.window.embedding.SplitAttributesCalculatorParams createTestSplitAttributesCalculatorParams(androidx.window.layout.WindowMetrics parentWindowMetrics, optional android.content.res.Configuration parentConfiguration, optional androidx.window.layout.WindowLayoutInfo parentWindowLayoutInfo, optional androidx.window.embedding.SplitAttributes defaultSplitAttributes);
-    method public static androidx.window.embedding.SplitAttributesCalculatorParams createTestSplitAttributesCalculatorParams(androidx.window.layout.WindowMetrics parentWindowMetrics, optional android.content.res.Configuration parentConfiguration, optional androidx.window.layout.WindowLayoutInfo parentWindowLayoutInfo);
-    method public static androidx.window.embedding.SplitAttributesCalculatorParams createTestSplitAttributesCalculatorParams(androidx.window.layout.WindowMetrics parentWindowMetrics, optional android.content.res.Configuration parentConfiguration);
-    method public static androidx.window.embedding.SplitAttributesCalculatorParams createTestSplitAttributesCalculatorParams(androidx.window.layout.WindowMetrics parentWindowMetrics);
+    method @androidx.window.core.ExperimentalWindowApi public static androidx.window.embedding.SplitAttributesCalculatorParams createTestSplitAttributesCalculatorParams(androidx.window.layout.WindowMetrics parentWindowMetrics, optional android.content.res.Configuration parentConfiguration, optional androidx.window.layout.WindowLayoutInfo parentWindowLayoutInfo, optional androidx.window.embedding.SplitAttributes defaultSplitAttributes, optional boolean areDefaultConstraintsSatisfied, optional String? splitRuleTag);
+    method @androidx.window.core.ExperimentalWindowApi public static androidx.window.embedding.SplitAttributesCalculatorParams createTestSplitAttributesCalculatorParams(androidx.window.layout.WindowMetrics parentWindowMetrics, optional android.content.res.Configuration parentConfiguration, optional androidx.window.layout.WindowLayoutInfo parentWindowLayoutInfo, optional androidx.window.embedding.SplitAttributes defaultSplitAttributes, optional boolean areDefaultConstraintsSatisfied);
+    method @androidx.window.core.ExperimentalWindowApi public static androidx.window.embedding.SplitAttributesCalculatorParams createTestSplitAttributesCalculatorParams(androidx.window.layout.WindowMetrics parentWindowMetrics, optional android.content.res.Configuration parentConfiguration, optional androidx.window.layout.WindowLayoutInfo parentWindowLayoutInfo, optional androidx.window.embedding.SplitAttributes defaultSplitAttributes);
+    method @androidx.window.core.ExperimentalWindowApi public static androidx.window.embedding.SplitAttributesCalculatorParams createTestSplitAttributesCalculatorParams(androidx.window.layout.WindowMetrics parentWindowMetrics, optional android.content.res.Configuration parentConfiguration, optional androidx.window.layout.WindowLayoutInfo parentWindowLayoutInfo);
+    method @androidx.window.core.ExperimentalWindowApi public static androidx.window.embedding.SplitAttributesCalculatorParams createTestSplitAttributesCalculatorParams(androidx.window.layout.WindowMetrics parentWindowMetrics, optional android.content.res.Configuration parentConfiguration);
+    method @androidx.window.core.ExperimentalWindowApi public static androidx.window.embedding.SplitAttributesCalculatorParams createTestSplitAttributesCalculatorParams(androidx.window.layout.WindowMetrics parentWindowMetrics);
   }
 
 }
diff --git a/window/window-testing/api/restricted_current.txt b/window/window-testing/api/restricted_current.txt
index baf8ef4..12a7d20 100644
--- a/window/window-testing/api/restricted_current.txt
+++ b/window/window-testing/api/restricted_current.txt
@@ -1,17 +1,4 @@
 // Signature format: 4.0
-package androidx.window.testing.embedding {
-
-  public final class TestSplitAttributesCalculatorParams {
-    method public static androidx.window.embedding.SplitAttributesCalculatorParams createTestSplitAttributesCalculatorParams(androidx.window.layout.WindowMetrics parentWindowMetrics, optional android.content.res.Configuration parentConfiguration, optional androidx.window.layout.WindowLayoutInfo parentWindowLayoutInfo, optional androidx.window.embedding.SplitAttributes defaultSplitAttributes, optional boolean areDefaultConstraintsSatisfied, optional String? splitRuleTag);
-    method public static androidx.window.embedding.SplitAttributesCalculatorParams createTestSplitAttributesCalculatorParams(androidx.window.layout.WindowMetrics parentWindowMetrics, optional android.content.res.Configuration parentConfiguration, optional androidx.window.layout.WindowLayoutInfo parentWindowLayoutInfo, optional androidx.window.embedding.SplitAttributes defaultSplitAttributes, optional boolean areDefaultConstraintsSatisfied);
-    method public static androidx.window.embedding.SplitAttributesCalculatorParams createTestSplitAttributesCalculatorParams(androidx.window.layout.WindowMetrics parentWindowMetrics, optional android.content.res.Configuration parentConfiguration, optional androidx.window.layout.WindowLayoutInfo parentWindowLayoutInfo, optional androidx.window.embedding.SplitAttributes defaultSplitAttributes);
-    method public static androidx.window.embedding.SplitAttributesCalculatorParams createTestSplitAttributesCalculatorParams(androidx.window.layout.WindowMetrics parentWindowMetrics, optional android.content.res.Configuration parentConfiguration, optional androidx.window.layout.WindowLayoutInfo parentWindowLayoutInfo);
-    method public static androidx.window.embedding.SplitAttributesCalculatorParams createTestSplitAttributesCalculatorParams(androidx.window.layout.WindowMetrics parentWindowMetrics, optional android.content.res.Configuration parentConfiguration);
-    method public static androidx.window.embedding.SplitAttributesCalculatorParams createTestSplitAttributesCalculatorParams(androidx.window.layout.WindowMetrics parentWindowMetrics);
-  }
-
-}
-
 package androidx.window.testing.layout {
 
   public final class DisplayFeatureTesting {
diff --git a/window/window-testing/build.gradle b/window/window-testing/build.gradle
index f1a00a3..0067b0a 100644
--- a/window/window-testing/build.gradle
+++ b/window/window-testing/build.gradle
@@ -43,6 +43,13 @@
     androidTestImplementation(libs.kotlinCoroutinesTest)
     androidTestImplementation(libs.multidex)
     androidTestImplementation(libs.truth)
+
+    testImplementation(libs.robolectric)
+    testImplementation(libs.testCore)
+    testImplementation(libs.testExtJunit)
+    testImplementation(libs.testRunner)
+    testImplementation(libs.testRules)
+    testImplementation(libs.kotlinCoroutinesTest)
 }
 
 androidx {
diff --git a/window/window-testing/src/main/java/androidx/window/testing/embedding/SplitAttributesCalculatorParamsTesting.kt b/window/window-testing/src/main/java/androidx/window/testing/embedding/SplitAttributesCalculatorParamsTesting.kt
index 4b39749..bf20738 100644
--- a/window/window-testing/src/main/java/androidx/window/testing/embedding/SplitAttributesCalculatorParamsTesting.kt
+++ b/window/window-testing/src/main/java/androidx/window/testing/embedding/SplitAttributesCalculatorParamsTesting.kt
@@ -18,6 +18,7 @@
 package androidx.window.testing.embedding
 
 import android.content.res.Configuration
+import androidx.window.core.ExperimentalWindowApi
 import androidx.window.embedding.SplitAttributes
 import androidx.window.embedding.SplitAttributesCalculatorParams
 import androidx.window.embedding.SplitController
@@ -54,6 +55,7 @@
  *
  * @see SplitAttributesCalculatorParams
  */
+@ExperimentalWindowApi
 @Suppress("FunctionName")
 @JvmName("createTestSplitAttributesCalculatorParams")
 @JvmOverloads
diff --git a/window/window-testing/src/main/java/androidx/window/testing/embedding/StubEmbeddingBackend.kt b/window/window-testing/src/main/java/androidx/window/testing/embedding/StubEmbeddingBackend.kt
index 9f9ee2f..64dbcba 100644
--- a/window/window-testing/src/main/java/androidx/window/testing/embedding/StubEmbeddingBackend.kt
+++ b/window/window-testing/src/main/java/androidx/window/testing/embedding/StubEmbeddingBackend.kt
@@ -18,6 +18,7 @@
 
 import android.app.Activity
 import androidx.core.util.Consumer
+import androidx.window.core.ExperimentalWindowApi
 import androidx.window.embedding.EmbeddingBackend
 import androidx.window.embedding.EmbeddingRule
 import androidx.window.embedding.SplitAttributes
@@ -77,6 +78,7 @@
     override fun isActivityEmbedded(activity: Activity): Boolean =
         embeddedActivities.contains(activity)
 
+    @ExperimentalWindowApi
     override fun setSplitAttributesCalculator(
         calculator: (SplitAttributesCalculatorParams) -> SplitAttributes
     ) {
diff --git a/window/window-testing/src/androidTest/java/androidx/window/testing/emedding/SplitAttributesCalculatorParamsTestingJavaTest.java b/window/window-testing/src/test/java/androidx/window/testing/embedding/SplitAttributesCalculatorParamsTestingJavaTest.java
similarity index 91%
rename from window/window-testing/src/androidTest/java/androidx/window/testing/emedding/SplitAttributesCalculatorParamsTestingJavaTest.java
rename to window/window-testing/src/test/java/androidx/window/testing/embedding/SplitAttributesCalculatorParamsTestingJavaTest.java
index cec8cb1..c2bb377 100644
--- a/window/window-testing/src/androidTest/java/androidx/window/testing/emedding/SplitAttributesCalculatorParamsTestingJavaTest.java
+++ b/window/window-testing/src/test/java/androidx/window/testing/embedding/SplitAttributesCalculatorParamsTestingJavaTest.java
@@ -14,7 +14,9 @@
  * limitations under the License.
  */
 
-package androidx.window.testing.emedding;
+package androidx.window.testing.embedding;
+
+import static androidx.window.embedding.SplitAttributes.SplitType.SPLIT_TYPE_HINGE;
 
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertNull;
@@ -24,7 +26,6 @@
 import android.graphics.Rect;
 
 import androidx.annotation.NonNull;
-import androidx.annotation.OptIn;
 import androidx.core.view.WindowInsetsCompat;
 import androidx.window.core.ExperimentalWindowApi;
 import androidx.window.embedding.SplitAttributes;
@@ -33,17 +34,22 @@
 import androidx.window.layout.FoldingFeature;
 import androidx.window.layout.WindowLayoutInfo;
 import androidx.window.layout.WindowMetrics;
-import androidx.window.testing.embedding.TestSplitAttributesCalculatorParams;
 import androidx.window.testing.layout.DisplayFeatureTesting;
 import androidx.window.testing.layout.WindowLayoutInfoTesting;
 
+import kotlin.OptIn;
+
 import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
 
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
 
 /** Test class to verify {@link TestSplitAttributesCalculatorParams} in Java. */
+@OptIn(markerClass = ExperimentalWindowApi.class)
+@RunWith(RobolectricTestRunner.class)
 public class SplitAttributesCalculatorParamsTestingJavaTest {
     private static final Rect TEST_BOUNDS = new Rect(0, 0, 2000, 2000);
     private static final WindowMetrics TEST_METRICS = new WindowMetrics(TEST_BOUNDS,
@@ -51,8 +57,7 @@
     private static final SplitAttributes DEFAULT_SPLIT_ATTRIBUTES =
             new SplitAttributes.Builder().build();
     private static final SplitAttributes TABLETOP_HINGE_ATTRIBUTES = new SplitAttributes.Builder()
-            .setSplitType(SplitAttributes.SplitType.splitByHinge(
-                    SplitAttributes.SplitType.splitEqually()))
+            .setSplitType(SPLIT_TYPE_HINGE)
             .setLayoutDirection(SplitAttributes.LayoutDirection.TOP_TO_BOTTOM)
             .build();
 
@@ -113,7 +118,7 @@
             return params.getDefaultSplitAttributes();
         } else {
             return new SplitAttributes.Builder()
-                    .setSplitType(SplitAttributes.SplitType.expandContainers())
+                    .setSplitType(SplitAttributes.SplitType.SPLIT_TYPE_EXPAND)
                     .build();
         }
     }
diff --git a/window/window-testing/src/androidTest/java/androidx/window/testing/emedding/SplitAttributesCalculatorParamsTestingTest.kt b/window/window-testing/src/test/java/androidx/window/testing/embedding/SplitAttributesCalculatorParamsTestingTest.kt
similarity index 89%
rename from window/window-testing/src/androidTest/java/androidx/window/testing/emedding/SplitAttributesCalculatorParamsTestingTest.kt
rename to window/window-testing/src/test/java/androidx/window/testing/embedding/SplitAttributesCalculatorParamsTestingTest.kt
index ff94890..975c16b 100644
--- a/window/window-testing/src/androidTest/java/androidx/window/testing/emedding/SplitAttributesCalculatorParamsTestingTest.kt
+++ b/window/window-testing/src/test/java/androidx/window/testing/embedding/SplitAttributesCalculatorParamsTestingTest.kt
@@ -14,26 +14,31 @@
  * limitations under the License.
  */
 
-package androidx.window.testing.emedding
+package androidx.window.testing.embedding
 
 import androidx.window.testing.layout.FoldingFeature as testFoldingFeature
 import android.content.res.Configuration
 import android.graphics.Rect
 import androidx.window.core.ExperimentalWindowApi
 import androidx.window.embedding.SplitAttributes
+import androidx.window.embedding.SplitAttributes.SplitType.Companion.SPLIT_TYPE_EXPAND
+import androidx.window.embedding.SplitAttributes.SplitType.Companion.SPLIT_TYPE_HINGE
 import androidx.window.embedding.SplitAttributesCalculatorParams
 import androidx.window.layout.FoldingFeature
 import androidx.window.layout.WindowLayoutInfo
 import androidx.window.layout.WindowMetrics
-import androidx.window.testing.embedding.TestSplitAttributesCalculatorParams
 import androidx.window.testing.layout.TestWindowLayoutInfo
 import java.util.Collections
 import org.junit.Assert.assertEquals
 import org.junit.Assert.assertNull
 import org.junit.Assert.assertTrue
 import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
 
 /** Test class to verify [TestSplitAttributesCalculatorParams]. */
+@OptIn(ExperimentalWindowApi::class)
+@RunWith(RobolectricTestRunner::class)
 class SplitAttributesCalculatorParamsTestingTest {
     /** Verifies if the default values of [TestSplitAttributesCalculatorParams] are as expected. */
     @Test
@@ -90,7 +95,7 @@
             params.defaultSplitAttributes
         } else {
             SplitAttributes.Builder()
-                .setSplitType(SplitAttributes.SplitType.expandContainers())
+                .setSplitType(SPLIT_TYPE_EXPAND)
                 .build()
         }
     }
@@ -100,7 +105,7 @@
         private val TEST_METRICS = WindowMetrics(TEST_BOUNDS)
         private val DEFAULT_SPLIT_ATTRIBUTES = SplitAttributes.Builder().build()
         private val TABLETOP_HINGE_ATTRIBUTES = SplitAttributes.Builder()
-            .setSplitType(SplitAttributes.SplitType.splitByHinge())
+            .setSplitType(SPLIT_TYPE_HINGE)
             .setLayoutDirection(SplitAttributes.LayoutDirection.TOP_TO_BOTTOM)
             .build()
     }
diff --git a/window/window/api/current.txt b/window/window/api/current.txt
index 8d9284a..a7806dc 100644
--- a/window/window/api/current.txt
+++ b/window/window/api/current.txt
@@ -126,54 +126,22 @@
   public static final class SplitAttributes.LayoutDirection.Companion {
   }
 
-  public static class SplitAttributes.SplitType {
-    method public static final androidx.window.embedding.SplitAttributes.SplitType.ExpandContainersSplitType expandContainers();
-    method public static final androidx.window.embedding.SplitAttributes.SplitType.RatioSplitType ratio(@FloatRange(from=0.0, to=1.0, fromInclusive=false, toInclusive=false) float ratio);
-    method public static final androidx.window.embedding.SplitAttributes.SplitType.HingeSplitType splitByHinge(optional androidx.window.embedding.SplitAttributes.SplitType fallbackSplitType);
-    method public static final androidx.window.embedding.SplitAttributes.SplitType.RatioSplitType splitEqually();
+  public static final class SplitAttributes.SplitType {
+    method public static androidx.window.embedding.SplitAttributes.SplitType ratio(@FloatRange(from=0.0, to=1.0, fromInclusive=false, toInclusive=false) float ratio);
     field public static final androidx.window.embedding.SplitAttributes.SplitType.Companion Companion;
+    field public static final androidx.window.embedding.SplitAttributes.SplitType SPLIT_TYPE_EQUAL;
+    field public static final androidx.window.embedding.SplitAttributes.SplitType SPLIT_TYPE_EXPAND;
+    field public static final androidx.window.embedding.SplitAttributes.SplitType SPLIT_TYPE_HINGE;
   }
 
   public static final class SplitAttributes.SplitType.Companion {
-    method public androidx.window.embedding.SplitAttributes.SplitType.ExpandContainersSplitType expandContainers();
-    method public androidx.window.embedding.SplitAttributes.SplitType.RatioSplitType ratio(@FloatRange(from=0.0, to=1.0, fromInclusive=false, toInclusive=false) float ratio);
-    method public androidx.window.embedding.SplitAttributes.SplitType.HingeSplitType splitByHinge(optional androidx.window.embedding.SplitAttributes.SplitType fallbackSplitType);
-    method public androidx.window.embedding.SplitAttributes.SplitType.RatioSplitType splitEqually();
-  }
-
-  public static final class SplitAttributes.SplitType.ExpandContainersSplitType extends androidx.window.embedding.SplitAttributes.SplitType {
-  }
-
-  public static final class SplitAttributes.SplitType.HingeSplitType extends androidx.window.embedding.SplitAttributes.SplitType {
-    method public androidx.window.embedding.SplitAttributes.SplitType getFallbackSplitType();
-    property public final androidx.window.embedding.SplitAttributes.SplitType fallbackSplitType;
-  }
-
-  public static final class SplitAttributes.SplitType.RatioSplitType extends androidx.window.embedding.SplitAttributes.SplitType {
-    method public float getRatio();
-    property public final float ratio;
-  }
-
-  public final class SplitAttributesCalculatorParams {
-    method public boolean getAreDefaultConstraintsSatisfied();
-    method public androidx.window.embedding.SplitAttributes getDefaultSplitAttributes();
-    method public android.content.res.Configuration getParentConfiguration();
-    method public androidx.window.layout.WindowLayoutInfo getParentWindowLayoutInfo();
-    method public androidx.window.layout.WindowMetrics getParentWindowMetrics();
-    method public String? getSplitRuleTag();
-    property public final boolean areDefaultConstraintsSatisfied;
-    property public final androidx.window.embedding.SplitAttributes defaultSplitAttributes;
-    property public final android.content.res.Configuration parentConfiguration;
-    property public final androidx.window.layout.WindowLayoutInfo parentWindowLayoutInfo;
-    property public final androidx.window.layout.WindowMetrics parentWindowMetrics;
-    property public final String? splitRuleTag;
+    method public androidx.window.embedding.SplitAttributes.SplitType ratio(@FloatRange(from=0.0, to=1.0, fromInclusive=false, toInclusive=false) float ratio);
   }
 
   public final class SplitController {
-    method public void addSplitListener(android.app.Activity activity, java.util.concurrent.Executor executor, androidx.core.util.Consumer<java.util.List<androidx.window.embedding.SplitInfo>> consumer);
     method public static androidx.window.embedding.SplitController getInstance(android.content.Context context);
     method public androidx.window.embedding.SplitController.SplitSupportStatus getSplitSupportStatus();
-    method public void removeSplitListener(androidx.core.util.Consumer<java.util.List<androidx.window.embedding.SplitInfo>> consumer);
+    method public kotlinx.coroutines.flow.Flow<java.util.List<androidx.window.embedding.SplitInfo>> splitInfoList(android.app.Activity activity);
     property public final androidx.window.embedding.SplitController.SplitSupportStatus splitSupportStatus;
     field public static final androidx.window.embedding.SplitController.Companion Companion;
   }
diff --git a/window/window/api/public_plus_experimental_current.txt b/window/window/api/public_plus_experimental_current.txt
index 40f06ac..01b7d59 100644
--- a/window/window/api/public_plus_experimental_current.txt
+++ b/window/window/api/public_plus_experimental_current.txt
@@ -167,35 +167,19 @@
   public static final class SplitAttributes.LayoutDirection.Companion {
   }
 
-  public static class SplitAttributes.SplitType {
-    method public static final androidx.window.embedding.SplitAttributes.SplitType.ExpandContainersSplitType expandContainers();
-    method public static final androidx.window.embedding.SplitAttributes.SplitType.RatioSplitType ratio(@FloatRange(from=0.0, to=1.0, fromInclusive=false, toInclusive=false) float ratio);
-    method public static final androidx.window.embedding.SplitAttributes.SplitType.HingeSplitType splitByHinge(optional androidx.window.embedding.SplitAttributes.SplitType fallbackSplitType);
-    method public static final androidx.window.embedding.SplitAttributes.SplitType.RatioSplitType splitEqually();
+  public static final class SplitAttributes.SplitType {
+    method public static androidx.window.embedding.SplitAttributes.SplitType ratio(@FloatRange(from=0.0, to=1.0, fromInclusive=false, toInclusive=false) float ratio);
     field public static final androidx.window.embedding.SplitAttributes.SplitType.Companion Companion;
+    field public static final androidx.window.embedding.SplitAttributes.SplitType SPLIT_TYPE_EQUAL;
+    field public static final androidx.window.embedding.SplitAttributes.SplitType SPLIT_TYPE_EXPAND;
+    field public static final androidx.window.embedding.SplitAttributes.SplitType SPLIT_TYPE_HINGE;
   }
 
   public static final class SplitAttributes.SplitType.Companion {
-    method public androidx.window.embedding.SplitAttributes.SplitType.ExpandContainersSplitType expandContainers();
-    method public androidx.window.embedding.SplitAttributes.SplitType.RatioSplitType ratio(@FloatRange(from=0.0, to=1.0, fromInclusive=false, toInclusive=false) float ratio);
-    method public androidx.window.embedding.SplitAttributes.SplitType.HingeSplitType splitByHinge(optional androidx.window.embedding.SplitAttributes.SplitType fallbackSplitType);
-    method public androidx.window.embedding.SplitAttributes.SplitType.RatioSplitType splitEqually();
+    method public androidx.window.embedding.SplitAttributes.SplitType ratio(@FloatRange(from=0.0, to=1.0, fromInclusive=false, toInclusive=false) float ratio);
   }
 
-  public static final class SplitAttributes.SplitType.ExpandContainersSplitType extends androidx.window.embedding.SplitAttributes.SplitType {
-  }
-
-  public static final class SplitAttributes.SplitType.HingeSplitType extends androidx.window.embedding.SplitAttributes.SplitType {
-    method public androidx.window.embedding.SplitAttributes.SplitType getFallbackSplitType();
-    property public final androidx.window.embedding.SplitAttributes.SplitType fallbackSplitType;
-  }
-
-  public static final class SplitAttributes.SplitType.RatioSplitType extends androidx.window.embedding.SplitAttributes.SplitType {
-    method public float getRatio();
-    property public final float ratio;
-  }
-
-  public final class SplitAttributesCalculatorParams {
+  @androidx.window.core.ExperimentalWindowApi public final class SplitAttributesCalculatorParams {
     method public boolean getAreDefaultConstraintsSatisfied();
     method public androidx.window.embedding.SplitAttributes getDefaultSplitAttributes();
     method public android.content.res.Configuration getParentConfiguration();
@@ -211,14 +195,15 @@
   }
 
   public final class SplitController {
-    method public void addSplitListener(android.app.Activity activity, java.util.concurrent.Executor executor, androidx.core.util.Consumer<java.util.List<androidx.window.embedding.SplitInfo>> consumer);
+    method @Deprecated @androidx.window.core.ExperimentalWindowApi public void addSplitListener(android.app.Activity activity, java.util.concurrent.Executor executor, androidx.core.util.Consumer<java.util.List<androidx.window.embedding.SplitInfo>> consumer);
     method @androidx.window.core.ExperimentalWindowApi public void clearSplitAttributesCalculator();
     method public static androidx.window.embedding.SplitController getInstance(android.content.Context context);
     method public androidx.window.embedding.SplitController.SplitSupportStatus getSplitSupportStatus();
     method @androidx.window.core.ExperimentalWindowApi public boolean isSplitAttributesCalculatorSupported();
     method @Deprecated @androidx.window.core.ExperimentalWindowApi public boolean isSplitSupported();
-    method public void removeSplitListener(androidx.core.util.Consumer<java.util.List<androidx.window.embedding.SplitInfo>> consumer);
+    method @Deprecated @androidx.window.core.ExperimentalWindowApi public void removeSplitListener(androidx.core.util.Consumer<java.util.List<androidx.window.embedding.SplitInfo>> consumer);
     method @androidx.window.core.ExperimentalWindowApi public void setSplitAttributesCalculator(kotlin.jvm.functions.Function1<? super androidx.window.embedding.SplitAttributesCalculatorParams,androidx.window.embedding.SplitAttributes> calculator);
+    method public kotlinx.coroutines.flow.Flow<java.util.List<androidx.window.embedding.SplitInfo>> splitInfoList(android.app.Activity activity);
     property public final androidx.window.embedding.SplitController.SplitSupportStatus splitSupportStatus;
     field public static final androidx.window.embedding.SplitController.Companion Companion;
   }
diff --git a/window/window/api/restricted_current.txt b/window/window/api/restricted_current.txt
index 8d9284a..a7806dc 100644
--- a/window/window/api/restricted_current.txt
+++ b/window/window/api/restricted_current.txt
@@ -126,54 +126,22 @@
   public static final class SplitAttributes.LayoutDirection.Companion {
   }
 
-  public static class SplitAttributes.SplitType {
-    method public static final androidx.window.embedding.SplitAttributes.SplitType.ExpandContainersSplitType expandContainers();
-    method public static final androidx.window.embedding.SplitAttributes.SplitType.RatioSplitType ratio(@FloatRange(from=0.0, to=1.0, fromInclusive=false, toInclusive=false) float ratio);
-    method public static final androidx.window.embedding.SplitAttributes.SplitType.HingeSplitType splitByHinge(optional androidx.window.embedding.SplitAttributes.SplitType fallbackSplitType);
-    method public static final androidx.window.embedding.SplitAttributes.SplitType.RatioSplitType splitEqually();
+  public static final class SplitAttributes.SplitType {
+    method public static androidx.window.embedding.SplitAttributes.SplitType ratio(@FloatRange(from=0.0, to=1.0, fromInclusive=false, toInclusive=false) float ratio);
     field public static final androidx.window.embedding.SplitAttributes.SplitType.Companion Companion;
+    field public static final androidx.window.embedding.SplitAttributes.SplitType SPLIT_TYPE_EQUAL;
+    field public static final androidx.window.embedding.SplitAttributes.SplitType SPLIT_TYPE_EXPAND;
+    field public static final androidx.window.embedding.SplitAttributes.SplitType SPLIT_TYPE_HINGE;
   }
 
   public static final class SplitAttributes.SplitType.Companion {
-    method public androidx.window.embedding.SplitAttributes.SplitType.ExpandContainersSplitType expandContainers();
-    method public androidx.window.embedding.SplitAttributes.SplitType.RatioSplitType ratio(@FloatRange(from=0.0, to=1.0, fromInclusive=false, toInclusive=false) float ratio);
-    method public androidx.window.embedding.SplitAttributes.SplitType.HingeSplitType splitByHinge(optional androidx.window.embedding.SplitAttributes.SplitType fallbackSplitType);
-    method public androidx.window.embedding.SplitAttributes.SplitType.RatioSplitType splitEqually();
-  }
-
-  public static final class SplitAttributes.SplitType.ExpandContainersSplitType extends androidx.window.embedding.SplitAttributes.SplitType {
-  }
-
-  public static final class SplitAttributes.SplitType.HingeSplitType extends androidx.window.embedding.SplitAttributes.SplitType {
-    method public androidx.window.embedding.SplitAttributes.SplitType getFallbackSplitType();
-    property public final androidx.window.embedding.SplitAttributes.SplitType fallbackSplitType;
-  }
-
-  public static final class SplitAttributes.SplitType.RatioSplitType extends androidx.window.embedding.SplitAttributes.SplitType {
-    method public float getRatio();
-    property public final float ratio;
-  }
-
-  public final class SplitAttributesCalculatorParams {
-    method public boolean getAreDefaultConstraintsSatisfied();
-    method public androidx.window.embedding.SplitAttributes getDefaultSplitAttributes();
-    method public android.content.res.Configuration getParentConfiguration();
-    method public androidx.window.layout.WindowLayoutInfo getParentWindowLayoutInfo();
-    method public androidx.window.layout.WindowMetrics getParentWindowMetrics();
-    method public String? getSplitRuleTag();
-    property public final boolean areDefaultConstraintsSatisfied;
-    property public final androidx.window.embedding.SplitAttributes defaultSplitAttributes;
-    property public final android.content.res.Configuration parentConfiguration;
-    property public final androidx.window.layout.WindowLayoutInfo parentWindowLayoutInfo;
-    property public final androidx.window.layout.WindowMetrics parentWindowMetrics;
-    property public final String? splitRuleTag;
+    method public androidx.window.embedding.SplitAttributes.SplitType ratio(@FloatRange(from=0.0, to=1.0, fromInclusive=false, toInclusive=false) float ratio);
   }
 
   public final class SplitController {
-    method public void addSplitListener(android.app.Activity activity, java.util.concurrent.Executor executor, androidx.core.util.Consumer<java.util.List<androidx.window.embedding.SplitInfo>> consumer);
     method public static androidx.window.embedding.SplitController getInstance(android.content.Context context);
     method public androidx.window.embedding.SplitController.SplitSupportStatus getSplitSupportStatus();
-    method public void removeSplitListener(androidx.core.util.Consumer<java.util.List<androidx.window.embedding.SplitInfo>> consumer);
+    method public kotlinx.coroutines.flow.Flow<java.util.List<androidx.window.embedding.SplitInfo>> splitInfoList(android.app.Activity activity);
     property public final androidx.window.embedding.SplitController.SplitSupportStatus splitSupportStatus;
     field public static final androidx.window.embedding.SplitController.Companion Companion;
   }
diff --git a/window/window/samples/src/main/java/androidx.window.samples.embedding/SplitAttributesCalculatorSamples.kt b/window/window/samples/src/main/java/androidx.window.samples.embedding/SplitAttributesCalculatorSamples.kt
index 2cba736..9e69205 100644
--- a/window/window/samples/src/main/java/androidx.window.samples.embedding/SplitAttributesCalculatorSamples.kt
+++ b/window/window/samples/src/main/java/androidx.window.samples.embedding/SplitAttributesCalculatorSamples.kt
@@ -21,6 +21,9 @@
 import androidx.annotation.Sampled
 import androidx.window.core.ExperimentalWindowApi
 import androidx.window.embedding.SplitAttributes
+import androidx.window.embedding.SplitAttributes.SplitType.Companion.SPLIT_TYPE_EXPAND
+import androidx.window.embedding.SplitAttributes.SplitType.Companion.SPLIT_TYPE_HINGE
+import androidx.window.embedding.SplitAttributes.SplitType.Companion.SPLIT_TYPE_EQUAL
 import androidx.window.embedding.SplitController
 import androidx.window.layout.FoldingFeature
 
@@ -50,7 +53,7 @@
                 // Split the parent container that followed by the hinge if the hinge separates the
                 // parent window.
                 return@setSplitAttributesCalculator SplitAttributes.Builder()
-                    .setSplitType(SplitAttributes.SplitType.splitByHinge())
+                    .setSplitType(SPLIT_TYPE_HINGE)
                     .setLayoutDirection(
                         if (foldingState.orientation == FoldingFeature.Orientation.HORIZONTAL) {
                             SplitAttributes.LayoutDirection.TOP_TO_BOTTOM
@@ -67,14 +70,14 @@
             ) {
                 // Split the parent container equally and vertically if the device is in landscape.
                 SplitAttributes.Builder()
-                    .setSplitType(SplitAttributes.SplitType.splitEqually())
+                    .setSplitType(SPLIT_TYPE_EQUAL)
                     .setLayoutDirection(SplitAttributes.LayoutDirection.LOCALE)
                     .setAnimationBackgroundColor(SplitAttributes.BackgroundColor.color(Color.GRAY))
                     .build()
             } else {
                 // Expand containers if the device is in portrait or the width is less than 600 dp.
                 SplitAttributes.Builder()
-                    .setSplitType(SplitAttributes.SplitType.expandContainers())
+                    .setSplitType(SPLIT_TYPE_EXPAND)
                     .build()
             }
         }
@@ -104,7 +107,7 @@
             } else {
                 // Fallback to expand the secondary container
                 builder
-                    .setSplitType(SplitAttributes.SplitType.expandContainers())
+                    .setSplitType(SPLIT_TYPE_EXPAND)
                     .build()
             }
         }
@@ -122,7 +125,7 @@
             val areDefaultConstraintsSatisfied = params.areDefaultConstraintsSatisfied
 
             val expandContainersAttrs = SplitAttributes.Builder()
-                .setSplitType(SplitAttributes.SplitType.expandContainers())
+                .setSplitType(SPLIT_TYPE_EXPAND)
                 .build()
             if (!areDefaultConstraintsSatisfied) {
                 return@setSplitAttributesCalculator expandContainersAttrs
@@ -130,7 +133,7 @@
             // Always expand containers for the splitRule tagged as
             // TAG_SPLIT_RULE_EXPAND_IN_PORTRAIT if the device is in portrait
             // even if [areDefaultConstraintsSatisfied] reports true.
-            if (bounds.height() > bounds.width() && TAG_SPLIT_RULE_EXPAND_IN_PORTRAIT.equals(tag)) {
+            if (bounds.height() > bounds.width() && TAG_SPLIT_RULE_EXPAND_IN_PORTRAIT == tag) {
                 return@setSplitAttributesCalculator expandContainersAttrs
             }
             // Otherwise, use the default splitAttributes.
@@ -138,6 +141,22 @@
         }
 }
 
+@OptIn(ExperimentalWindowApi::class)
+@Sampled
+fun fallbackToExpandContainersForSplitTypeHinge() {
+    SplitController.getInstance(context).setSplitAttributesCalculator { params ->
+        SplitAttributes.Builder()
+            .setSplitType(
+                if (params.parentWindowLayoutInfo.displayFeatures
+                        .filterIsInstance<FoldingFeature>().isNotEmpty()) {
+                    SPLIT_TYPE_HINGE
+                } else {
+                    SPLIT_TYPE_EXPAND
+                }
+            ).build()
+    }
+}
+
 /** Assume it's a valid [Application]... */
 val context = Application()
 const val TAG_SPLIT_RULE_MAIN = "main"
diff --git a/window/window/src/androidTest/java/androidx/window/embedding/EmbeddingAdapterTest.kt b/window/window/src/androidTest/java/androidx/window/embedding/EmbeddingAdapterTest.kt
index 0ce0920..631dfc3 100644
--- a/window/window/src/androidTest/java/androidx/window/embedding/EmbeddingAdapterTest.kt
+++ b/window/window/src/androidTest/java/androidx/window/embedding/EmbeddingAdapterTest.kt
@@ -25,8 +25,10 @@
 import androidx.window.core.ExtensionsUtil
 import androidx.window.core.PredicateAdapter
 import androidx.window.embedding.SplitAttributes.SplitType
+import androidx.window.embedding.SplitAttributes.SplitType.Companion.SPLIT_TYPE_HINGE
 import androidx.window.extensions.WindowExtensions
 import androidx.window.extensions.embedding.SplitAttributes.LayoutDirection.TOP_TO_BOTTOM
+import androidx.window.extensions.embedding.SplitAttributes.SplitType.RatioSplitType
 import com.nhaarman.mockitokotlin2.mock
 import com.nhaarman.mockitokotlin2.whenever
 import org.junit.Assert.assertEquals
@@ -57,7 +59,7 @@
             ActivityStack(ArrayList(), isEmpty = true),
             ActivityStack(ArrayList(), isEmpty = true),
             SplitAttributes.Builder()
-                .setSplitType(SplitType.splitEqually())
+                .setSplitType(SplitType.SPLIT_TYPE_EQUAL)
                 .setLayoutDirection(SplitAttributes.LayoutDirection.LOCALE)
                 .setAnimationBackgroundColor(SplitAttributes.BackgroundColor.DEFAULT)
                 .build()
@@ -80,7 +82,7 @@
             ActivityStack(ArrayList(), isEmpty = true),
             ActivityStack(ArrayList(), isEmpty = true),
             SplitAttributes.Builder()
-                .setSplitType(SplitType.expandContainers())
+                .setSplitType(SplitType.SPLIT_TYPE_EXPAND)
                 .setLayoutDirection(SplitAttributes.LayoutDirection.LOCALE)
                 .build()
         )
@@ -120,11 +122,8 @@
             createTestOEMActivityStack(ArrayList(), true),
             createTestOEMActivityStack(ArrayList(), true),
             OEMSplitAttributes.Builder()
-                .setSplitType(
-                    OEMSplitAttributes.SplitType.HingeSplitType(
-                        OEMSplitAttributes.SplitType.RatioSplitType(0.3f)
-                    )
-                ).setLayoutDirection(TOP_TO_BOTTOM)
+                .setSplitType(OEMSplitAttributes.SplitType.HingeSplitType(RatioSplitType(0.5f)))
+                .setLayoutDirection(TOP_TO_BOTTOM)
                 .setAnimationBackgroundColor(Color.YELLOW)
                 .build(),
         )
@@ -132,7 +131,7 @@
             ActivityStack(ArrayList(), isEmpty = true),
             ActivityStack(ArrayList(), isEmpty = true),
             SplitAttributes.Builder()
-                .setSplitType(SplitType.splitByHinge(SplitType.ratio(0.3f)))
+                .setSplitType(SPLIT_TYPE_HINGE)
                 .setLayoutDirection(SplitAttributes.LayoutDirection.TOP_TO_BOTTOM)
                 .setAnimationBackgroundColor(SplitAttributes.BackgroundColor.color(Color.YELLOW))
                 .build()
diff --git a/window/window/src/androidTest/java/androidx/window/embedding/SplitPairFilterTest.kt b/window/window/src/androidTest/java/androidx/window/embedding/SplitPairFilterTest.kt
deleted file mode 100644
index 4c75bd7..0000000
--- a/window/window/src/androidTest/java/androidx/window/embedding/SplitPairFilterTest.kt
+++ /dev/null
@@ -1,143 +0,0 @@
-/*
- * Copyright 2022 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package androidx.window.embedding
-
-import android.app.Activity
-import android.content.ComponentName
-import android.content.Intent
-import com.google.common.truth.Truth.assertWithMessage
-import com.nhaarman.mockitokotlin2.doReturn
-import com.nhaarman.mockitokotlin2.mock
-import com.nhaarman.mockitokotlin2.whenever
-import org.junit.Before
-import org.junit.Test
-
-class SplitPairFilterTest {
-    private val intent1 = Intent()
-    private val intent2 = Intent()
-    private val activity1 = mock<Activity> {
-        on { intent } doReturn intent1
-        on { componentName } doReturn COMPONENT_1
-    }
-    private val activity2 = mock<Activity> {
-        on { intent } doReturn intent2
-        on { componentName } doReturn COMPONENT_2
-    }
-
-    @Before
-    fun setUp() {
-        intent1.component = COMPONENT_1
-        intent2.component = COMPONENT_2
-    }
-
-    @Test
-    fun testMatch_WithoutAction() {
-        val filter = SplitPairFilter(
-            COMPONENT_1,
-            COMPONENT_2,
-            null /* secondaryActivityIntentAction */
-        )
-
-        assertWithMessage("#matchesActivityPair must be true because intents match")
-            .that(filter.matchesActivityPair(activity1, activity2)).isTrue()
-        assertWithMessage("#matchesActivityIntentPair must be true because intents match")
-            .that(filter.matchesActivityIntentPair(activity1, intent2)).isTrue()
-
-        assertWithMessage("#matchesActivityPair must be false because secondary doesn't match")
-            .that(filter.matchesActivityPair(activity1, activity1)).isFalse()
-        assertWithMessage(
-            "#matchesActivityIntentPair must be false because secondary doesn't match"
-        )
-            .that(filter.matchesActivityIntentPair(activity1, intent1)).isFalse()
-
-        assertWithMessage("#matchesActivityPair must be false because primary doesn't match")
-            .that(filter.matchesActivityPair(activity2, activity2)).isFalse()
-        assertWithMessage(
-            "#matchesActivityIntentPair must be false because primary doesn't match"
-        )
-            .that(filter.matchesActivityIntentPair(activity2, intent2)).isFalse()
-    }
-
-    @Test
-    fun testMatch_WithAction() {
-        val filter = SplitPairFilter(COMPONENT_1, WILDCARD, ACTION)
-
-        assertWithMessage("#matchesActivityPair must be false because intent has no action")
-            .that(filter.matchesActivityPair(activity1, activity2)).isFalse()
-        assertWithMessage("#matchesActivityIntentPair must be false because intent has no action")
-            .that(filter.matchesActivityIntentPair(activity1, intent2)).isFalse()
-
-        intent2.action = ACTION
-
-        assertWithMessage("#matchesActivityPair must be true because intent.action matches")
-            .that(filter.matchesActivityPair(activity1, activity2)).isTrue()
-        assertWithMessage("#matchesActivityIntentPair must be true because intent.action matches")
-            .that(filter.matchesActivityIntentPair(activity1, intent2)).isTrue()
-    }
-
-    @Test
-    fun testMatch_WithIntentPackage() {
-        val filter = SplitPairFilter(
-            COMPONENT_1,
-            CLASS_WILDCARD,
-            null /* secondaryActivityIntentAction */
-        )
-        intent2.component = null
-        intent2.`package` = CLASS_WILDCARD.packageName
-        doReturn(COMPONENT_1).whenever(activity2).componentName
-
-        assertWithMessage("#matchesActivityPair must be true because intent.package matches")
-            .that(filter.matchesActivityPair(activity1, activity2)).isTrue()
-        assertWithMessage("#matchesActivityIntentPair must be true because intent.package matches")
-            .that(filter.matchesActivityIntentPair(activity1, intent2)).isTrue()
-
-        intent2.component = COMPONENT_1
-
-        assertWithMessage(
-            "#matchesActivityPair must be false because intent.component doesn't match"
-        )
-            .that(filter.matchesActivityPair(activity1, activity1)).isFalse()
-        assertWithMessage(
-            "#matchesActivityIntentPair must be false because intent.component doesn't match"
-        )
-            .that(filter.matchesActivityIntentPair(activity1, intent1)).isFalse()
-    }
-
-    @Test
-    fun testMatch_EmptyIntentWithWildcard() {
-        val filter = SplitPairFilter(
-            WILDCARD,
-            WILDCARD,
-            null /* secondaryActivityIntentAction */
-        )
-        intent1.component = null
-        intent2.component = null
-
-        assertWithMessage("#matchesActivityPair must be true because rule is wildcard")
-            .that(filter.matchesActivityPair(activity1, activity2)).isTrue()
-        assertWithMessage("#matchesActivityIntentPair must be true because rule is wildcard")
-            .that(filter.matchesActivityIntentPair(activity1, intent2)).isTrue()
-    }
-
-    companion object {
-        private const val ACTION = "action.test"
-        private val COMPONENT_1 = ComponentName("a.b.c", "a.b.c.TestActivity")
-        private val COMPONENT_2 = ComponentName("d.e.f", "d.e.f.TestActivity")
-        private val WILDCARD = ComponentName("*", "*")
-        private val CLASS_WILDCARD = ComponentName("d.e.f", "*")
-    }
-}
\ No newline at end of file
diff --git a/window/window/src/main/java/androidx/window/embedding/EmbeddingAdapter.kt b/window/window/src/main/java/androidx/window/embedding/EmbeddingAdapter.kt
index da6a05b..111a858 100644
--- a/window/window/src/main/java/androidx/window/embedding/EmbeddingAdapter.kt
+++ b/window/window/src/main/java/androidx/window/embedding/EmbeddingAdapter.kt
@@ -34,6 +34,7 @@
 import android.content.Intent
 import android.util.LayoutDirection
 import android.view.WindowMetrics
+import androidx.window.core.ExperimentalWindowApi
 import androidx.window.core.ExtensionsUtil
 import androidx.window.core.PredicateAdapter
 import androidx.window.embedding.SplitAttributes.LayoutDirection.Companion.BOTTOM_TO_TOP
@@ -42,9 +43,14 @@
 import androidx.window.embedding.SplitAttributes.LayoutDirection.Companion.RIGHT_TO_LEFT
 import androidx.window.embedding.SplitAttributes.LayoutDirection.Companion.TOP_TO_BOTTOM
 import androidx.window.embedding.SplitAttributes.SplitType
+import androidx.window.embedding.SplitAttributes.SplitType.Companion.SPLIT_TYPE_HINGE
+import androidx.window.embedding.SplitAttributes.SplitType.Companion.SPLIT_TYPE_EQUAL
+import androidx.window.embedding.SplitAttributes.SplitType.Companion.SPLIT_TYPE_EXPAND
+import androidx.window.embedding.SplitAttributes.SplitType.Companion.ratio
 import androidx.window.extensions.WindowExtensions
 import androidx.window.extensions.core.util.function.Function
 import androidx.window.extensions.core.util.function.Predicate
+import androidx.window.extensions.embedding.SplitAttributes.SplitType.RatioSplitType
 import androidx.window.extensions.embedding.SplitPairRule.FINISH_ADJACENT
 import androidx.window.extensions.embedding.SplitPairRule.FINISH_ALWAYS
 import androidx.window.extensions.embedding.SplitPairRule.FINISH_NEVER
@@ -90,8 +96,14 @@
 
     internal fun translate(splitAttributes: OEMSplitAttributes): SplitAttributes =
         SplitAttributes.Builder()
-            .setSplitType(translate(splitAttributes.splitType))
-            .setLayoutDirection(
+            .setSplitType(
+                when (val splitType = splitAttributes.splitType) {
+                    is OEMSplitType.HingeSplitType -> SPLIT_TYPE_HINGE
+                    is OEMSplitType.ExpandContainersSplitType -> SPLIT_TYPE_EXPAND
+                    is OEMSplitType.RatioSplitType -> ratio(splitType.ratio)
+                    else -> throw IllegalArgumentException("Unknown split type: $splitType")
+                }
+            ).setLayoutDirection(
                 when (val layoutDirection = splitAttributes.layoutDirection) {
                     OEMSplitAttributes.LayoutDirection.LEFT_TO_RIGHT -> LEFT_TO_RIGHT
                     OEMSplitAttributes.LayoutDirection.RIGHT_TO_LEFT -> RIGHT_TO_LEFT
@@ -108,32 +120,14 @@
             )
             .build()
 
-    private fun translate(splitType: OEMSplitType): SplitType =
-        when (splitType) {
-            is OEMSplitType.RatioSplitType -> translate(splitType)
-            is OEMSplitType.ExpandContainersSplitType -> SplitType.expandContainers()
-            is OEMSplitType.HingeSplitType -> translate(splitType)
-            else -> throw IllegalArgumentException("Unsupported split type: $splitType")
-        }
-
-    private fun translate(hinge: OEMSplitType.HingeSplitType): SplitType.HingeSplitType =
-        SplitType.splitByHinge(
-            when (val splitType = hinge.fallbackSplitType) {
-                is OEMSplitType.ExpandContainersSplitType -> SplitType.expandContainers()
-                is OEMSplitType.RatioSplitType -> translate(splitType)
-                else -> throw IllegalArgumentException("Unsupported split type: $splitType")
-            }
-        )
-
-    private fun translate(splitRatio: OEMSplitType.RatioSplitType): SplitType.RatioSplitType =
-        SplitType.ratio(splitRatio.ratio)
-
+    @OptIn(ExperimentalWindowApi::class)
     fun translateSplitAttributesCalculator(
         calculator: (SplitAttributesCalculatorParams) -> SplitAttributes
     ): Function<OEMSplitAttributesCalculatorParams, OEMSplitAttributes> = Function { oemParams ->
             translateSplitAttributes(calculator.invoke(translate(oemParams)))
         }
 
+    @OptIn(ExperimentalWindowApi::class)
     @SuppressLint("NewApi")
     fun translate(
         params: OEMSplitAttributesCalculatorParams
@@ -202,7 +196,7 @@
         }
     }
 
-    internal fun translateSplitAttributes(splitAttributes: SplitAttributes): OEMSplitAttributes {
+    fun translateSplitAttributes(splitAttributes: SplitAttributes): OEMSplitAttributes {
         require(vendorApiLevel >= WindowExtensions.VENDOR_API_LEVEL_2)
         // To workaround the "unused" error in ktlint. It is necessary to translate SplitAttributes
         // from WM Jetpack version to WM extension version.
@@ -227,25 +221,22 @@
     private fun translateSplitType(splitType: SplitType): OEMSplitType {
         require(vendorApiLevel >= WindowExtensions.VENDOR_API_LEVEL_2)
         return when (splitType) {
-            is SplitType.HingeSplitType -> translateHinge(splitType)
-            is SplitType.ExpandContainersSplitType -> OEMSplitType.ExpandContainersSplitType()
-            is SplitType.RatioSplitType -> translateRatio(splitType)
-            else -> throw IllegalArgumentException("Unsupported splitType: $splitType")
+            SPLIT_TYPE_HINGE -> OEMSplitType.HingeSplitType(
+                translateSplitType(SPLIT_TYPE_EQUAL)
+            )
+            SPLIT_TYPE_EXPAND -> OEMSplitType.ExpandContainersSplitType()
+            else -> {
+                val ratio = splitType.value
+                if (ratio > 0.0 && ratio < 1.0) {
+                    RatioSplitType(ratio)
+                } else {
+                    throw IllegalArgumentException("Unsupported SplitType: $splitType with value:" +
+                        " ${splitType.value}")
+                }
+            }
         }
     }
 
-    private fun translateHinge(hinge: SplitType.HingeSplitType): OEMSplitType.HingeSplitType =
-        OEMSplitType.HingeSplitType(
-            when (val splitType = hinge.fallbackSplitType) {
-                is SplitType.ExpandContainersSplitType -> OEMSplitType.ExpandContainersSplitType()
-                is SplitType.RatioSplitType -> translateRatio(splitType)
-                else -> throw IllegalArgumentException("Unsupported splitType: $splitType")
-            }
-        )
-
-    private fun translateRatio(splitRatio: SplitType.RatioSplitType): OEMSplitType.RatioSplitType =
-        OEMSplitType.RatioSplitType(splitRatio.ratio)
-
     private fun translateSplitPlaceholderRule(
         context: Context,
         rule: SplitPlaceholderRule,
@@ -487,7 +478,7 @@
          * higher.
          */
         private fun isSplitAttributesSupported(attrs: SplitAttributes) =
-            attrs.splitType is SplitType.RatioSplitType &&
+            attrs.splitType.value in 0.0..1.0 && attrs.splitType.value != 1.0f &&
                 attrs.layoutDirection in arrayOf(LEFT_TO_RIGHT, RIGHT_TO_LEFT, LOCALE)
 
         @SuppressLint("ClassVerificationFailure", "NewApi")
@@ -510,40 +501,16 @@
                 splitRule.checkParentMetrics(context, windowMetrics)
             }
 
-        // TODO(b/267391190): Remove the NoSuchMethodError in EmbeddingAdapter
-        fun translateCompat(splitInfo: OEMSplitInfo): SplitInfo {
-            val primaryActivityStack = splitInfo.primaryActivityStack
-            val isPrimaryStackEmpty = try {
-                primaryActivityStack.isEmpty
-            } catch (e: NoSuchMethodError) {
-                // Users may use older library which #isEmpty hasn't existed. Provide a fallback
-                // value for this case to avoid crash.
-                false
-            }
-            val primaryFragment = ActivityStack(
-                primaryActivityStack.activities,
-                isPrimaryStackEmpty
+        fun translateCompat(splitInfo: OEMSplitInfo): SplitInfo = SplitInfo(
+                ActivityStack(
+                    splitInfo.primaryActivityStack.activities,
+                    splitInfo.primaryActivityStack.isEmpty,
+                ),
+                ActivityStack(
+                    splitInfo.secondaryActivityStack.activities,
+                    splitInfo.secondaryActivityStack.isEmpty,
+                ),
+                getSplitAttributesCompat(splitInfo),
             )
-
-            val secondaryActivityStack = splitInfo.secondaryActivityStack
-            val isSecondaryStackEmpty = try {
-                secondaryActivityStack.isEmpty
-            } catch (e: NoSuchMethodError) {
-                // Users may use older library which #isEmpty hasn't existed. Provide a fallback
-                // value for this case to avoid crash.
-                false
-            }
-            val secondaryFragment = ActivityStack(
-                secondaryActivityStack.activities,
-                isSecondaryStackEmpty
-            )
-
-            val splitAttributes = getSplitAttributesCompat(splitInfo)
-            return SplitInfo(
-                primaryFragment,
-                secondaryFragment,
-                splitAttributes
-            )
-        }
     }
 }
diff --git a/window/window/src/main/java/androidx/window/embedding/EmbeddingBackend.kt b/window/window/src/main/java/androidx/window/embedding/EmbeddingBackend.kt
index 7e3413e..3819052 100644
--- a/window/window/src/main/java/androidx/window/embedding/EmbeddingBackend.kt
+++ b/window/window/src/main/java/androidx/window/embedding/EmbeddingBackend.kt
@@ -50,6 +50,7 @@
 
     fun isActivityEmbedded(activity: Activity): Boolean
 
+    @ExperimentalWindowApi
     fun setSplitAttributesCalculator(
         calculator: (SplitAttributesCalculatorParams) -> SplitAttributes
     )
diff --git a/window/window/src/main/java/androidx/window/embedding/EmbeddingCompat.kt b/window/window/src/main/java/androidx/window/embedding/EmbeddingCompat.kt
index b8ac44e5..6a1248c 100644
--- a/window/window/src/main/java/androidx/window/embedding/EmbeddingCompat.kt
+++ b/window/window/src/main/java/androidx/window/embedding/EmbeddingCompat.kt
@@ -22,6 +22,7 @@
 import android.util.Log
 import androidx.window.core.BuildConfig
 import androidx.window.core.ConsumerAdapter
+import androidx.window.core.ExperimentalWindowApi
 import androidx.window.core.ExtensionsUtil
 import androidx.window.core.VerificationMode
 import androidx.window.embedding.EmbeddingInterfaceCompat.EmbeddingCallbackInterface
@@ -89,6 +90,7 @@
         return embeddingExtension.isActivityEmbedded(activity)
     }
 
+    @ExperimentalWindowApi
     override fun setSplitAttributesCalculator(
         calculator: (SplitAttributesCalculatorParams) -> SplitAttributes
     ) {
diff --git a/window/window/src/main/java/androidx/window/embedding/EmbeddingInterfaceCompat.kt b/window/window/src/main/java/androidx/window/embedding/EmbeddingInterfaceCompat.kt
index 8c74aec..c9830a5 100644
--- a/window/window/src/main/java/androidx/window/embedding/EmbeddingInterfaceCompat.kt
+++ b/window/window/src/main/java/androidx/window/embedding/EmbeddingInterfaceCompat.kt
@@ -17,6 +17,7 @@
 package androidx.window.embedding
 
 import android.app.Activity
+import androidx.window.core.ExperimentalWindowApi
 import androidx.window.extensions.embedding.ActivityEmbeddingComponent
 
 /**
@@ -35,6 +36,7 @@
 
     fun isActivityEmbedded(activity: Activity): Boolean
 
+    @ExperimentalWindowApi
     fun setSplitAttributesCalculator(
         calculator: (SplitAttributesCalculatorParams) -> SplitAttributes
     )
diff --git a/window/window/src/main/java/androidx/window/embedding/ExtensionEmbeddingBackend.kt b/window/window/src/main/java/androidx/window/embedding/ExtensionEmbeddingBackend.kt
index 16840d5..34ebbda 100644
--- a/window/window/src/main/java/androidx/window/embedding/ExtensionEmbeddingBackend.kt
+++ b/window/window/src/main/java/androidx/window/embedding/ExtensionEmbeddingBackend.kt
@@ -24,6 +24,7 @@
 import androidx.collection.ArraySet
 import androidx.core.util.Consumer
 import androidx.window.core.ConsumerAdapter
+import androidx.window.core.ExperimentalWindowApi
 import androidx.window.core.ExtensionsUtil
 import androidx.window.core.PredicateAdapter
 import androidx.window.embedding.EmbeddingInterfaceCompat.EmbeddingCallbackInterface
@@ -305,6 +306,7 @@
         return embeddingExtension?.isActivityEmbedded(activity) ?: false
     }
 
+    @ExperimentalWindowApi
     override fun setSplitAttributesCalculator(
         calculator: (SplitAttributesCalculatorParams) -> SplitAttributes
     ) {
diff --git a/window/window/src/main/java/androidx/window/embedding/SplitAttributes.kt b/window/window/src/main/java/androidx/window/embedding/SplitAttributes.kt
index 64219d2..645a56d 100644
--- a/window/window/src/main/java/androidx/window/embedding/SplitAttributes.kt
+++ b/window/window/src/main/java/androidx/window/embedding/SplitAttributes.kt
@@ -25,7 +25,7 @@
 import androidx.window.core.VerificationMode
 import androidx.window.embedding.SplitAttributes.BackgroundColor
 import androidx.window.embedding.SplitAttributes.LayoutDirection.Companion.LOCALE
-import androidx.window.embedding.SplitAttributes.SplitType.Companion.splitEqually
+import androidx.window.embedding.SplitAttributes.SplitType.Companion.SPLIT_TYPE_EQUAL
 
 /**
  * Attributes that describe how the parent window (typically the activity task
@@ -65,7 +65,7 @@
      * The split type attribute. Defaults to an equal split of the parent window
      * for the primary and secondary containers.
      */
-    val splitType: SplitType = splitEqually(),
+    val splitType: SplitType = SPLIT_TYPE_EQUAL,
 
     /**
      * The layout direction attribute for the parent window split. The default
@@ -90,7 +90,7 @@
      * The type of parent window split, which defines the proportion of the
      * parent window occupied by the primary and secondary activity containers.
      */
-    open class SplitType internal constructor(
+    class SplitType internal constructor(
 
         /**
          * The description of this `SplitType`.
@@ -135,53 +135,9 @@
         override fun hashCode(): Int = description.hashCode() + 31 * value.hashCode()
 
         /**
-         * A window split that's based on the ratio of the size of the primary
-         * container to the size of the parent window.
-         *
-         * @see SplitAttributes.SplitType.ratio
-         */
-        class RatioSplitType internal constructor(
-
-            /**
-             * The proportion of the parent window occupied by the primary
-             * container of the split.
-             */
-            @FloatRange(from = 0.0, to = 1.0, fromInclusive = false, toInclusive = false)
-            val ratio: Float
-
-        ) : SplitType("ratio:$ratio", ratio)
-
-        /**
-         * A window split in which the primary and secondary activity containers
-         * each occupy the entire parent window.
-         *
-         * The secondary container overlays the primary container.
-         *
-         * @see SplitAttributes.SplitType.ExpandContainersSplitType
-         */
-        class ExpandContainersSplitType internal constructor() : SplitType("expandContainer", 0.0f)
-
-        /**
-         * A parent window split that conforms to a hinge or separating fold in
-         * the device display.
-         *
-         * @see SplitAttributes.SplitType.splitByHinge
-         */
-        class HingeSplitType internal constructor(
-
-            /**
-             * The split type to use if a split based on the device hinge or
-             * separating fold cannot be determined.
-             */
-            val fallbackSplitType: SplitType
-
-        ) : SplitType("hinge, fallback=$fallbackSplitType", -1.0f)
-
-        /**
          * Methods that create various split types.
          */
         companion object {
-
             /**
              * Creates a split type based on the proportion of the parent window
              * occupied by the primary container of the split.
@@ -197,13 +153,13 @@
              *
              * @param ratio The proportion of the parent window occupied by the
              *     primary container of the split.
-             * @return An instance of [RatioSplitType] with the specified ratio.
+             * @return An instance of `SplitType` with the specified ratio.
              */
             @JvmStatic
             fun ratio(
                 @FloatRange(from = 0.0, to = 1.0, fromInclusive = false, toInclusive = false)
                 ratio: Float
-            ): RatioSplitType {
+            ): SplitType {
                 val checkedRatio = ratio.startSpecification(
                     TAG,
                     VerificationMode.STRICT
@@ -211,45 +167,37 @@
                     "Use SplitType.expandContainers() instead of 0 or 1.") {
                     ratio in 0.0..1.0 && ratio !in arrayOf(0.0f, 1.0f)
                 }.compute()!!
-                return RatioSplitType(checkedRatio)
+                return SplitType("ratio:$checkedRatio", checkedRatio)
             }
 
-            private val EXPAND_CONTAINERS = ExpandContainersSplitType()
-
             /**
-             * Creates a split type in which the primary and secondary activity
-             * containers each expand to fill the parent window; the secondary
-             * container overlays the primary container.
+             * A split type in which the primary and secondary activity containers each expand to
+             * fill the parent window; the secondary container overlays the primary container.
              *
-             * Use this method with the function set in
+             * It is useful to use this `SplitType` with the function set in
              * [SplitController.setSplitAttributesCalculator] to expand the activity containers in
-             * some device states. The following sample shows how to always fill the parent bounds
-             * if the device is in portrait orientation:
+             * some device or window states. The following sample shows how to always fill the
+             * parent bounds if the device is in portrait orientation:
              *
              * @sample androidx.window.samples.embedding.expandContainersInPortrait
-             *
-             * @return An instance of [ExpandContainersSplitType].
              */
-            @JvmStatic
-            fun expandContainers(): ExpandContainersSplitType = EXPAND_CONTAINERS
+            @JvmField
+            val SPLIT_TYPE_EXPAND = SplitType("expandContainers", 0.0f)
 
             /**
-             * Creates a split type in which the primary and secondary
-             * containers occupy equal portions of the parent window.
+             * A split type in which the primary and secondary containers occupy equal portions of
+             * the parent window.
              *
              * Serves as the default [SplitType].
-             *
-             * @return A `RatioSplitType` in which the activity containers
-             *     occupy equal portions of the parent window.
              */
-            @JvmStatic
-            fun splitEqually(): RatioSplitType = ratio(0.5f)
+            @JvmField
+            val SPLIT_TYPE_EQUAL = ratio(0.5f)
 
             /**
-             * Creates a split type in which the split ratio conforms to the
+             * A split type in which the split ratio conforms to the
              * position of a hinge or separating fold in the device display.
              *
-             * The split type is created only if:
+             * The split type works only if:
              * <ul>
              *     <li>The host task is not in multi-window mode (e.g.,
              *         split-screen mode or picture-in-picture mode)</li>
@@ -268,40 +216,29 @@
              *     </li>
              * </ul>
              *
-             * Otherwise, the method falls back to `fallbackSplitType`.
+             * Otherwise, this `SplitType` fallback to show the split with [SPLIT_TYPE_EQUAL].
              *
-             * @param fallbackSplitType The split type to use if a split based
-             *     on the device hinge or separating fold cannot be determined.
-             *     Can be a [RatioSplitType] or [ExpandContainersSplitType].
-             *     Defaults to [SplitType.splitEqually].
-             * @return An instance of [HingeSplitType] with a fallback split
-             *     type.
+             * If the app wants to have another fallback `SplitType` if [SPLIT_TYPE_HINGE] cannot
+             * be applied. It is suggested to use [SplitController.setSplitAttributesCalculator] to
+             * customize the fallback `SplitType`.
+             *
+             * The following sample shows how to fallback to [SPLIT_TYPE_EXPAND]
+             * if there's no hinge area in the parent window container bounds.
+             *
+             * @sample androidx.window.samples.embedding.fallbackToExpandContainersForSplitTypeHinge
              */
-            @JvmStatic
-            fun splitByHinge(
-                fallbackSplitType: SplitType = splitEqually()
-            ): HingeSplitType {
-                val checkedType = fallbackSplitType.startSpecification(
-                    TAG,
-                    VerificationMode.STRICT
-                ).require(
-                    "FallbackSplitType must be a RatioSplitType or ExpandContainerSplitType"
-                ) {
-                    fallbackSplitType is RatioSplitType ||
-                        fallbackSplitType is ExpandContainersSplitType
-                }.compute()!!
-                return HingeSplitType(checkedType)
-            }
+            @JvmField
+            val SPLIT_TYPE_HINGE = SplitType("hinge", -1.0f)
 
+            // TODO(b/241044092): add XML support to SPLIT_TYPE_HINGE
             /**
              * Returns a `SplitType` with the given `value`.
              */
             @SuppressLint("Range") // value = 0.0 is covered.
-            @JvmStatic
             internal fun buildSplitTypeFromValue(
                 @FloatRange(from = 0.0, to = 1.0, toInclusive = false) value: Float
-            ) = if (value == EXPAND_CONTAINERS.value) {
-                    expandContainers()
+            ) = if (value == SPLIT_TYPE_EXPAND.value) {
+                    SPLIT_TYPE_EXPAND
                 } else {
                     ratio(value)
                 }
@@ -558,7 +495,7 @@
      *    window background color.
      */
     class Builder {
-        private var splitType: SplitType = splitEqually()
+        private var splitType = SPLIT_TYPE_EQUAL
         private var layoutDirection = LOCALE
         private var animationBackgroundColor = BackgroundColor.DEFAULT
 
diff --git a/window/window/src/main/java/androidx/window/embedding/SplitAttributesCalculatorParams.kt b/window/window/src/main/java/androidx/window/embedding/SplitAttributesCalculatorParams.kt
index 40453be..8da62c6 100644
--- a/window/window/src/main/java/androidx/window/embedding/SplitAttributesCalculatorParams.kt
+++ b/window/window/src/main/java/androidx/window/embedding/SplitAttributesCalculatorParams.kt
@@ -18,6 +18,7 @@
 
 import android.content.res.Configuration
 import androidx.annotation.RestrictTo
+import androidx.window.core.ExperimentalWindowApi
 import androidx.window.layout.WindowLayoutInfo
 import androidx.window.layout.WindowMetrics
 
@@ -26,6 +27,7 @@
  * [SplitController.setSplitAttributesCalculator] and references the corresponding [SplitRule] by
  * [splitRuleTag] if [SplitPairRule.tag] is specified.
  */
+@ExperimentalWindowApi
 class SplitAttributesCalculatorParams @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) constructor(
     /** The parent container's [WindowMetrics] */
     val parentWindowMetrics: WindowMetrics,
diff --git a/window/window/src/main/java/androidx/window/embedding/SplitController.kt b/window/window/src/main/java/androidx/window/embedding/SplitController.kt
index 3cddfc5..575a07d 100644
--- a/window/window/src/main/java/androidx/window/embedding/SplitController.kt
+++ b/window/window/src/main/java/androidx/window/embedding/SplitController.kt
@@ -22,6 +22,7 @@
 import android.os.Build
 import android.util.Log
 import androidx.annotation.DoNotInline
+import androidx.annotation.GuardedBy
 import androidx.annotation.RequiresApi
 import androidx.core.util.Consumer
 import androidx.window.WindowProperties
@@ -33,6 +34,13 @@
 import java.util.concurrent.Executor
 import java.util.concurrent.locks.ReentrantLock
 import kotlin.concurrent.withLock
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.asCoroutineDispatcher
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.callbackFlow
+import kotlinx.coroutines.launch
 
 /**
 * A singleton controller class that gets information about the currently active activity
@@ -50,10 +58,68 @@
     private val embeddingBackend: EmbeddingBackend = ExtensionEmbeddingBackend
         .getInstance(applicationContext)
 
-    // TODO(b/258356512): Make this method a flow API
+    /** A [ReentrantLock] to protect against concurrent access to [consumerToJobMap]. */
+    private val lock = ReentrantLock()
+    @GuardedBy("lock")
+    private val consumerToJobMap = mutableMapOf<Consumer<List<SplitInfo>>, Job>()
+
     /**
-     * Registers a listener for updates about the active split state(s) that this
-     * activity is part of. An activity can be in zero, one or more active splits.
+     * @deprecated Use [splitInfoList] for kotlin usages or delegate to
+     * [androidx.window.java.embedding.SplitControllerCallbackAdapter.addSplitListener] for Java
+     * usages.
+     */
+    @Deprecated(
+        message = "Replace to provide Flow API to get SplitInfo list",
+        replaceWith = ReplaceWith(
+            expression = "splitInfoList",
+            imports = ["androidx.window.embedding.SplitController"]
+        )
+    )
+    @ExperimentalWindowApi
+    fun addSplitListener(
+        activity: Activity,
+        executor: Executor,
+        consumer: Consumer<List<SplitInfo>>
+    ) {
+        lock.withLock {
+            if (consumerToJobMap[consumer] != null) {
+                return
+            }
+            val scope = CoroutineScope(executor.asCoroutineDispatcher())
+            consumerToJobMap[consumer] = scope.launch {
+                splitInfoList(activity).collect { splitInfoList ->
+                    consumer.accept(splitInfoList) }
+            }
+        }
+    }
+
+    /**
+     * @deprecated Use [splitInfoList] for kotlin usages or delegate to
+     * [androidx.window.java.embedding.SplitControllerCallbackAdapter.removeSplitListener] for
+     * Java usages.
+     */
+    @Deprecated(
+        message = "Replace to provide Flow API to get SplitInfo list",
+        replaceWith = ReplaceWith(
+            expression = "splitInfoList",
+            imports = ["androidx.window.embedding.SplitController"]
+        )
+    )
+    @ExperimentalWindowApi
+    fun removeSplitListener(
+        consumer: Consumer<List<SplitInfo>>
+    ) {
+        lock.withLock {
+            consumerToJobMap[consumer]?.cancel()
+            consumerToJobMap.remove(consumer)
+        }
+    }
+
+    /**
+     * A [Flow] of [SplitInfo] list that contains the current split states that this [activity] is
+     * part of.
+     *
+     * An activity can be in zero, one or more [active splits][SplitInfo].
      * More than one active split is possible if an activity created multiple
      * containers to side, stacked on top of each other. Or it can be in two
      * different splits at the same time - in a secondary container for one (it was
@@ -62,29 +128,15 @@
      * bottom to top by their z-order, more recent splits appearing later.
      * Guaranteed to be called at least once to report the most recent state.
      *
-     * @param activity only split that this [Activity] is part of will be reported.
-     * @param executor when there is an update to the active split state(s), the [consumer] will be
-     * invoked on this [Executor].
-     * @param consumer [Consumer] that will be invoked on the [executor] when there is an update to
-     * the active split state(s).
+     * @param activity The [Activity] that is interested in getting the split states
+     * @return a [Flow] of [SplitInfo] list that includes this [activity]
      */
-    fun addSplitListener(
-        activity: Activity,
-        executor: Executor,
-        consumer: Consumer<List<SplitInfo>>
-    ) {
-        embeddingBackend.addSplitListenerForActivity(activity, executor, consumer)
-    }
-
-    /**
-     * Unregisters a listener that was previously registered via [addSplitListener].
-     *
-     * @param consumer the previously registered [Consumer] to unregister.
-     */
-    fun removeSplitListener(
-        consumer: Consumer<List<SplitInfo>>
-    ) {
-        embeddingBackend.removeSplitListenerForActivity(consumer)
+    fun splitInfoList(activity: Activity): Flow<List<SplitInfo>> = callbackFlow {
+        val listener = Consumer { info: List<SplitInfo> -> trySend(info) }
+        embeddingBackend.addSplitListenerForActivity(activity, Runnable::run, listener)
+        awaitClose {
+            embeddingBackend.removeSplitListenerForActivity(listener)
+        }
     }
 
     /**
diff --git a/window/window/src/main/java/androidx/window/embedding/SplitPairRule.kt b/window/window/src/main/java/androidx/window/embedding/SplitPairRule.kt
index 2cf3650..67af938 100644
--- a/window/window/src/main/java/androidx/window/embedding/SplitPairRule.kt
+++ b/window/window/src/main/java/androidx/window/embedding/SplitPairRule.kt
@@ -28,7 +28,8 @@
  * Split configuration rules for activity pairs. Define when activities that were launched on top
  * should be placed adjacent to the one below, and the visual properties of such splits. Can be set
  * either by [RuleController.setRules] or [RuleController.addRule]. The rules are always
- * applied only to activities that will be started after the rules were set.
+ * applied only to activities that will be started from the activity fills the whole parent task
+ * container or activity in the primary split after the rules were set.
  */
 class SplitPairRule : SplitRule {
 
diff --git a/window/window/src/androidTest/java/androidx/window/embedding/SplitAttributesTest.kt b/window/window/src/test/java/androidx/window/embedding/SplitAttributesTest.kt
similarity index 83%
rename from window/window/src/androidTest/java/androidx/window/embedding/SplitAttributesTest.kt
rename to window/window/src/test/java/androidx/window/embedding/SplitAttributesTest.kt
index 69c507d..a0ff9a7 100644
--- a/window/window/src/androidTest/java/androidx/window/embedding/SplitAttributesTest.kt
+++ b/window/window/src/test/java/androidx/window/embedding/SplitAttributesTest.kt
@@ -1,5 +1,5 @@
 /*
- * Copyright 2022 The Android Open Source Project
+ * 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.
@@ -24,37 +24,43 @@
 import androidx.window.embedding.SplitAttributes.LayoutDirection.Companion.RIGHT_TO_LEFT
 import androidx.window.embedding.SplitAttributes.LayoutDirection.Companion.TOP_TO_BOTTOM
 import androidx.window.embedding.SplitAttributes.SplitType
+import androidx.window.embedding.SplitAttributes.SplitType.Companion.SPLIT_TYPE_EXPAND
+import androidx.window.embedding.SplitAttributes.SplitType.Companion.SPLIT_TYPE_HINGE
+import androidx.window.embedding.SplitAttributes.SplitType.Companion.SPLIT_TYPE_EQUAL
 import org.junit.Assert.assertEquals
 import org.junit.Assert.assertNotEquals
 import org.junit.Assert.assertThrows
 import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
 
 /** Test class to verify [SplitAttributes] */
+@RunWith(RobolectricTestRunner::class)
 class SplitAttributesTest {
     @Test
     fun testSplitAttributesEquals() {
         val attrs1 = SplitAttributes.Builder()
-            .setSplitType(SplitType.splitEqually())
+            .setSplitType(SPLIT_TYPE_EQUAL)
             .setLayoutDirection(LOCALE)
             .setAnimationBackgroundColor(SplitAttributes.BackgroundColor.DEFAULT)
             .build()
         val attrs2 = SplitAttributes.Builder()
-            .setSplitType(SplitType.splitByHinge())
+            .setSplitType(SPLIT_TYPE_HINGE)
             .setLayoutDirection(LOCALE)
             .setAnimationBackgroundColor(SplitAttributes.BackgroundColor.DEFAULT)
             .build()
         val attrs3 = SplitAttributes.Builder()
-            .setSplitType(SplitType.splitByHinge())
+            .setSplitType(SPLIT_TYPE_HINGE)
             .setLayoutDirection(TOP_TO_BOTTOM)
             .setAnimationBackgroundColor(SplitAttributes.BackgroundColor.DEFAULT)
             .build()
         val attrs4 = SplitAttributes.Builder()
-            .setSplitType(SplitType.splitByHinge())
+            .setSplitType(SPLIT_TYPE_HINGE)
             .setLayoutDirection(TOP_TO_BOTTOM)
             .setAnimationBackgroundColor(SplitAttributes.BackgroundColor.color(Color.GREEN))
             .build()
         val attrs5 = SplitAttributes.Builder()
-            .setSplitType(SplitType.splitByHinge())
+            .setSplitType(SPLIT_TYPE_HINGE)
             .setLayoutDirection(TOP_TO_BOTTOM)
             .setAnimationBackgroundColor(SplitAttributes.BackgroundColor.color(Color.GREEN))
             .build()
@@ -78,20 +84,12 @@
     @Test
     fun testTypesEquals() {
         val splitTypes = arrayOf(
-            SplitType.splitEqually(),
-            SplitType.expandContainers(),
-            SplitType.splitByHinge(),
-            SplitType.splitByHinge(SplitType.expandContainers())
+            SPLIT_TYPE_EQUAL,
+            SPLIT_TYPE_EXPAND,
+            SPLIT_TYPE_HINGE,
         )
 
         for ((i, type1) in splitTypes.withIndex()) {
-            if (type1 is SplitType.RatioSplitType) {
-                assertEquals(
-                    "Two SplitTypes must regarded as equal if their ratios are the same.",
-                    type1, SplitType.ratio(type1.value)
-                )
-                assertEquals(type1.hashCode(), SplitType.ratio(type1.value).hashCode())
-            }
             for ((j, type2) in splitTypes.withIndex()) {
                 if (i == j) {
                     assertEquals(type1, type2)
@@ -102,6 +100,12 @@
                 }
             }
         }
+
+        assertEquals(
+            "Two SplitTypes must regarded as equal if their ratios are the same.",
+            SPLIT_TYPE_EQUAL, SplitType.ratio(0.5f)
+        )
+        assertEquals(SPLIT_TYPE_EQUAL.hashCode(), SplitType.ratio(0.5f).hashCode())
     }
 
     @Test
diff --git a/window/window/src/test/java/androidx/window/embedding/SplitPairFilterTest.kt b/window/window/src/test/java/androidx/window/embedding/SplitPairFilterTest.kt
new file mode 100644
index 0000000..5e35eae
--- /dev/null
+++ b/window/window/src/test/java/androidx/window/embedding/SplitPairFilterTest.kt
@@ -0,0 +1,221 @@
+/*
+ * Copyright 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.window.embedding
+
+import android.app.Activity
+import android.content.ComponentName
+import android.content.Intent
+import com.google.common.truth.Truth.assertWithMessage
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.kotlin.doReturn
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.whenever
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+class SplitPairFilterTest {
+    private val intent1 = Intent()
+    private val intent2 = Intent()
+    private val activity1 = mock<Activity> {
+        on { intent } doReturn intent1
+        on { componentName } doReturn COMPONENT_1
+    }
+    private val activity2 = mock<Activity> {
+        on { intent } doReturn intent2
+        on { componentName } doReturn COMPONENT_2
+    }
+
+    @Test(expected = IllegalArgumentException::class)
+    fun packageNameMustNotBeEmpty_primary() {
+        val emptyPackageComponent = ComponentName(EMPTY, FAKE_CLASS)
+        SplitPairFilter(emptyPackageComponent, COMPONENT_1, null)
+    }
+
+    @Test(expected = IllegalArgumentException::class)
+    fun packageNameMustNotBeEmpty_secondary() {
+        val emptyPackageComponent = ComponentName(EMPTY, FAKE_CLASS)
+        SplitPairFilter(COMPONENT_1, emptyPackageComponent, null)
+    }
+
+    @Test(expected = IllegalArgumentException::class)
+    fun classNameMustNotBeEmpty_primary() {
+        val emptyClassComponent = ComponentName(FAKE_PACKAGE, EMPTY)
+        SplitPairFilter(emptyClassComponent, COMPONENT_1, null)
+    }
+
+    @Test(expected = IllegalArgumentException::class)
+    fun classNameMustNotBeEmpty_secondary() {
+        val emptyClassComponent = ComponentName(FAKE_PACKAGE, EMPTY)
+        SplitPairFilter(COMPONENT_1, emptyClassComponent, null)
+    }
+
+    @Test(expected = IllegalArgumentException::class)
+    fun packageNameCannotContainWildcard_primary() {
+        val wildcardPackageComponent = ComponentName(FAKE_PACKAGE_WILDCARD_INSIDE, FAKE_CLASS)
+        SplitPairFilter(wildcardPackageComponent, COMPONENT_1, null)
+    }
+
+    @Test(expected = IllegalArgumentException::class)
+    fun packageNameCannotContainWildcard_secondary() {
+        val wildcardPackageComponent = ComponentName(FAKE_PACKAGE_WILDCARD_INSIDE, FAKE_CLASS)
+        SplitPairFilter(COMPONENT_1, wildcardPackageComponent, null)
+    }
+
+    @Test(expected = IllegalArgumentException::class)
+    fun classNameCannotContainWildcard_primary() {
+        val wildcardInsideClassComponent = ComponentName(FAKE_PACKAGE, FAKE_CLASS_WILDCARD_INSIDE)
+        SplitPairFilter(wildcardInsideClassComponent, COMPONENT_1, null)
+    }
+
+    @Test(expected = IllegalArgumentException::class)
+    fun classNameCannotContainWildcard_secondary() {
+        val wildcardInsideClassComponent = ComponentName(FAKE_PACKAGE, FAKE_CLASS_WILDCARD_INSIDE)
+        SplitPairFilter(COMPONENT_1, wildcardInsideClassComponent, null)
+    }
+
+    @Test
+    fun sameComponentName() {
+        val splitPairFilter = SplitPairFilter(COMPONENT_1, COMPONENT_2, INTENT_ACTION)
+        assertEquals(COMPONENT_1.packageName, splitPairFilter.primaryActivityName.packageName)
+        assertEquals(COMPONENT_1.className, splitPairFilter.primaryActivityName.className)
+        assertEquals(
+            COMPONENT_2.packageName,
+            splitPairFilter.secondaryActivityName.packageName
+        )
+        assertEquals(COMPONENT_2.className, splitPairFilter.secondaryActivityName.className)
+        assertEquals(INTENT_ACTION, splitPairFilter.secondaryActivityIntentAction)
+    }
+
+    @Test
+    fun equalsImpliesSameHashCode() {
+        val first = SplitPairFilter(COMPONENT_1, COMPONENT_2, INTENT_ACTION)
+        val second = SplitPairFilter(COMPONENT_1, COMPONENT_2, INTENT_ACTION)
+
+        assertEquals(first, second)
+        assertEquals(first.hashCode(), second.hashCode())
+    }
+
+    @Test
+    fun testMatch_WithoutAction() {
+        val filter = SplitPairFilter(
+            COMPONENT_1,
+            COMPONENT_2,
+            null /* secondaryActivityIntentAction */
+        )
+        intent1.component = COMPONENT_1
+        intent2.component = COMPONENT_2
+
+        assertWithMessage("#matchesActivityPair must be true because intents match")
+            .that(filter.matchesActivityPair(activity1, activity2)).isTrue()
+        assertWithMessage("#matchesActivityIntentPair must be true because intents match")
+            .that(filter.matchesActivityIntentPair(activity1, intent2)).isTrue()
+
+        assertWithMessage("#matchesActivityPair must be false because secondary doesn't match")
+            .that(filter.matchesActivityPair(activity1, activity1)).isFalse()
+        assertWithMessage(
+            "#matchesActivityIntentPair must be false because secondary doesn't match"
+        )
+            .that(filter.matchesActivityIntentPair(activity1, intent1)).isFalse()
+
+        assertWithMessage("#matchesActivityPair must be false because primary doesn't match")
+            .that(filter.matchesActivityPair(activity2, activity2)).isFalse()
+        assertWithMessage(
+            "#matchesActivityIntentPair must be false because primary doesn't match"
+        )
+            .that(filter.matchesActivityIntentPair(activity2, intent2)).isFalse()
+    }
+
+    @Test
+    fun testMatch_WithAction() {
+        val filter = SplitPairFilter(COMPONENT_1, WILDCARD_COMPONENT, INTENT_ACTION)
+        intent1.component = COMPONENT_1
+        intent2.component = COMPONENT_2
+
+        assertWithMessage("#matchesActivityPair must be false because intent has no action")
+            .that(filter.matchesActivityPair(activity1, activity2)).isFalse()
+        assertWithMessage("#matchesActivityIntentPair must be false because intent has no action")
+            .that(filter.matchesActivityIntentPair(activity1, intent2)).isFalse()
+
+        intent2.action = INTENT_ACTION
+
+        assertWithMessage("#matchesActivityPair must be true because intent.action matches")
+            .that(filter.matchesActivityPair(activity1, activity2)).isTrue()
+        assertWithMessage("#matchesActivityIntentPair must be true because intent.action matches")
+            .that(filter.matchesActivityIntentPair(activity1, intent2)).isTrue()
+    }
+
+    @Test
+    fun testMatch_WithIntentPackage() {
+        val filter = SplitPairFilter(
+            COMPONENT_1,
+            WILDCARD_CLASS_COMPONENT,
+            null /* secondaryActivityIntentAction */
+        )
+        intent1.component = COMPONENT_1
+        intent2.component = null
+        intent2.`package` = WILDCARD_CLASS_COMPONENT.packageName
+        doReturn(COMPONENT_1).whenever(activity2).componentName
+
+        assertWithMessage("#matchesActivityPair must be true because intent.package matches")
+            .that(filter.matchesActivityPair(activity1, activity2)).isTrue()
+        assertWithMessage("#matchesActivityIntentPair must be true because intent.package matches")
+            .that(filter.matchesActivityIntentPair(activity1, intent2)).isTrue()
+
+        intent2.component = COMPONENT_1
+
+        assertWithMessage(
+            "#matchesActivityPair must be false because intent.component doesn't match"
+        )
+            .that(filter.matchesActivityPair(activity1, activity1)).isFalse()
+        assertWithMessage(
+            "#matchesActivityIntentPair must be false because intent.component doesn't match"
+        )
+            .that(filter.matchesActivityIntentPair(activity1, intent1)).isFalse()
+    }
+
+    @Test
+    fun testMatch_EmptyIntentWithWildcard() {
+        val filter = SplitPairFilter(
+            WILDCARD_COMPONENT,
+            WILDCARD_COMPONENT,
+            null /* secondaryActivityIntentAction */
+        )
+        intent1.component = null
+        intent2.component = null
+
+        assertWithMessage("#matchesActivityPair must be true because rule is wildcard")
+            .that(filter.matchesActivityPair(activity1, activity2)).isTrue()
+        assertWithMessage("#matchesActivityIntentPair must be true because rule is wildcard")
+            .that(filter.matchesActivityIntentPair(activity1, intent2)).isTrue()
+    }
+
+    companion object {
+        private const val FAKE_PACKAGE: String = "fake.package"
+        private const val FAKE_PACKAGE_WILDCARD_INSIDE = "fake.*.package"
+        private const val FAKE_CLASS: String = "fake.class.test"
+        private const val FAKE_CLASS_WILDCARD_INSIDE = "fake.*.class"
+
+        private const val EMPTY: String = ""
+        private const val INTENT_ACTION = "fake.action"
+        private val COMPONENT_1 = ComponentName("a.b.c", "a.b.c.TestActivity")
+        private val COMPONENT_2 = ComponentName("d.e.f", "d.e.f.TestActivity")
+        private val WILDCARD_CLASS_COMPONENT = ComponentName(FAKE_PACKAGE, "*")
+        private val WILDCARD_COMPONENT = ComponentName("*", "*")
+    }
+}
\ No newline at end of file
diff --git a/window/window/src/test/java/androidx/window/embedding/SplitRuleTest.kt b/window/window/src/test/java/androidx/window/embedding/SplitRuleTest.kt
new file mode 100644
index 0000000..0de2845
--- /dev/null
+++ b/window/window/src/test/java/androidx/window/embedding/SplitRuleTest.kt
@@ -0,0 +1,40 @@
+/*
+ * 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.window.embedding
+
+import org.junit.Test
+
+/**
+ * Unit tests for [SplitRule] to validate base behavior.
+ */
+class SplitRuleTest {
+
+    @Test(expected = IllegalArgumentException::class)
+    fun test_minWidthMustBeNonNegative() {
+        SplitRule(minWidthDp = -1, defaultSplitAttributes = SplitAttributes())
+    }
+
+    @Test(expected = IllegalArgumentException::class)
+    fun test_minHeightMustBeNonNegative() {
+        SplitRule(minHeightDp = -1, defaultSplitAttributes = SplitAttributes())
+    }
+
+    @Test(expected = IllegalArgumentException::class)
+    fun test_minSmallestWidthMustBeNonNegative() {
+        SplitRule(minSmallestWidthDp = -1, defaultSplitAttributes = SplitAttributes())
+    }
+}
\ No newline at end of file